@k2b/cloud 0.44.0 → 0.46.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.
- package/package.json +9 -2
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +2 -0
- package/src/_internal/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +36 -0
- package/src/access/GroupCoverage.tsx +20 -5
- package/src/access/PermissionEditor.tsx +11 -2
- package/src/access/PrincipalAvatar.tsx +30 -21
- package/src/access/messages.ts +4 -4
- package/src/account/EntitySearch.tsx +12 -0
- package/src/ai/capabilities.ts +4 -2
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/dictation-runtime.ts +6 -2
- package/src/ai/executor.ts +22 -4
- package/src/ai/index.ts +1 -0
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/project-tool.ts +4 -4
- package/src/ai/projects-authorization.ts +53 -0
- package/src/ai/projects-routes.ts +56 -46
- package/src/ai/projects.ts +100 -79
- package/src/ai/protocol.ts +3 -1
- package/src/ai/recent-resources.ts +3 -3
- package/src/ai/routes.ts +32 -30
- package/src/ai/skill-tool.ts +4 -4
- package/src/ai/skills-routes.ts +27 -26
- package/src/ai/skills.ts +120 -81
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -0
- package/src/api/accounts-entities.ts +6 -3
- package/src/api/admin-ai-skills.ts +4 -1
- package/src/api/auth.ts +9 -2
- package/src/api/capabilities.ts +90 -12
- package/src/api/mcp.ts +4 -2
- package/src/browser/notifications.ts +25 -8
- package/src/browser/session.ts +39 -0
- package/src/browser/shared-text-history-validation.ts +151 -0
- package/src/browser/shared-text-history.ts +464 -0
- package/src/browser/shared-text-offline-authorized.ts +29 -0
- package/src/browser/shared-text-offline.ts +351 -0
- package/src/browser/shared-text-provider.ts +714 -0
- package/src/browser/shared-text-table-contract.ts +150 -0
- package/src/browser/shared-text.ts +19 -0
- package/src/capabilities/discussion-providers.ts +99 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +58 -0
- package/src/capabilities/server-invoke.ts +23 -13
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- package/src/contracts/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/contracts/shared.ts +2 -1
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -0
- package/src/server/api/respond.ts +10 -2
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +23 -10
- package/src/server/services/index.ts +1 -0
- package/src/services/accounts/app.ts +56 -17
- package/src/services/accounts/entities.ts +36 -16
- package/src/services/accounts/group-sql.ts +23 -0
- package/src/services/accounts/groups.ts +26 -17
- package/src/services/accounts/identities.ts +8 -1
- package/src/services/accounts/identity-reconciliation.ts +3 -1
- package/src/services/accounts/local-groups.ts +141 -27
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/accounts/users.ts +38 -8
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -0
- package/src/services/mandates/index.ts +2 -2
- package/src/services/mandates/policy.ts +1 -0
- package/src/services/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/services/session/index.ts +12 -3
- package/src/shared/email-html.ts +1 -0
- package/src/shared/markdown/extensions/katex.ts +8 -1
- package/src/shared/markdown/extensions/tables.ts +14 -1
- package/src/shared/markdown/index.ts +22 -11
- package/src/shared-text/anchors.ts +29 -0
- package/src/shared-text/awareness.ts +87 -0
- package/src/shared-text/changes.ts +27 -0
- package/src/shared-text/documents.ts +428 -77
- package/src/shared-text/events.ts +12 -2
- package/src/shared-text/index.ts +16 -1
- package/src/shared-text/log.ts +24 -5
- package/src/shared-text/snapshots.ts +69 -23
- package/src/shared-text/structural-history.ts +377 -0
- package/src/shared-text/table-actions.ts +432 -0
- package/src/shared-text/types.ts +24 -5
- package/src/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/CloudShell.island.tsx +19 -3
- package/src/ssr/Layout.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +2 -2
- package/src/ssr/ProfilePreferences.tsx +2 -1
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/admin-navigation.ts +1 -11
- package/src/ssr/app-launchpad.tsx +6 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/profile-actions.ts +3 -6
- package/src/ssr/shell-data.tsx +1 -1
- package/src/styles/global.css +2 -0
- package/src/workflows/language/expressions.ts +3 -2
- package/src/workflows/runtime/executor.ts +2 -1
package/src/shared-text/log.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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 =
|
|
174
|
+
let reachedTarget = covered;
|
|
160
175
|
try {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
+
};
|