Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,11 @@ jobs:
# deploy/update stack
sudo env IMAGE_TAG="$IMAGE_TAG" docker stack deploy -c stack.yml --with-registry-auth --resolve-image always --prune pubpub

# show progress and cleanup
# show progress
sudo docker stack services pubpub
sudo docker image prune -f

# Remove images not used by running containers, keeping the last 72h for rollback
sudo docker image prune -a --filter "until=72h" -f

# wait until rollout is complete and then clear cache
wait_rollout() {
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ dist
workers/tasks/export/styles/printDocument.css
pubpub-postgres-test/
pubpub-localdb/
firebase-export-*/
.pubpub_repl_history*
tsconfig.tsbuildinfo
.jest/secret-env.js
Expand Down
2 changes: 2 additions & 0 deletions client/components/Editor/Editor.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ storiesOf('Editor', module)
updatechangeObject(evt);
}}
collaborativeOptions={{
pubId: 'storybook-pub-id',
firebaseRef: draftRef as any,
clientData,
initialDocKey: -1,
Expand Down Expand Up @@ -237,6 +238,7 @@ storiesOf('Editor', module)
}
}}
collaborativeOptions={{
pubId: 'storybook-pub-id',
firebaseRef: draftRef as any,
clientData,
initialDocKey: -1,
Expand Down
3 changes: 2 additions & 1 deletion client/components/Editor/plugins/collaborative/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export default (
) => {
const { collaborativeOptions, isReadOnly, onError = noop } = options;
const {
pubId,
firebaseRef: ref,
onStatusChange = noop,
onUpdateLatestKey = noop,
Expand Down Expand Up @@ -100,7 +101,7 @@ export default (
/* If multiple of saveEveryNSteps, update checkpoint */
const saveEveryNSteps = 100;
if (snapshot.key && snapshot.key % saveEveryNSteps === 0) {
storeCheckpoint(ref, newState.doc, snapshot.key);
storeCheckpoint(pubId, newState.doc, snapshot.key);
}
}

Expand Down
1 change: 1 addition & 0 deletions client/components/Editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type CollaborativeOptions = {
clientData: {
id: null | string;
};
pubId: string;
firebaseRef: firebase.database.Reference;
initialDocKey: number;
onStatusChange?: (status: CollaborativeEditorStatus) => unknown;
Expand Down
36 changes: 20 additions & 16 deletions client/components/Editor/utils/firebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,30 @@ import type { Step } from 'prosemirror-transform';

import type { CompressedChange, CompressedKeyable } from '../types';

import { compressStateJSON, compressStepJSON } from 'prosemirror-compress-pubpub';
import { compressStepJSON } from 'prosemirror-compress-pubpub';
import uuid from 'uuid';

import { apiFetch } from 'client/utils/apiFetch';

export const firebaseTimestamp = { '.sv': 'timestamp' };

export const storeCheckpoint = async (
firebaseRef: firebase.database.Reference,
doc: Node,
keyNumber: number,
) => {
const checkpoint = {
d: compressStateJSON({ doc: doc.toJSON() }).d,
k: keyNumber,
t: firebaseTimestamp,
};
await Promise.all([
firebaseRef.child(`checkpoints/${keyNumber}`).set(checkpoint),
firebaseRef.child('checkpoint').set(checkpoint),
firebaseRef.child(`checkpointMap/${keyNumber}`).set(firebaseTimestamp),
]);
/**
* Store a checkpoint by writing the doc to Postgres via the server API.
* Firebase checkpoints are no longer written — Postgres is the single
* source of truth for checkpoints.
*/
export const storeCheckpoint = async (pubId: string, doc: Node, keyNumber: number) => {
try {
await apiFetch.post('/api/draftCheckpoint', {
pubId,
historyKey: keyNumber,
doc: doc.toJSON(),
});
} catch (err) {
// Non-fatal: the checkpoint is an optimization, not required for correctness.
// The next checkpoint attempt (100 steps later) will try again.
console.error('Failed to store checkpoint:', err);
}
};

export const flattenKeyables = (
Expand Down
2 changes: 2 additions & 0 deletions client/containers/Pub/PubDocument/PubBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const markSentryError = (err: Error) => {
const PubBody = (props: Props) => {
const { editorWrapperRef } = props;
const {
pubData,
noteManager,
updateCollabData,
historyData: { setLatestHistoryKey },
Expand Down Expand Up @@ -84,6 +85,7 @@ const PubBody = (props: Props) => {

const collaborativeOptions = includeCollabPlugin &&
!!firebaseDraftRef && {
pubId: pubData.id,
initialDocKey: initialHistoryKey,
firebaseRef: firebaseDraftRef,
clientData: localCollabUser,
Expand Down
2 changes: 2 additions & 0 deletions server/apiRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { router as customScriptRouter } from './customScript/api';
import { router as devApiRouter } from './dev/api';
import { router as discussionRouter } from './discussion/api';
import { router as doiRouter } from './doi/api';
import { router as draftCheckpointRouter } from './draftCheckpoint/api';
import { router as editorRouter } from './editor/api';
import { router as integrationDataOAuth1Router } from './integrationDataOAuth1/api';
import { router as landingPageFeatureRouter } from './landingPageFeature/api';
Expand Down Expand Up @@ -46,6 +47,7 @@ const apiRouter = Router()
.use(customScriptRouter)
.use(discussionRouter)
.use(doiRouter)
.use(draftCheckpointRouter)
.use(editorRouter)
.use(integrationDataOAuth1Router)
.use(landingPageFeatureRouter)
Expand Down
4 changes: 0 additions & 4 deletions server/draft/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
Model,
PrimaryKey,
Table,
// HasOne,
} from 'sequelize-typescript';
// import { Pub } from '../models';

Expand All @@ -29,7 +28,4 @@ export class Draft extends Model<InferAttributes<Draft>, InferCreationAttributes
@AllowNull(false)
@Column(DataType.STRING)
declare firebasePath: string;

// @HasOne(() => Pub, { as: 'pub', foreignKey: 'draftId' })
// pub?: Pub;
}
39 changes: 39 additions & 0 deletions server/draftCheckpoint/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Router } from 'express';

import { Draft, Pub } from 'server/models';
import { wrap } from 'server/wrap';
import { expect } from 'utils/assert';

import { upsertDraftCheckpoint } from './queries';

export const router = Router();

router.post(
'/api/draftCheckpoint',
wrap(async (req, res) => {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({});
}

const { pubId, historyKey: rawHistoryKey, doc } = req.body;
const historyKey =
typeof rawHistoryKey === 'string' ? parseInt(rawHistoryKey, 10) : rawHistoryKey;
if (!pubId || typeof historyKey !== 'number' || Number.isNaN(historyKey) || !doc) {
return res.status(400).json({ error: 'Missing pubId, historyKey, or doc' });
}

// Look up the draft for this pub
const pub = await Pub.findOne({
where: { id: pubId },
include: [{ model: Draft, as: 'draft' }],
});
if (!pub?.draft) {
return res.status(404).json({ error: 'Pub or draft not found' });
}

const checkpoint = await upsertDraftCheckpoint(pub.draft.id, historyKey, doc, Date.now());

return res.status(200).json({ id: checkpoint.id });
}),
);
73 changes: 73 additions & 0 deletions server/draftCheckpoint/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize';

import type { SerializedModel } from 'types';

import {
AllowNull,
BelongsTo,
Column,
DataType,
Default,
Index,
Model,
PrimaryKey,
Table,
} from 'sequelize-typescript';

import { Draft } from '../models';

@Table
export class DraftCheckpoint extends Model<
InferAttributes<DraftCheckpoint>,
InferCreationAttributes<DraftCheckpoint>
> {
public declare toJSON: <M extends Model>(this: M) => SerializedModel<M>;

@Default(DataType.UUIDV4)
@PrimaryKey
@Column(DataType.UUID)
declare id: CreationOptional<string>;

@AllowNull(false)
@Index
@Column(DataType.UUID)
declare draftId: string;

// The history key this checkpoint represents (i.e. the doc state after applying
// all changes up to and including this key)
@AllowNull(false)
@Column(DataType.INTEGER)
declare historyKey: number;

// The compressed doc JSON (same shape as Doc.content — a ProseMirror doc JSON)
@AllowNull(false)
@Column(DataType.JSONB)
declare doc: Record<string, any>;

// Timestamp of the change at this history key
@Column(DataType.BIGINT)
declare timestamp: number | null;

// Firebase discussion positions at the time of cold storage, keyed by discussion ID.
// Stored so they can be "thawed" back into Firebase when the draft is next loaded.
@Column(DataType.JSONB)
declare discussions: Record<string, any> | null;

// Cumulative StepMap ranges from the latest release historyKey to this checkpoint's
// historyKey. Used to map discussion anchors during release creation when the
// original steps are no longer available in Firebase.
// Shape: Array<number[]> — each inner array is a StepMap.ranges (triples of
// [oldStart, oldSize, newSize]).
@Column(DataType.JSONB)
declare stepMaps: number[][] | null;

// The history key that stepMaps cover up to. After cold storage thaw + editing,
// the checkpoint's historyKey advances but stepMaps still only cover up to this key.
// At release time, Firebase changes from stepMapToKey+1 → currentKey are composed
// with the stored stepMaps.
@Column(DataType.INTEGER)
declare stepMapToKey: number | null;

@BelongsTo(() => Draft, { as: 'draft', foreignKey: 'draftId' })
declare draft?: Draft;
}
61 changes: 61 additions & 0 deletions server/draftCheckpoint/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { DocJson } from 'types';

import { DraftCheckpoint } from 'server/models';

/**
* Create or update the checkpoint for a draft.
* Each draft has at most one checkpoint — an upsert on draftId.
*/
export const upsertDraftCheckpoint = async (
draftId: string,
historyKey: number,
doc: DocJson,
timestamp: number | null = null,
sequelizeTransaction: any = null,
options: {
discussions?: Record<string, any> | null;
stepMaps?: number[][] | null;
stepMapToKey?: number | null;
} = {},
) => {
// Only include optional fields in the update if they were explicitly provided.
// This prevents normal checkpoint writes (from the client API) from clobbering
// stepMaps/discussions that were set during cold storage.
const optionalFields: Partial<
Pick<DraftCheckpoint, 'discussions' | 'stepMaps' | 'stepMapToKey'>
> = {};
if ('discussions' in options) optionalFields.discussions = options.discussions ?? null;
if ('stepMaps' in options) optionalFields.stepMaps = options.stepMaps ?? null;
if ('stepMapToKey' in options) optionalFields.stepMapToKey = options.stepMapToKey ?? null;

const existing = await DraftCheckpoint.findOne({
where: { draftId },
transaction: sequelizeTransaction,
});

if (existing) {
// Only update if the new key is more recent
if (historyKey > existing.historyKey) {
await existing.update(
{ historyKey, doc, timestamp, ...optionalFields },
{ transaction: sequelizeTransaction },
);
}
return existing;
}

return DraftCheckpoint.create(
{ draftId, historyKey, doc, timestamp, ...optionalFields },
{ transaction: sequelizeTransaction },
);
};

/**
* Get the checkpoint for a draft, if one exists.
*/
export const getDraftCheckpoint = async (draftId: string, sequelizeTransaction: any = null) => {
return DraftCheckpoint.findOne({
where: { draftId },
transaction: sequelizeTransaction,
});
};
3 changes: 3 additions & 0 deletions server/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Discussion } from './discussion/model';
import { DiscussionAnchor } from './discussionAnchor/model';
import { Doc } from './doc/model';
import { Draft } from './draft/model';
import { DraftCheckpoint } from './draftCheckpoint/model';
import { EmailChangeToken } from './emailChangeToken/model';
import { Export } from './export/model';
import { ExternalPublication } from './externalPublication/model';
Expand Down Expand Up @@ -78,6 +79,7 @@ sequelize.addModels([
DiscussionAnchor,
Doc,
Draft,
DraftCheckpoint,
EmailChangeToken,
Export,
ExternalPublication,
Expand Down Expand Up @@ -174,6 +176,7 @@ export {
EmailChangeToken,
Doc,
Draft,
DraftCheckpoint,
Export,
ExternalPublication,
FeatureFlag,
Expand Down
2 changes: 1 addition & 1 deletion server/pubHistory/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const restorePubDraftToHistoryKey = async (options: RestorePubOptions) =>
const { pubId, userId, historyKey } = options;
assert(typeof historyKey === 'number' && historyKey >= 0);
const pubDraftRef = await getPubDraftRef(pubId);
const { doc } = await getPubDraftDoc(pubDraftRef, historyKey);
const { doc } = await getPubDraftDoc(pubId, historyKey);
const editor = await editFirebaseDraftByRef(pubDraftRef, userId);

editor.transform((tr, schema) => {
Expand Down
Loading
Loading