@k2b/cloud 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/package.json +6 -2
  2. package/src/access/GroupCoverage.tsx +20 -5
  3. package/src/access/PermissionEditor.tsx +11 -2
  4. package/src/access/PrincipalAvatar.tsx +30 -21
  5. package/src/access/messages.ts +4 -4
  6. package/src/account/EntitySearch.tsx +12 -0
  7. package/src/ai/dictation-runtime.ts +6 -2
  8. package/src/ai/executor.ts +9 -3
  9. package/src/ai/index.ts +1 -0
  10. package/src/ai/project-tool.ts +4 -4
  11. package/src/ai/projects-authorization.ts +53 -0
  12. package/src/ai/projects-routes.ts +56 -46
  13. package/src/ai/projects.ts +100 -79
  14. package/src/ai/recent-resources.ts +3 -3
  15. package/src/ai/routes.ts +30 -30
  16. package/src/ai/skill-tool.ts +4 -4
  17. package/src/ai/skills-routes.ts +27 -26
  18. package/src/ai/skills.ts +120 -81
  19. package/src/api/accounts-entities.ts +6 -3
  20. package/src/api/admin-ai-skills.ts +4 -1
  21. package/src/api/auth.ts +9 -2
  22. package/src/browser/session.ts +39 -0
  23. package/src/browser/shared-text-history-validation.ts +151 -0
  24. package/src/browser/shared-text-history.ts +464 -0
  25. package/src/browser/shared-text-offline-authorized.ts +29 -0
  26. package/src/browser/shared-text-offline.ts +351 -0
  27. package/src/browser/shared-text-provider.ts +714 -0
  28. package/src/browser/shared-text-table-contract.ts +150 -0
  29. package/src/browser/shared-text.ts +19 -0
  30. package/src/contracts/shared.ts +2 -1
  31. package/src/server/api/respond.ts +10 -2
  32. package/src/server/index.ts +1 -0
  33. package/src/server/services/access.ts +23 -10
  34. package/src/server/services/index.ts +1 -0
  35. package/src/services/accounts/app.ts +30 -13
  36. package/src/services/accounts/entities.ts +29 -16
  37. package/src/services/accounts/group-sql.ts +23 -0
  38. package/src/services/accounts/groups.ts +16 -13
  39. package/src/services/accounts/identities.ts +8 -1
  40. package/src/services/accounts/identity-reconciliation.ts +3 -1
  41. package/src/services/accounts/local-groups.ts +69 -4
  42. package/src/services/accounts/users.ts +38 -8
  43. package/src/services/mandates/index.ts +2 -2
  44. package/src/services/mandates/policy.ts +1 -0
  45. package/src/services/session/index.ts +12 -3
  46. package/src/shared/email-html.ts +1 -0
  47. package/src/shared/markdown/extensions/katex.ts +8 -1
  48. package/src/shared/markdown/extensions/tables.ts +14 -1
  49. package/src/shared/markdown/index.ts +22 -11
  50. package/src/shared-text/anchors.ts +29 -0
  51. package/src/shared-text/awareness.ts +87 -0
  52. package/src/shared-text/changes.ts +27 -0
  53. package/src/shared-text/documents.ts +428 -77
  54. package/src/shared-text/events.ts +12 -2
  55. package/src/shared-text/index.ts +16 -1
  56. package/src/shared-text/log.ts +24 -5
  57. package/src/shared-text/snapshots.ts +69 -23
  58. package/src/shared-text/structural-history.ts +377 -0
  59. package/src/shared-text/table-actions.ts +432 -0
  60. package/src/shared-text/types.ts +24 -5
  61. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  62. package/src/ssr/CloudShell.island.tsx +19 -3
  63. package/src/ssr/Layout.tsx +1 -1
  64. package/src/ssr/MobileProfileActions.tsx +2 -2
  65. package/src/ssr/ProfilePreferences.tsx +2 -1
  66. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  67. package/src/ssr/admin-navigation.ts +1 -11
  68. package/src/ssr/app-launchpad.tsx +6 -1
  69. package/src/ssr/platform-messages.ts +2 -0
  70. package/src/ssr/profile-actions.ts +3 -6
  71. package/src/ssr/shell-data.tsx +1 -1
  72. package/src/workflows/language/expressions.ts +3 -2
  73. package/src/workflows/runtime/executor.ts +2 -1
@@ -29,14 +29,24 @@ export const isValidSharedTextUpdate = (payload: string): boolean => {
29
29
  }
30
30
  };
31
31
 
