Skip to content
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
7 changes: 4 additions & 3 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 @@ -30,6 +29,8 @@ export class Draft extends Model<InferAttributes<Draft>, InferCreationAttributes
@Column(DataType.STRING)
declare firebasePath: string;

// @HasOne(() => Pub, { as: 'pub', foreignKey: 'draftId' })
// pub?: Pub;
// UUID of the DraftCheckpoint row in Postgres, if one exists.
// When set, the checkpoint is loaded from Postgres rather than Firebase.
@Column(DataType.UUID)
declare coldCheckpointId: string | null;
}
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