MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m17s
- Background job at start and every 6 h: log entries older than 30 days (private notes: 7) are removed, documents.log_floor marks the boundary; clients behind it get resync and keep their queued changes - Deleted blocks are purged 30 days after deleted_at via a "purge" log effect, so open editors drop them without reloading; a tombstone stays while a deleted child still points to it - Text for a purged block becomes a new block at the end; inserts under a purged parent go to the top level (server and browser model) - doc_op_results kept for 90 days; expired lease/session maps are swept - Tests: storage, API, engine resync, simulation with compactions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
438 lines
14 KiB
TypeScript
438 lines
14 KiB
TypeScript
// Block tree, operations and effects of collaborative note editing. Port of internal/collab (doc.go, ops.go).
|
|
// applyEffect must behave exactly like the server's Doc.ApplyEffect, because clients replay the server log.
|
|
import { keyBetween, validOrderKey } from './orderKey';
|
|
|
|
export interface Props {
|
|
checked?: boolean;
|
|
collapsed?: boolean;
|
|
lang?: string;
|
|
conflictOf?: string;
|
|
conflictAuthor?: string;
|
|
}
|
|
|
|
export interface Block {
|
|
id: string;
|
|
parentId: string; // '' = top level
|
|
orderKey: string;
|
|
type: string;
|
|
content: string;
|
|
props: Props;
|
|
version: number;
|
|
deleted?: boolean;
|
|
updatedBy?: string;
|
|
}
|
|
|
|
export interface Doc {
|
|
title: string;
|
|
titleVersion: number;
|
|
blocks: Map<string, Block>;
|
|
}
|
|
|
|
export interface PropsPatch {
|
|
type?: string;
|
|
checked?: boolean;
|
|
collapsed?: boolean;
|
|
lang?: string;
|
|
clearConflict?: boolean;
|
|
}
|
|
|
|
// 'purge' only appears as a log effect: log compaction removed old tombstones for good
|
|
export type OpKind = 'insert' | 'set_content' | 'set_props' | 'move' | 'delete' | 'restore' | 'set_title' | 'purge';
|
|
|
|
export interface Op {
|
|
opId: string;
|
|
kind: OpKind;
|
|
blockId?: string;
|
|
baseVersion?: number;
|
|
parentId?: string;
|
|
orderKey?: string;
|
|
type?: string;
|
|
content?: string;
|
|
props?: Props;
|
|
patch?: PropsPatch;
|
|
ids?: string[];
|
|
title?: string;
|
|
}
|
|
|
|
export interface Effect {
|
|
kind: OpKind;
|
|
blockId?: string;
|
|
parentId?: string;
|
|
orderKey?: string;
|
|
type?: string;
|
|
content?: string;
|
|
props?: Props;
|
|
version?: number;
|
|
ids?: string[];
|
|
title?: string;
|
|
titleVersion?: number;
|
|
by?: string;
|
|
}
|
|
|
|
export interface Result {
|
|
opId: string;
|
|
status: 'applied' | 'conflict' | 'rejected';
|
|
reason?: string;
|
|
version?: number;
|
|
conflictBlockId?: string;
|
|
seq?: number;
|
|
}
|
|
|
|
export interface FlatBlock extends Block {
|
|
level: number;
|
|
}
|
|
|
|
export function emptyDoc(): Doc {
|
|
return { title: '', titleVersion: 0, blocks: new Map() };
|
|
}
|
|
|
|
export function cloneDoc(d: Doc): Doc {
|
|
const blocks = new Map<string, Block>();
|
|
d.blocks.forEach((b, id) => blocks.set(id, { ...b, props: { ...b.props } }));
|
|
return { title: d.title, titleVersion: d.titleVersion, blocks };
|
|
}
|
|
|
|
/** Normalizes props so that missing and false/empty values compare equal (the server omits them in JSON). */
|
|
export function normProps(p?: Props): Props {
|
|
const out: Props = {};
|
|
if (p?.checked) out.checked = true;
|
|
if (p?.collapsed) out.collapsed = true;
|
|
if (p?.lang) out.lang = p.lang;
|
|
if (p?.conflictOf) out.conflictOf = p.conflictOf;
|
|
if (p?.conflictAuthor) out.conflictAuthor = p.conflictAuthor;
|
|
return out;
|
|
}
|
|
|
|
export function propsEqual(a?: Props, b?: Props): boolean {
|
|
const x = normProps(a);
|
|
const y = normProps(b);
|
|
return (
|
|
x.checked === y.checked &&
|
|
x.collapsed === y.collapsed &&
|
|
x.lang === y.lang &&
|
|
x.conflictOf === y.conflictOf &&
|
|
x.conflictAuthor === y.conflictAuthor
|
|
);
|
|
}
|
|
|
|
function siblingLess(a: Block, b: Block): number {
|
|
if (a.orderKey !== b.orderKey) return a.orderKey < b.orderKey ? -1 : 1;
|
|
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
}
|
|
|
|
/** Live children of parentId in display order. */
|
|
export function children(d: Doc, parentId: string): Block[] {
|
|
const out: Block[] = [];
|
|
d.blocks.forEach((b) => {
|
|
if (!b.deleted && b.parentId === parentId) out.push(b);
|
|
});
|
|
return out.sort(siblingLess);
|
|
}
|
|
|
|
/** All live blocks in display order with their nesting level. */
|
|
export function flatten(d: Doc): FlatBlock[] {
|
|
const byParent = new Map<string, Block[]>();
|
|
d.blocks.forEach((b) => {
|
|
if (b.deleted) return;
|
|
const list = byParent.get(b.parentId);
|
|
if (list) list.push(b);
|
|
else byParent.set(b.parentId, [b]);
|
|
});
|
|
byParent.forEach((list) => list.sort(siblingLess));
|
|
const out: FlatBlock[] = [];
|
|
const walk = (parent: string, level: number) => {
|
|
for (const b of byParent.get(parent) || []) {
|
|
out.push({ ...b, level });
|
|
walk(b.id, level + 1);
|
|
}
|
|
};
|
|
walk('', 0);
|
|
return out;
|
|
}
|
|
|
|
function subtree(d: Doc, id: string): string[] {
|
|
const kids = new Map<string, string[]>();
|
|
d.blocks.forEach((b) => {
|
|
if (b.deleted) return;
|
|
const list = kids.get(b.parentId);
|
|
if (list) list.push(b.id);
|
|
else kids.set(b.parentId, [b.id]);
|
|
});
|
|
const out = [id];
|
|
for (let i = 0; i < out.length; i++) out.push(...(kids.get(out[i]) || []));
|
|
return [out[0], ...out.slice(1).sort()];
|
|
}
|
|
|
|
function liveAncestor(d: Doc, id: string): string {
|
|
const seen = new Set<string>();
|
|
while (id && !seen.has(id)) {
|
|
seen.add(id);
|
|
const b = d.blocks.get(id);
|
|
if (!b) return '';
|
|
if (!b.deleted) return id;
|
|
id = b.parentId;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function isAncestor(d: Doc, ancestorId: string, id: string): boolean {
|
|
const seen = new Set<string>();
|
|
while (id && !seen.has(id)) {
|
|
if (id === ancestorId) return true;
|
|
seen.add(id);
|
|
const b = d.blocks.get(id);
|
|
if (!b) return false;
|
|
id = b.parentId;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Mutates the document with one effect, exactly like the server. */
|
|
export function applyEffect(d: Doc, e: Effect): void {
|
|
switch (e.kind) {
|
|
case 'insert':
|
|
d.blocks.set(e.blockId!, {
|
|
id: e.blockId!,
|
|
parentId: e.parentId || '',
|
|
orderKey: e.orderKey || '',
|
|
type: e.type || '',
|
|
content: e.content || '',
|
|
props: normProps(e.props),
|
|
version: e.version || 0,
|
|
deleted: false,
|
|
updatedBy: e.by || '',
|
|
});
|
|
break;
|
|
case 'set_content': {
|
|
const b = d.blocks.get(e.blockId!);
|
|
if (b) {
|
|
b.content = e.content || '';
|
|
b.version = e.version || 0;
|
|
b.updatedBy = e.by || '';
|
|
}
|
|
break;
|
|
}
|
|
case 'set_props': {
|
|
const b = d.blocks.get(e.blockId!);
|
|
if (b) {
|
|
b.type = e.type || '';
|
|
if (e.props) b.props = normProps(e.props);
|
|
}
|
|
break;
|
|
}
|
|
case 'move': {
|
|
const b = d.blocks.get(e.blockId!);
|
|
if (b) {
|
|
b.parentId = e.parentId || '';
|
|
b.orderKey = e.orderKey || '';
|
|
}
|
|
break;
|
|
}
|
|
case 'delete':
|
|
for (const id of e.ids || []) {
|
|
const b = d.blocks.get(id);
|
|
if (b) b.deleted = true;
|
|
}
|
|
break;
|
|
case 'restore':
|
|
for (const id of e.ids || []) {
|
|
const b = d.blocks.get(id);
|
|
if (b) b.deleted = false;
|
|
}
|
|
break;
|
|
case 'set_title':
|
|
d.title = e.title || '';
|
|
d.titleVersion = e.titleVersion || 0;
|
|
break;
|
|
case 'purge':
|
|
for (const id of e.ids || []) d.blocks.delete(id);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies an operation the way the server does (without leases). The client uses it to show its own
|
|
* unconfirmed operations on top of the confirmed state; tests use it as a stand-in server.
|
|
* conflictId derives the ID of a conflict copy (the server uses a hash of the op ID).
|
|
*/
|
|
export function applyOp(d: Doc, op: Op, by = '', conflictId: (opId: string) => string = (id) => `blk_cf_${id}`): { effects: Effect[]; result: Result } {
|
|
const effects: Effect[] = [];
|
|
const emit = (e: Effect) => {
|
|
e.by = by;
|
|
applyEffect(d, e);
|
|
effects.push(e);
|
|
};
|
|
const done = (r: Omit<Result, 'opId'>) => ({ effects, result: { opId: op.opId, ...r } as Result });
|
|
const reject = (reason: string) => done({ status: 'rejected', reason });
|
|
|
|
const restore = (ids: string[]) => {
|
|
const set = new Set(ids);
|
|
const sorted = [...ids].sort();
|
|
emit({ kind: 'restore', ids: sorted });
|
|
for (const id of sorted) {
|
|
const b = d.blocks.get(id)!;
|
|
if (b.parentId === '' || set.has(b.parentId)) continue;
|
|
const p = d.blocks.get(b.parentId);
|
|
if (p && !p.deleted) continue;
|
|
emit({ kind: 'move', blockId: b.id, parentId: liveAncestor(d, b.parentId), orderKey: b.orderKey });
|
|
}
|
|
};
|
|
const keyAfter = (b: Block): string => {
|
|
const sibs = children(d, b.parentId);
|
|
let next = '';
|
|
for (const s of sibs) {
|
|
if (s.orderKey > b.orderKey) {
|
|
next = s.orderKey;
|
|
break;
|
|
}
|
|
}
|
|
try {
|
|
return keyBetween(b.orderKey, next);
|
|
} catch {
|
|
return keyBetween(b.orderKey, '');
|
|
}
|
|
};
|
|
|
|
switch (op.kind) {
|
|
case 'insert': {
|
|
if (!op.blockId || op.blockId.length > 64) return reject('invalid_id');
|
|
if (d.blocks.has(op.blockId)) return reject('exists');
|
|
let parent = op.parentId || '';
|
|
if (parent) {
|
|
// A purged parent (compaction) puts the block at the top level, like the server
|
|
parent = d.blocks.has(parent) ? liveAncestor(d, parent) : '';
|
|
}
|
|
if (!op.orderKey || !validOrderKey(op.orderKey)) return reject('invalid_order_key');
|
|
emit({
|
|
kind: 'insert',
|
|
blockId: op.blockId,
|
|
parentId: parent,
|
|
orderKey: op.orderKey,
|
|
type: op.type || 'bullet',
|
|
content: op.content || '',
|
|
props: normProps(op.props),
|
|
version: 1,
|
|
});
|
|
return done({ status: 'applied', version: 1 });
|
|
}
|
|
case 'set_content': {
|
|
const b = d.blocks.get(op.blockId || '');
|
|
if (op.content === undefined) return reject('no_content');
|
|
if (!b) {
|
|
// The block was purged (offline for weeks): like the server, the text becomes a new block at the end
|
|
if (!op.content || !op.blockId || op.blockId.length > 64) return reject('missing');
|
|
const roots = children(d, '');
|
|
const key = keyBetween(roots.length ? roots[roots.length - 1].orderKey : '', '');
|
|
emit({ kind: 'insert', blockId: op.blockId, parentId: '', orderKey: key, type: 'bullet', content: op.content, props: {}, version: 1 });
|
|
return done({ status: 'applied', version: 1, reason: 'revived' });
|
|
}
|
|
if (b.deleted) restore([b.id]);
|
|
if (op.content === b.content) return done({ status: 'applied', version: b.version });
|
|
if ((op.baseVersion ?? 0) !== b.version) {
|
|
const id = conflictId(op.opId);
|
|
if (!d.blocks.has(id)) {
|
|
emit({
|
|
kind: 'insert',
|
|
blockId: id,
|
|
parentId: b.parentId,
|
|
orderKey: keyAfter(b),
|
|
type: b.type,
|
|
content: op.content,
|
|
props: { conflictOf: b.id },
|
|
version: 1,
|
|
});
|
|
}
|
|
return done({ status: 'conflict', version: b.version, conflictBlockId: id });
|
|
}
|
|
emit({ kind: 'set_content', blockId: b.id, content: op.content, version: b.version + 1 });
|
|
return done({ status: 'applied', version: b.version });
|
|
}
|
|
case 'set_props': {
|
|
const b = d.blocks.get(op.blockId || '');
|
|
if (!b || b.deleted) return reject('missing');
|
|
if (!op.patch) return reject('no_patch');
|
|
const type = op.patch.type ? op.patch.type : b.type;
|
|
const props = normProps({
|
|
...b.props,
|
|
...(op.patch.checked !== undefined ? { checked: op.patch.checked } : {}),
|
|
...(op.patch.collapsed !== undefined ? { collapsed: op.patch.collapsed } : {}),
|
|
...(op.patch.lang !== undefined ? { lang: op.patch.lang } : {}),
|
|
...(op.patch.clearConflict ? { conflictOf: undefined, conflictAuthor: undefined } : {}),
|
|
});
|
|
if (type !== b.type || !propsEqual(props, b.props)) emit({ kind: 'set_props', blockId: b.id, type, props });
|
|
return done({ status: 'applied' });
|
|
}
|
|
case 'move': {
|
|
const b = d.blocks.get(op.blockId || '');
|
|
if (!b || b.deleted) return reject('missing');
|
|
const parent = op.parentId || '';
|
|
if (parent) {
|
|
const p = d.blocks.get(parent);
|
|
if (!p || p.deleted) return reject('parent_missing');
|
|
if (isAncestor(d, b.id, parent)) return reject('cycle');
|
|
}
|
|
if (!op.orderKey || !validOrderKey(op.orderKey)) return reject('invalid_order_key');
|
|
if (parent !== b.parentId || op.orderKey !== b.orderKey) emit({ kind: 'move', blockId: b.id, parentId: parent, orderKey: op.orderKey });
|
|
return done({ status: 'applied' });
|
|
}
|
|
case 'delete': {
|
|
const b = d.blocks.get(op.blockId || '');
|
|
if (!b) return reject('missing');
|
|
if (!b.deleted) emit({ kind: 'delete', ids: subtree(d, b.id) });
|
|
return done({ status: 'applied' });
|
|
}
|
|
case 'restore': {
|
|
const ids = (op.ids || []).filter((id) => d.blocks.get(id)?.deleted);
|
|
if (ids.length > 0) restore(ids);
|
|
return done({ status: 'applied' });
|
|
}
|
|
case 'set_title': {
|
|
if (op.title === undefined) return reject('no_title');
|
|
if (op.title === d.title) return done({ status: 'applied', version: d.titleVersion });
|
|
if ((op.baseVersion ?? 0) !== d.titleVersion) return done({ status: 'conflict', version: d.titleVersion });
|
|
emit({ kind: 'set_title', title: op.title, titleVersion: d.titleVersion + 1 });
|
|
return done({ status: 'applied', version: d.titleVersion });
|
|
}
|
|
}
|
|
return reject('unknown_kind');
|
|
}
|
|
|
|
/** Builds a Doc from snapshot blocks (as delivered by GET /docs/{id}/snapshot). */
|
|
export function docFromSnapshot(title: string, titleVersion: number, blocks: Block[]): Doc {
|
|
const d: Doc = { title, titleVersion, blocks: new Map() };
|
|
for (const b of blocks) {
|
|
d.blocks.set(b.id, {
|
|
id: b.id,
|
|
parentId: b.parentId || '',
|
|
orderKey: b.orderKey,
|
|
type: b.type || '',
|
|
content: b.content || '',
|
|
props: normProps(b.props),
|
|
version: b.version || 0,
|
|
deleted: !!b.deleted,
|
|
updatedBy: b.updatedBy || '',
|
|
});
|
|
}
|
|
return d;
|
|
}
|
|
|
|
export function docsEqual(a: Doc, b: Doc): boolean {
|
|
if (a.title !== b.title || a.titleVersion !== b.titleVersion || a.blocks.size !== b.blocks.size) return false;
|
|
for (const [id, x] of a.blocks) {
|
|
const y = b.blocks.get(id);
|
|
if (
|
|
!y ||
|
|
x.parentId !== y.parentId ||
|
|
x.orderKey !== y.orderKey ||
|
|
x.type !== y.type ||
|
|
x.content !== y.content ||
|
|
x.version !== y.version ||
|
|
!!x.deleted !== !!y.deleted ||
|
|
!propsEqual(x.props, y.props)
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|