32
- export const applySharedTextEvent = (doc: Y.Doc, event: { cursor: string; data: SharedTextEvent }, docId: string): void => {
33
- if (event.data.kind !== "sync") return;
32
+ export const applySharedTextEvent = (
33
+ doc: Y.Doc,
34
+ event: { cursor: string; data: SharedTextEvent },
35
+ docId: string,
36
+ restoreRevision?: string,
37
+ ): boolean => {
38
+ if (event.data.kind !== "sync") return false;
39
+ if (event.data.restoreRevision !== undefined) {
40
+ if (restoreRevision === undefined) throw new TypeError("A versioned update needs its snapshot restore revision");
41
+ if (event.data.restoreRevision !== restoreRevision) return false;
42
+ }
34
43
  try {
35
44
  const update = fromBase64(event.data.payload);
36
45
  // Decode completely before mutating the document: malformed bytes must not
37
46
  // partially integrate state before recovery skips the invalid event.
38
47
  Y.decodeUpdate(update);
39
48
  Y.applyUpdate(doc, update, `replay:${event.cursor}`);
49
+ return true;
40
50
  } catch (error) {
41
51
  throw new MalformedSharedTextEventError(docId, event.cursor, error);
42
52
  }
@@ -1,3 +1,5 @@
1
+ export { captureTextAnchor, resolveTextAnchor } from "./anchors";
2
+ export { createTrustedTextAwareness, InvalidSharedTextAwarenessError } from "./awareness";
1
3
  export { compareStreamCursor, maxStreamCursor, parseStreamCursor } from "./cursor";
2
4
  export {
3
5
  applySharedTextEvent,
@@ -9,6 +11,19 @@ export {
9
11
  toBase64,
10
12
  } from "./events";
11
13
  export { sharedText } from "./log";
12
- export type { SharedTextSnapshotBacklog, SharedTextSnapshotRequest, SharedTextSnapshotStore } from "./snapshots";
14
+ export type { SharedTextSnapshotBacklog, SharedTextSnapshotRepair, SharedTextSnapshotRequest, SharedTextSnapshotStore } from "./snapshots";
13
15
  export { sharedTextSnapshotJobConfig } from "./snapshots";
16
+ export { planStructuralInverse, type SharedTextStructuralHistory, validateStructuralIngress } from "./structural-history";
17
+ export {
18
+ captureTableAction,
19
+ captureTableFocus,
20
+ captureTableSource,
21
+ planTableAction,
22
+ prepareTableActionFocus,
23
+ resolveTableFocus,
24
+ type TableActionIntent,
25
+ TableActionLimitError,
26
+ TableActionRejectedError,
27
+ type TableFocusWitness,
28
+ } from "./table-actions";
14
29
  export * from "./types";
@@ -14,6 +14,7 @@ const define = (topicConfig: Readonly<Pick<TopicConfig, "id" | "retention" | "de
14
14
  after: string | null;
15
15
  until: string;
16
16
  doc: Y.Doc;
17
+ restoreRevision?: string;
17
18
  signal?: AbortSignal;
18
19
  }): Promise<SharedTextContributor[]> => {
19
20
  const contributors = new Map<string, SharedTextContributor>();
@@ -22,8 +23,8 @@ const define = (topicConfig: Readonly<Pick<TopicConfig, "id" | "retention" | "de
22
23
  if (compareStreamCursor(after, config.until) >= 0) return [];
23
24
  let reachedTarget = false;
24
25
  for await (const event of handle.replay({ tenantId: config.docId, after, until: config.until, signal: config.signal })) {
25
- applySharedTextEvent(config.doc, event, config.docId);
26
- if (event.data.actor)
26
+ const applied = applySharedTextEvent(config.doc, event, config.docId, config.restoreRevision);
27
+ if (applied && event.data.actor)
27
28
  contributors.set(`${event.data.actor.kind}:${event.data.actor.id}`, {
28
29
  ...event.data.actor,
29
30
  lastContributedAt: event.publishedAt,
@@ -39,6 +40,7 @@ const define = (topicConfig: Readonly<Pick<TopicConfig, "id" | "retention" | "de
39
40
  after: string | null;
40
41
  until: string;
41
42
  doc: Y.Doc;
43
+ restoreRevision?: string;
42
44
  signal?: AbortSignal;
43
45
  }): Promise<{ recoveredUpdates: number; malformedUpdates: number; historyGaps: number }> => {
44
46
  const handle = topic();
@@ -51,8 +53,7 @@ const define = (topicConfig: Readonly<Pick<TopicConfig, "id" | "retention" | "de
51
53
  try {
52
54
  for await (const event of handle.replay({ tenantId: config.docId, after, until: config.until, signal: config.signal })) {
53
55
  try {
54
- applySharedTextEvent(config.doc, event, config.docId);
55
- recoveredUpdates++;
56
+ if (applySharedTextEvent(config.doc, event, config.docId, config.restoreRevision)) recoveredUpdates++;
56
57
  } catch (error) {
57
58
  if (!(error instanceof MalformedSharedTextEventError)) throw error;
58
59
  malformedUpdates++;
@@ -87,7 +88,25 @@ const define = (topicConfig: Readonly<Pick<TopicConfig, "id" | "retention" | "de
87
88
  return 0;
88
89
  },
89
90
  withStore<Saved, Context>(store: SharedTextStore<Saved, Context>) {
90
- return bindDocuments({ topic, replay, maxPayloadBytes: topicConfig.maxPayloadBytes }, store);
91
+ return bindDocuments(
92
+ {
93
+ topic,
94
+ replay,
95
+ maxPayloadBytes: topicConfig.maxPayloadBytes,
96
+ async *events(docId, after, until, signal) {
97
+ const handle = topic();
98
+ const exact = after === until;
99
+ const start = exact ? handle.cursorAt(handle.cursorSequence(until) - 1) : (after ?? handle.cursorAt(0));
100
+ let reached = false;
101
+ for await (const event of handle.replay({ tenantId: docId, after: start, until, signal })) {
102
+ reached = event.cursor === until;
103
+ if (!exact || event.cursor === until) yield event;
104
+ }
105
+ if (!reached) throw new Error("The structural receipt history is unavailable");
106
+ },
107
+ },
108
+ store,
109
+ );
91
110
  },
92
111
  };
93
112
  };
@@ -1,10 +1,11 @@
1
1
  import { type Job, type JobConfig, RetentionGapError, type Topic, type Worker } from "@k2b/sync";
2
2
  import * as Y from "yjs";
3
3
  import { logger } from "../services/logging";
4
+ import { validateChanges } from "./changes";
4
5
  import { compareStreamCursor } from "./cursor";
5
6
  import { createDoc } from "./documents";
6
- import { applySharedTextEvent, MalformedSharedTextEventError, SHARED_TEXT_NAME, sharedTextNodeId } from "./events";
7
- import type { SharedTextContributor, SharedTextSaveInput, SharedTextStoredState, SharedTextSyncEvent } from "./types";
7
+ import { applySharedTextEvent, MalformedSharedTextEventError, SHARED_TEXT_NAME, sharedTextNodeId, toBase64 } from "./events";
8
+ import type { SharedTextChange, SharedTextContributor, SharedTextSaveInput, SharedTextStoredState, SharedTextSyncEvent } from "./types";
8
9
 
9
10
  const ACK_WAIT_MS = 120_000;
10
11
  const RECONCILE_LIMIT = 5_000;
@@ -69,10 +70,20 @@ export type SharedTextSnapshotBacklog = {
69
70
  };
70
71
  type SnapshotLog = {
71
72
  id: string;
72
- topic(): Pick<Topic<SharedTextSyncEvent>, "head" | "latestCursor" | "cursorSequence" | "cursorAt" | "replay">;
73
+ topic(): Pick<Topic<SharedTextSyncEvent>, "head" | "latestCursor" | "cursorSequence" | "cursorAt" | "replay" | "publish">;
73
74
  firstSequence(): Promise<number>;
74
75
  };
75
- type SnapshotOptions = { log: SnapshotLog; store: SharedTextSnapshotStore; logger?: ReturnType<typeof logger> };
76
+ export type SharedTextSnapshotRepair = {
77
+ /** Application-owned, insertion-only repair in original UTF-16 coordinates. */
78
+ plan(text: string): readonly SharedTextChange[];
79
+ maxLength: number;
80
+ };
81
+ type SnapshotOptions = {
82
+ log: SnapshotLog;
83
+ store: SharedTextSnapshotStore;
84
+ logger?: ReturnType<typeof logger>;
85
+ repair?: SharedTextSnapshotRepair;
86
+ };
76
87
  type InputCodec<Input> = { encode(request: SharedTextSnapshotRequest): Input; decode(input: Input): SharedTextSnapshotRequest };
77
88
  type SnapshotJob<Input> = () => Pick<Job<Input>, "submit" | "process">;
78
89
 
@@ -91,10 +102,12 @@ export function snapshots<Input>(
91
102
  }
92
103
 
93
104
  const createSnapshots = <Input>(
94
- { log, store, logger: suppliedLogger }: SnapshotOptions,
105
+ { log, store, repair, logger: suppliedLogger }: SnapshotOptions,
95
106
  job: SnapshotJob<Input>,
96
107
  input: InputCodec<Input>,
97
108
  ) => {
109
+ if (repair && (!Number.isSafeInteger(repair.maxLength) || repair.maxLength < 0))
110
+ throw new RangeError("Repair maxLength must be a nonnegative safe integer");
98
111
  const output = suppliedLogger ?? logger("shared-text-snapshots");
99
112
  let worker: Promise<Worker> | null = null;
100
113
  let lastBacklog: SharedTextSnapshotBacklog | null = null;
@@ -147,31 +160,34 @@ const createSnapshots = <Input>(
147
160
  const head = await topic.head();
148
161
  // Cover queued older cursors in one replay; later jobs skip already saved state.
149
162
  const target = await topic.latestCursor({ tenantId: docId });
150
- if (!target) {
163
+ if (!target && !repair) {
151
164
  output.warn("Snapshot skipped: no retained events for the note", { docId, requestedCursor: request.targetCursor });
152
165
  return;
153
166
  }
154
167
  const requestedAt = Date.now();
155
- if (!initial || (initial.cursor && compareStreamCursor(initial.cursor, target) >= 0)) return;
168
+ if (!initial) return;
169
+ const covered = !target || !!(initial.cursor && compareStreamCursor(initial.cursor, target) >= 0);
170
+ if (covered && !repair) return;
156
171
  const doc = createDoc(initial);
157
172
  const contributors = new Map<string, SharedTextContributor>();
158
173
  try {
159
- let reachedTarget = false;
174
+ let reachedTarget = covered;
160
175
  try {
161
- for await (const event of topic.replay({
162
- tenantId: docId,
163
- after: initial.cursor ?? topic.cursorAt(0),
164
- until: target,
165
- signal: context.signal,
166
- })) {
167
- applySharedTextEvent(doc, event, docId);
168
- if (event.data.actor)
169
- contributors.set(`${event.data.actor.kind}:${event.data.actor.id}`, {
170
- ...event.data.actor,
171
- lastContributedAt: event.publishedAt,
172
- });
173
- reachedTarget = event.cursor === target;
174
- }
176
+ if (!covered && target)
177
+ for await (const event of topic.replay({
178
+ tenantId: docId,
179
+ after: initial.cursor ?? topic.cursorAt(0),
180
+ until: target,
181
+ signal: context.signal,
182
+ })) {
183
+ const applied = applySharedTextEvent(doc, event, docId, initial.restoreRevision);
184
+ if (applied && event.data.actor)
185
+ contributors.set(`${event.data.actor.kind}:${event.data.actor.id}`, {
186
+ ...event.data.actor,
187
+ lastContributedAt: event.publishedAt,
188
+ });
189
+ reachedTarget = event.cursor === target;
190
+ }
175
191
  } catch (error) {
176
192
  // Preserve all valid retained updates and unresolved dependencies;
177
193
  // record the missing/invalid history once in the DLQ after recovery.
@@ -184,10 +200,40 @@ const createSnapshots = <Input>(
184
200
  if (!reachedTarget) throw new Error("Snapshot replay did not reach its target");
185
201
  await context.heartbeat();
186
202
  context.signal.throwIfAborted();
203
+ let cursor = target && compareStreamCursor(target, head) > 0 ? target : head;
204
+ if (repair && !doc.store.pendingStructs && !doc.store.pendingDs) {
205
+ const text = doc.getText(SHARED_TEXT_NAME);
206
+ const planned = repair.plan(text.toString());
207
+ if (planned.some((change) => change.from !== change.to)) throw new RangeError("Snapshot repair may only insert text");
208
+ const changes = planned.length ? validateChanges(text.toString(), planned, repair.maxLength) : [];
209
+ if (changes.length) {
210
+ const before = Y.encodeStateVector(doc);
211
+ doc.transact(() => {
212
+ for (const change of [...changes].reverse()) text.insert(change.from, change.insert);
213
+ }, "snapshot-repair");
214
+ context.signal.throwIfAborted();
215
+ if (heartbeatError) throw heartbeatError;
216
+ // A conflict retries the whole ordered job from the current store
217
+ // and tenant head; never publish a plan over a newer document.
218
+ const published = await topic.publish({
219
+ tenantId: docId,
220
+ expectedAfter: target,
221
+ data: {
222
+ kind: "sync",
223
+ payload: toBase64(Y.encodeStateAsUpdate(doc, before)),
224
+ originNodeId: sharedTextNodeId,
225
+ originPeerId: null,
226
+ restoreRevision: initial.restoreRevision,
227
+ },
228
+ });
229
+ cursor = published.cursor;
230
+ } else if (covered) return;
231
+ } else if (covered) return;
232
+ context.signal.throwIfAborted();
187
233
  await store.save(docId, {
188
234
  state: Y.encodeStateAsUpdate(doc),
189
235
  text: doc.getText(SHARED_TEXT_NAME).toString(),
190
- cursor: compareStreamCursor(head, target) > 0 ? head : target,
236
+ cursor,
191
237
  restoreRevision: initial.restoreRevision,
192
238
  requestedAt,
193
239
  actor: null,
@@ -0,0 +1,377 @@
1
+ /** Server-authored provenance. Read only from the authenticated document log. */
2
+ import * as T from "@k2b/ui/text-editor/model";
3
+ import * as Y from "yjs";
4
+ import { captureTextAnchor, resolveTextAnchor } from "./anchors";
5
+ import { fromBase64, toBase64 } from "./events";
6
+ import { TableActionRejectedError } from "./table-actions";
7
+ import type { SharedTextChange } from "./types";
8
+
9
+ /** Proven later schema/row dependency; caller preserves the rejected history step. */
10
+ export class SharedTextInverseDependencyError extends TableActionRejectedError {
11
+ constructor(readonly reason: "schema" | "row_scaffold" | "patch" | "alignment") {
12
+ super();
13
+ }
14
+ }
15
+
16
+ type Run = { client: number; clock: number; length: number };
17
+ type Patch = {
18
+ left: string | null;
19
+ right: string | null;
20
+ removed: string;
21
+ inserted: string;
22
+ added: Run | null;
23
+ deleted: Run[];
24
+ recreate?: true;
25
+ };
26
+ export type SharedTextStructuralHistory = {
27
+ patches: Patch[];
28
+ column?: { header: string; hyphen: string };
29
+ /** Separator-only format actions depend on the complete original column alignment. */
30
+ alignmentColumns?: string[];
31
+ tables: string[];
32
+ retained?: boolean;
33
+ inverseOf?: string;
34
+ ingressBarrier?: boolean;
35
+ /** Post-action schema/row identities fence generic restoration. */
36
+ schemas?: { header: string; hyphens: string[]; rows: string[] }[];
37
+ };
38
+ const anchor = (text: Y.Text, at: number) => toBase64(captureTextAnchor(text, at));
39
+ const resolve = (text: Y.Text, encoded: string) => resolveTextAnchor(text, fromBase64(encoded));
40
+ const reject = (): never => {
41
+ throw new TableActionRejectedError();
42
+ };
43
+
44
+ // Exported Yjs traversal visits identity runs, not one linked-list search per
45
+ // character. The transient snapshot is bounded by this document's state.
46
+ const runs = (text: Y.Text, from: number, to: number): Run[] => {
47
+ const result: Run[] = [];
48
+ const first = Y.createRelativePositionFromTypeIndex(text, from).item;
49
+ if (from === to || !first || !text.doc) return result;
50
+ let left = to - from;
51
+ let item = Y.getItem(text.doc.store, first);
52
+ let clock = first.clock;
53
+ while (left > 0) {
54
+ if (!item.deleted && item.countable) {
55
+ const length = Math.min(left, item.id.clock + item.length - clock);
56
+ result.push({ client: item.id.client, clock, length });
57
+ left -= length;
58
+ }
59
+ if (!left) break;
60
+ if (!item.right) return reject();
61
+ item = item.right;
62
+ clock = item.id.clock;
63
+ }
64
+ return result;
65
+ };
66
+
67
+ export const prepareStructuralHistory = (text: Y.Text, changes: readonly SharedTextChange[]) => {
68
+ const source = text.toString();
69
+ const tables = T.findTables(source);
70
+ const previous = tables.map((table) => ({
71
+ header: anchor(text, table.lines[0]!.to),
72
+ hyphens: table.lines[1]!.cells.map((cell) => anchor(text, cell.from + cell.text.indexOf("-"))),
73
+ }));
74
+ const patches: Patch[] = changes.map((change) => ({
75
+ left: change.from > 0 ? anchor(text, change.from - 1) : null,
76
+ right: change.to < text.length ? anchor(text, change.to) : null,
77
+ removed: source.slice(change.from, change.to),
78
+ inserted: change.insert,
79
+ added: null,
80
+ deleted: runs(text, change.from, change.to),
81
+ ...(change.recreate ? { recreate: true as const } : {}),
82
+ }));
83
+ const alignmentColumns =
84
+ patches.length && patches.every((patch) => patch.removed + patch.inserted === ":")
85
+ ? tables.flatMap((table) =>
86
+ table.lines[1]!.cells.filter((cell) => changes.some((change) => change.from >= cell.from && change.to <= cell.to)).map((cell) =>
87
+ anchor(text, cell.from + cell.text.indexOf("-")),
88
+ ),
89
+ )
90
+ : [];
91
+ const affected = tables
92
+ .filter((table) => changes.some((change) => change.from <= table.to && change.to >= table.from))
93
+ .map((table) => anchor(text, table.lines[0]!.to));
94
+ return () => {
95
+ let shift = 0;
96
+ changes.forEach((change, index) => {
97
+ if (change.insert.length) {
98
+ const id = Y.createRelativePositionFromTypeIndex(text, change.from + shift).item!;
99
+ patches[index]!.added = { client: id.client, clock: id.clock, length: change.insert.length };
100
+ }
101
+ shift += change.insert.length - (change.to - change.from);
102
+ });
103
+ const result: SharedTextStructuralHistory = { patches, tables: affected };
104
+ if (alignmentColumns.length) result.alignmentColumns = alignmentColumns;
105
+ result.schemas = T.findTables(text.toString())
106
+ .filter((table) => affected.some((header) => resolve(text, header) === table.lines[0]!.to))
107
+ .map((table) => ({
108
+ header: anchor(text, table.lines[0]!.to),
109
+ hyphens: table.lines[1]!.cells.map((cell) => anchor(text, cell.from + cell.text.indexOf("-"))),
110
+ rows: table.lines.slice(2).map((line) => anchor(text, line.pipes[0] ?? line.from)),
111
+ }));
112
+ if (changes.every((change) => change.from === change.to)) {
113
+ for (const table of T.findTables(text.toString())) {
114
+ const before = previous.find((old) => resolve(text, old.header) === table.lines[0]!.to);
115
+ if (!before || table.columns !== before.hyphens.length + 1) continue;
116
+ const old = before.hyphens.map((value) => resolve(text, value));
117
+ const current = table.lines[1]!.cells.map((cell) => cell.from + cell.text.indexOf("-"));
118
+ if (!old.every((at) => at !== null && current.includes(at))) continue;
119
+ const at = current.find((at) => !old.includes(at))!;
120
+ result.column = { header: before.header, hyphen: anchor(text, at) };
121
+ break;
122
+ }
123
+ }
124
+ return result;
125
+ };
126
+ };
127
+
128
+ /** No mutation; a changed/deleted fixed target rejects rather than guessing. */
129
+ export const planStructuralInverse = (
130
+ text: Y.Text,
131
+ record: SharedTextStructuralHistory,
132
+ ): { changes: SharedTextChange[]; retained: boolean } => {
133
+ if (record.retained) return { changes: [], retained: true };
134
+ if (record.column) {
135
+ const header = resolve(text, record.column.header),
136
+ hyphen = resolve(text, record.column.hyphen);
137
+ const table = T.findTables(text.toString()).find((table) => table.lines[0]!.to === header);
138
+ const column = table?.lines[1]!.cells.findIndex((cell) => hyphen !== null && hyphen >= cell.from && hyphen < cell.to) ?? -1;
139
+ if (!table || column < 0) return reject();
140
+ const dependent = table.lines.some((line, row) => (row === 1 ? line.cells[column]?.text.includes(":") : !!line.cells[column]?.text));
141
+ if (dependent) return { changes: [], retained: true };
142
+ const changes = T.deleteColumn(table, column);
143
+ if (!changes.length) return reject();
144
+ const owned = record.patches.flatMap((patch) => (patch.added ? [patch.added] : []));
145
+ const source = text.toString();
146
+ for (const change of changes) {
147
+ const row = table.lines.findIndex((line) => change.from >= line.from && change.to <= line.to);
148
+ const line = table.lines[row];
149
+ // Native append has one canonical scaffold, generated by insertRow. Prove
150
+ // its ORIGINAL delimiter IDs, not trimmed cell emptiness. A later space
151
+ // has another clock even if it looks exactly like scaffold padding.
152
+ let appended: Run | undefined;
153
+ if (row >= 2 && line) {
154
+ const newline = table.lines[0]!.lineBreak || "\n";
155
+ const start = Y.createRelativePositionFromTypeIndex(text, line.from - newline.length).item;
156
+ const expected = T.insertRow(table, 0)[0]!.insert;
157
+ const pipeOffsets = [...expected.matchAll(/\|/g)].map((match) => match.index);
158
+ if (
159
+ start &&
160
+ line.pipes.length === pipeOffsets.length &&
161
+ line.pipes.every((at, index) => {
162
+ const id = Y.createRelativePositionFromTypeIndex(text, at).item;
163
+ return id?.client === start.client && id.clock === start.clock + pipeOffsets[index]!;
164
+ }) &&
165
+ source.slice(change.from, change.to) === " |"
166
+ )
167
+ appended = { client: start.client, clock: start.clock + newline.length + 1 + column * 4, length: 4 };
168
+ }
169
+ const scaffold = appended ? [...owned, appended] : owned;
170
+ if (
171
+ runs(text, change.from, change.to).some(
172
+ (run) =>
173
+ !scaffold.some(
174
+ (allowed) =>
175
+ run.client === allowed.client && run.clock >= allowed.clock && run.clock + run.length <= allowed.clock + allowed.length,
176
+ ),
177
+ )
178
+ )
179
+ return { changes: [], retained: true };
180
+ }
181
+ return { changes, retained: false };
182
+ }
183
+ const source = text.toString();
184
+ if (record.patches.some((patch) => patch.removed)) {
185
+ if (record.tables.length && !record.schemas) return reject();
186
+ // Restoring historical slices into newly accepted rows would assign their
187
+ // cells to different headers. Until identity-aware column restoration is
188
+ // available, that changed target visibly rejects without consuming history.
189
+ const tables = T.findTables(source);
190
+ // Alignment changes affect only separator colons. New/deleted body rows do
191
+ // not change those fixed targets; retain all column/schema and patch-ID
192
+ // checks below. Other restorations still require the original row set.
193
+ const alignmentOnly = record.patches.every((patch) => {
194
+ if (patch.recreate || patch.removed + patch.inserted !== ":") return false;
195
+ const left = patch.left === null ? null : resolve(text, patch.left);
196
+ const right = patch.right === null ? null : resolve(text, patch.right);
197
+ return (
198
+ left !== null &&
199
+ right !== null &&
200
+ tables.some((table) =>
201
+ table.lines[1]!.cells.some(
202
+ (cell) =>
203
+ left >= cell.from - 1 &&
204
+ right <= cell.to &&
205
+ (record.schemas ?? []).some(
206
+ (schema) =>
207
+ resolve(text, schema.header) === table.lines[0]!.to &&
208
+ schema.hyphens.some((hyphen) => resolve(text, hyphen) === cell.from + cell.text.indexOf("-")),
209
+ ),
210
+ ),
211
+ )
212
+ );
213
+ });
214
+ for (const schema of alignmentOnly ? [] : (record.schemas ?? [])) {
215
+ const table = tables.find((table) => table.lines[0]!.to === resolve(text, schema.header));
216
+ if (
217
+ !table ||
218
+ table.columns !== schema.hyphens.length ||
219
+ (!alignmentOnly && table.lines.length - 2 !== schema.rows.length) ||
220
+ schema.hyphens.some(
221
+ (value, index) => resolve(text, value) !== table.lines[1]!.cells[index]!.from + table.lines[1]!.cells[index]!.text.indexOf("-"),
222
+ ) ||
223
+ (!alignmentOnly &&
224
+ schema.rows.some((value, index) => resolve(text, value) !== (table.lines[index + 2]!.pipes[0] ?? table.lines[index + 2]!.from)))
225
+ )
226
+ throw new SharedTextInverseDependencyError("schema");
227
+ }
228
+ }
229
+ const deleted = Y.createDeleteSetFromStructStore(text.doc!.store);
230
+ const changes = record.patches.map((patch) => {
231
+ if (patch.added && !patch.removed) {
232
+ const run = patch.added;
233
+ if (deleted.clients.get(run.client)?.some((range) => range.clock < run.clock + run.length && range.clock + range.len > run.clock))
234
+ throw new SharedTextInverseDependencyError("patch");
235
+ const at = (clock: number) =>
236
+ Y.createAbsolutePositionFromRelativePosition(
237
+ new Y.RelativePosition(null, null, Y.createID(run.client, clock), 0),
238
+ text.doc!,
239
+ false,
240
+ );
241
+ const from = at(run.clock),
242
+ last = at(run.clock + run.length - 1);
243
+ if (from?.type !== text || last?.type !== text || source.slice(from.index, last.index + 1) !== patch.inserted)
244
+ throw new SharedTextInverseDependencyError("patch");
245
+ // A peer can extend an inserted row with a new column AFTER this run's
246
+ // last pipe. Removing only our run would attach that tail to the previous
247
+ // row (or separator). It is a dependency, not an empty independent row.
248
+ const end = last.index + 1;
249
+ if (
250
+ (patch.inserted.startsWith("\n") || patch.inserted.startsWith("\r")) &&
251
+ end < source.length &&
252
+ source[end] !== "\n" &&
253
+ source[end] !== "\r"
254
+ )
255
+ throw new SharedTextInverseDependencyError("row_scaffold");
256
+ return { from: from.index, to: end, insert: "" };
257
+ }
258
+ const left = patch.left === null ? -1 : resolve(text, patch.left);
259
+ const right = patch.right === null ? text.length : resolve(text, patch.right);
260
+ if (left === null || right === null || right <= left || source.slice(left + 1, right) !== patch.inserted)
261
+ throw new SharedTextInverseDependencyError("patch");
262
+ if (patch.added) {
263
+ const run = patch.added;
264
+ if (deleted.clients.get(run.client)?.some((range) => range.clock < run.clock + run.length && range.clock + range.len > run.clock))
265
+ throw new SharedTextInverseDependencyError("patch");
266
+ const first = Y.createRelativePositionFromTypeIndex(text, left + 1).item;
267
+ const last = Y.createRelativePositionFromTypeIndex(text, right - 1).item;
268
+ if (
269
+ first?.client !== run.client ||
270
+ first.clock !== run.clock ||
271
+ last?.client !== run.client ||
272
+ last.clock !== run.clock + run.length - 1
273
+ )
274
+ throw new SharedTextInverseDependencyError("patch");
275
+ }
276
+ return { from: left + 1, to: right, insert: patch.removed, ...(patch.recreate ? { recreate: true as const } : {}) };
277
+ });
278
+ return { changes, retained: false };
279
+ };
280
+
281
+ /** Ordinary input may also remove row/column syntax (raw Markdown/paste).
282
+ * Only the server classifies that ingress. A changed scaffold fences older
283
+ * unconfirmed input; unchanged cell input remains concurrently mergeable.
284
+ * The identity signature is transient; only the bounded classification enters
285
+ * the existing retained event, never a second snapshot or recovery store.
286
+ */
287
+ export const prepareIngressHistory = (text: Y.Text) => {
288
+ const signature = () =>
289
+ JSON.stringify(
290
+ T.findTables(text.toString()).map((table) => ({
291
+ header: anchor(text, table.lines[0]!.to),
292
+ hyphens: table.lines[1]!.cells.map((cell) => anchor(text, cell.from + cell.text.indexOf("-"))),
293
+ rows: table.lines.map((line) => ({
294
+ before: line.from > 0 ? anchor(text, line.from - 1) : null,
295
+ pipes: line.pipes.map((at) => anchor(text, at)),
296
+ })),
297
+ })),
298
+ );
299
+ const before = signature();
300
+ return (): SharedTextStructuralHistory => ({ patches: [], tables: [], ...(signature() !== before ? { ingressBarrier: true } : {}) });
301
+ };
302
+
303
+ /** Stream retained provenance: memory is one document, one update and one receipt. */
304
+ export const createStructuralIngressValidation = (doc: Y.Doc, update: Uint8Array) => {
305
+ const currentText = doc.getText("codemirror");
306
+ const decoded = Y.decodeUpdate(update);
307
+ const known = Y.decodeStateVector(Y.encodeStateVector(doc));
308
+ const introduced = decoded.structs.filter(
309
+ (item): item is Y.Item => item instanceof Y.Item && item.id.clock + item.length > (known.get(item.id.client) ?? 0),
310
+ );
311
+ let candidate: Y.Doc | undefined;
312
+ let tables: T.Table[] | undefined;
313
+ return {
314
+ check(record: SharedTextStructuralHistory, inverseSource?: SharedTextStructuralHistory): boolean {
315
+ if (record.ingressBarrier) return false;
316
+ const removed = record.patches.flatMap((patch) => patch.deleted);
317
+ const replaces = record.patches.some((patch) => patch.inserted.length > 0);
318
+ // Restoration provenance lives on the referenced retained receipt. Read
319
+ // it one event at a time: no growing map of all crossed action receipts.
320
+ if (replaces && record.inverseOf && !inverseSource) return false;
321
+ const replaced = inverseSource ? [...removed, ...inverseSource.patches.flatMap((patch) => patch.deleted)] : removed;
322
+ const touchesRemoved = (id: Y.ID | null) =>
323
+ id && removed.some((run) => id.client === run.client && id.clock >= run.clock && id.clock < run.clock + run.length);
324
+ if (introduced.some((item) => touchesRemoved(item.origin) || touchesRemoved(item.rightOrigin))) return false;
325
+ // Deletion-only input has no introduced origins. The receipt removed
326
+ // these IDs while they were LIVE, so an older unconfirmed removal cannot
327
+ // silently count as applied to their recreated replacement. An already
328
+ // accepted deletion is absent from these live removed runs and may retry.
329
+ // Pure removals create no replacement target: a stale row deletion may
330
+ // safely also remove already-deleted empty column scaffold.
331
+ if (
332
+ replaces &&
333
+ replaced.some((run) =>
334
+ decoded.ds.clients.get(run.client)?.some((range) => range.clock < run.clock + run.length && range.clock + range.len > run.clock),
335
+ )
336
+ )
337
+ return false;
338
+ if (!candidate) {
339
+ candidate = new Y.Doc();
340
+ Y.applyUpdate(candidate, Y.encodeStateAsUpdate(doc));
341
+ Y.applyUpdate(candidate, update);
342
+ tables = T.findTables(candidate.getText("codemirror").toString());
343
+ }
344
+ if (candidate.store.pendingStructs || candidate.store.pendingDs) return false;
345
+ const text = candidate.getText("codemirror");
346
+ for (const header of record.tables) {
347
+ const table = tables!.find((table) => table.lines[0]!.to === resolve(text, header));
348
+ if (table?.lines.some((line) => line.cells.length !== table.columns)) return false;
349
+ const before = T.findTables(currentText.toString()).find((table) => table.lines[0]!.to === resolve(currentText, header));
350
+ // An appended row encodes the entire old column schema. Its width
351
+ // cannot prove original column identity after that schema was removed.
352
+ if (before && removed.length)
353
+ for (const item of introduced) {
354
+ if (!(item.content instanceof Y.ContentString) || !item.content.str.includes("\n")) continue;
355
+ for (const id of [item.origin, item.rightOrigin]) {
356
+ if (!id) continue;
357
+ const position = Y.createAbsolutePositionFromRelativePosition(new Y.RelativePosition(null, null, id, 0), doc, false);
358
+ if (position?.type === currentText && position.index >= before.from && position.index <= before.to) return false;
359
+ }
360
+ }
361
+ }
362
+ return true;
363
+ },
364
+ dispose() {
365
+ candidate?.destroy();
366
+ },
367
+ };
368
+ };
369
+
370
+ export const validateStructuralIngress = (doc: Y.Doc, update: Uint8Array, crossed: readonly SharedTextStructuralHistory[]): boolean => {
371
+ const validation = createStructuralIngressValidation(doc, update);
372
+ try {
373
+ return crossed.every((record) => validation.check(record));
374
+ } finally {
375
+ validation.dispose();
376
+ }
377
+ };