@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.
- package/package.json +6 -2
- 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/dictation-runtime.ts +6 -2
- package/src/ai/executor.ts +9 -3
- package/src/ai/index.ts +1 -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/recent-resources.ts +3 -3
- package/src/ai/routes.ts +30 -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/api/accounts-entities.ts +6 -3
- package/src/api/admin-ai-skills.ts +4 -1
- package/src/api/auth.ts +9 -2
- 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/contracts/shared.ts +2 -1
- 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 +30 -13
- package/src/services/accounts/entities.ts +29 -16
- package/src/services/accounts/group-sql.ts +23 -0
- package/src/services/accounts/groups.ts +16 -13
- package/src/services/accounts/identities.ts +8 -1
- package/src/services/accounts/identity-reconciliation.ts +3 -1
- package/src/services/accounts/local-groups.ts +69 -4
- package/src/services/accounts/users.ts +38 -8
- package/src/services/mandates/index.ts +2 -2
- package/src/services/mandates/policy.ts +1 -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/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/workflows/language/expressions.ts +3 -2
- package/src/workflows/runtime/executor.ts +2 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as decoding from "lib0/decoding";
|
|
2
|
+
import * as encoding from "lib0/encoding";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
const unsigned = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
|
|
6
|
+
const positionId = z.strictObject({ client: unsigned, clock: unsigned });
|
|
7
|
+
const relativePosition = z
|
|
8
|
+
.strictObject({
|
|
9
|
+
type: positionId.nullable().optional(),
|
|
10
|
+
tname: z.string().nullable().optional(),
|
|
11
|
+
item: positionId.nullable().optional(),
|
|
12
|
+
assoc: z.number().int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
|
|
13
|
+
})
|
|
14
|
+
.refine((position) => position.type != null || position.tname != null);
|
|
15
|
+
const stateSchema = z
|
|
16
|
+
.strictObject({
|
|
17
|
+
// Identity is supplied by the authenticated transport, never copied from here.
|
|
18
|
+
user: z.unknown().optional(),
|
|
19
|
+
cursor: z.strictObject({ anchor: relativePosition, head: relativePosition }).nullable().optional(),
|
|
20
|
+
})
|
|
21
|
+
.nullable();
|
|
22
|
+
|
|
23
|
+
export class InvalidSharedTextAwarenessError extends Error {
|
|
24
|
+
constructor() {
|
|
25
|
+
super("Invalid shared text awareness update");
|
|
26
|
+
this.name = "InvalidSharedTextAwarenessError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const encode = (clientId: number, clock: number, state: unknown): Uint8Array => {
|
|
31
|
+
const encoder = encoding.createEncoder();
|
|
32
|
+
encoding.writeVarUint(encoder, 1);
|
|
33
|
+
encoding.writeVarUint(encoder, clientId);
|
|
34
|
+
encoding.writeVarUint(encoder, clock);
|
|
35
|
+
encoding.writeVarString(encoder, JSON.stringify(state));
|
|
36
|
+
return encoding.toUint8Array(encoder);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** One authorized connection, with an ID allocated uniquely by its transport. */
|
|
40
|
+
export const createTrustedTextAwareness = (config: {
|
|
41
|
+
clientId: number;
|
|
42
|
+
textName: string;
|
|
43
|
+
user: { userId: string; name: string; color: string };
|
|
44
|
+
}) => {
|
|
45
|
+
if (!Number.isSafeInteger(config.clientId) || config.clientId < 2 ** 32) throw new RangeError("Invalid server awareness ID");
|
|
46
|
+
const user = { userId: config.user.userId, name: config.user.name, color: config.user.color };
|
|
47
|
+
let localClientId: number | undefined;
|
|
48
|
+
let clock = 0;
|
|
49
|
+
let active = false;
|
|
50
|
+
const next = (state: unknown): Uint8Array => {
|
|
51
|
+
if (clock === Number.MAX_SAFE_INTEGER) throw new RangeError("Awareness clock exhausted");
|
|
52
|
+
return encode(config.clientId, ++clock, state);
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
/** Validate one local state and replace its envelope and identity. Throws on invalid input. */
|
|
56
|
+
publish(bytes: Uint8Array): Uint8Array {
|
|
57
|
+
try {
|
|
58
|
+
const decoder = decoding.createDecoder(bytes);
|
|
59
|
+
if (decoding.readVarUint(decoder) !== 1) throw new InvalidSharedTextAwarenessError();
|
|
60
|
+
const clientId = decoding.readVarUint(decoder);
|
|
61
|
+
const clientClock = decoding.readVarUint(decoder);
|
|
62
|
+
if (!Number.isSafeInteger(clientId) || !Number.isSafeInteger(clientClock)) throw new InvalidSharedTextAwarenessError();
|
|
63
|
+
const state = stateSchema.parse(JSON.parse(decoding.readVarString(decoder)));
|
|
64
|
+
if (decoder.pos !== bytes.length || (localClientId !== undefined && localClientId !== clientId))
|
|
65
|
+
throw new InvalidSharedTextAwarenessError();
|
|
66
|
+
if (
|
|
67
|
+
state?.cursor &&
|
|
68
|
+
[state.cursor.anchor, state.cursor.head].some((position) => position.tname !== config.textName || position.type != null)
|
|
69
|
+
)
|
|
70
|
+
throw new InvalidSharedTextAwarenessError();
|
|
71
|
+
const result = next(state === null ? null : { user, writerId: clientId, cursor: state.cursor ?? null });
|
|
72
|
+
localClientId = clientId;
|
|
73
|
+
active = state !== null;
|
|
74
|
+
return result;
|
|
75
|
+
} catch {
|
|
76
|
+
throw new InvalidSharedTextAwarenessError();
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
/** Publish on leave/close, including when the browser cannot send its own null state. */
|
|
80
|
+
remove(): Uint8Array | null {
|
|
81
|
+
if (!active) return null;
|
|
82
|
+
const result = next(null);
|
|
83
|
+
active = false;
|
|
84
|
+
return result;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SharedTextChange } from "./types";
|
|
2
|
+
|
|
3
|
+
export const validateChanges = (text: string, changes: readonly SharedTextChange[], maxLength: number): SharedTextChange[] => {
|
|
4
|
+
const sorted = [...changes].sort((a, b) => a.from - b.from || a.to - b.to);
|
|
5
|
+
let length = text.length;
|
|
6
|
+
const splitsCharacter = (at: number) =>
|
|
7
|
+
at > 0 && at < text.length && /[\uD800-\uDBFF]/.test(text[at - 1]!) && /[\uDC00-\uDFFF]/.test(text[at]!);
|
|
8
|
+
for (let index = 0; index < sorted.length; index++) {
|
|
9
|
+
const change = sorted[index]!;
|
|
10
|
+
const previous = sorted[index - 1];
|
|
11
|
+
if (
|
|
12
|
+
!Number.isSafeInteger(change.from) ||
|
|
13
|
+
!Number.isSafeInteger(change.to) ||
|
|
14
|
+
change.from < 0 ||
|
|
15
|
+
change.to < change.from ||
|
|
16
|
+
change.to > text.length ||
|
|
17
|
+
(change.recreate === true && change.to === change.from) ||
|
|
18
|
+
(previous && (change.from < previous.to || change.from === previous.from))
|
|
19
|
+
)
|
|
20
|
+
throw new RangeError("Shared text changes must have distinct, non-overlapping original-source ranges");
|
|
21
|
+
if (splitsCharacter(change.from) || splitsCharacter(change.to) || !change.insert.isWellFormed())
|
|
22
|
+
throw new RangeError("Shared text changes must preserve Unicode characters");
|
|
23
|
+
length += change.insert.length - (change.to - change.from);
|
|
24
|
+
}
|
|
25
|
+
if (length > maxLength) throw new RangeError("Shared text changes exceed maxLength");
|
|
26
|
+
return sorted.filter((change) => change.recreate === true || text.slice(change.from, change.to) !== change.insert);
|
|
27
|
+
};
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import type { Topic } from "@k2b/sync";
|
|
2
2
|
import * as Y from "yjs";
|
|
3
|
-
import {
|
|
3
|
+
import { validateChanges } from "./changes";
|
|
4
|
+
import { compareStreamCursor, maxStreamCursor } from "./cursor";
|
|
4
5
|
import { SHARED_TEXT_NAME, sharedTextNodeId, toBase64 } from "./events";
|
|
6
|
+
import {
|
|
7
|
+
createStructuralIngressValidation,
|
|
8
|
+
planStructuralInverse,
|
|
9
|
+
prepareIngressHistory,
|
|
10
|
+
prepareStructuralHistory,
|
|
11
|
+
SharedTextInverseDependencyError,
|
|
12
|
+
type SharedTextStructuralHistory,
|
|
13
|
+
} from "./structural-history";
|
|
14
|
+
import { TableActionRejectedError } from "./table-actions";
|
|
5
15
|
import { textDiffHunks } from "./text-diff";
|
|
6
16
|
import type {
|
|
7
17
|
SharedTextActor,
|
|
18
|
+
SharedTextChange,
|
|
8
19
|
SharedTextContributor,
|
|
9
20
|
SharedTextEditResult,
|
|
10
21
|
SharedTextSnapshot,
|
|
@@ -13,15 +24,25 @@ import type {
|
|
|
13
24
|
SharedTextSyncEvent,
|
|
14
25
|
} from "./types";
|
|
15
26
|
|
|
27
|
+
/** Text for a diffed edit, original-source changes, or an application-prepared Yjs update. */
|
|
28
|
+
type PlannedEdit = string | readonly SharedTextChange[] | { update: Uint8Array };
|
|
29
|
+
|
|
16
30
|
type DocumentLog = {
|
|
17
|
-
/** Omitted logs use Sync’s default message payload limit (128 KiB). */
|
|
31
|
+
/** Omitted logs use Sync’s default message payload limit (128 KiB) for diffs and skip the event size check. */
|
|
18
32
|
maxPayloadBytes?: number;
|
|
33
|
+
events?(
|
|
34
|
+
docId: string,
|
|
35
|
+
after: string | null,
|
|
36
|
+
until: string,
|
|
37
|
+
signal?: AbortSignal,
|
|
38
|
+
): AsyncIterable<{ cursor: string; data: SharedTextSyncEvent }>;
|
|
19
39
|
topic(): Pick<Topic<SharedTextSyncEvent>, "latestCursor" | "publish">;
|
|
20
40
|
replay(config: {
|
|
21
41
|
docId: string;
|
|
22
42
|
after: string | null;
|
|
23
43
|
until: string;
|
|
24
44
|
doc: Y.Doc;
|
|
45
|
+
restoreRevision?: string;
|
|
25
46
|
signal?: AbortSignal;
|
|
26
47
|
}): Promise<SharedTextContributor[]>;
|
|
27
48
|
};
|
|
@@ -51,109 +72,439 @@ export const createDoc = (stored: SharedTextStoredState): Y.Doc => {
|
|
|
51
72
|
};
|
|
52
73
|
|
|
53
74
|
export const bindDocuments = <Saved, Context>(
|
|
54
|
-
{ topic, replay, maxPayloadBytes
|
|
75
|
+
{ topic, replay, events, maxPayloadBytes }: DocumentLog,
|
|
55
76
|
store: SharedTextStore<Saved, Context>,
|
|
56
77
|
) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
78
|
+
const write = async (
|
|
79
|
+
docId: string,
|
|
80
|
+
options: {
|
|
81
|
+
actor: SharedTextActor | null;
|
|
82
|
+
context?: Context;
|
|
83
|
+
signal?: AbortSignal;
|
|
84
|
+
actionId?: string;
|
|
85
|
+
inverseOf?: string;
|
|
86
|
+
retained?: () => boolean;
|
|
87
|
+
validate?: () => Promise<void>;
|
|
88
|
+
},
|
|
89
|
+
update: (current: {
|
|
90
|
+
text: string;
|
|
91
|
+
cursor: string | null;
|
|
92
|
+
restoreRevision: string;
|
|
93
|
+
contributors: SharedTextContributor[];
|
|
94
|
+
state(): Uint8Array;
|
|
95
|
+
}) => PlannedEdit | Promise<PlannedEdit>,
|
|
96
|
+
maxLength?: number,
|
|
97
|
+
): Promise<SharedTextEditResult<Saved>> => {
|
|
98
|
+
if (maxLength !== undefined && (!Number.isSafeInteger(maxLength) || maxLength < 0))
|
|
99
|
+
throw new RangeError("maxLength must be a nonnegative safe integer");
|
|
100
|
+
const requestedAt = Date.now();
|
|
101
|
+
const stored = await store.load(docId, { signal: options.signal });
|
|
102
|
+
if (!stored) return { status: "notFound" };
|
|
103
|
+
const handle = topic();
|
|
104
|
+
const target = await handle.latestCursor({ tenantId: docId });
|
|
105
|
+
const doc = createDoc(stored);
|
|
106
|
+
try {
|
|
107
|
+
let contributors: SharedTextContributor[] = [];
|
|
65
108
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
109
|
+
if (target)
|
|
110
|
+
contributors = await replay({
|
|
111
|
+
docId,
|
|
112
|
+
after: stored.cursor,
|
|
113
|
+
until: target,
|
|
114
|
+
doc,
|
|
115
|
+
restoreRevision: stored.restoreRevision,
|
|
116
|
+
signal: options.signal,
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
// A cancelled request is not a sync failure: it throws like every other abort before publication.
|
|
120
|
+
options.signal?.throwIfAborted();
|
|
121
|
+
return { status: "syncFailed", error };
|
|
122
|
+
}
|
|
123
|
+
options.signal?.throwIfAborted();
|
|
124
|
+
if (maxLength !== undefined && (doc.store.pendingStructs || doc.store.pendingDs))
|
|
125
|
+
return { status: "syncFailed", error: new Error("Shared text has unresolved update dependencies") };
|
|
126
|
+
const ytext = doc.getText(SHARED_TEXT_NAME);
|
|
127
|
+
const text = ytext.toString();
|
|
128
|
+
const next = await update({
|
|
129
|
+
text,
|
|
130
|
+
cursor: target,
|
|
131
|
+
restoreRevision: stored.restoreRevision,
|
|
132
|
+
contributors,
|
|
133
|
+
state: () => Y.encodeStateAsUpdate(doc),
|
|
134
|
+
});
|
|
135
|
+
let changes: readonly SharedTextChange[] = [];
|
|
136
|
+
let prepared: Uint8Array | undefined;
|
|
137
|
+
if (maxLength === undefined) {
|
|
138
|
+
if (typeof next === "string") {
|
|
139
|
+
if (next === text) return { status: "unchanged", text };
|
|
140
|
+
changes = textDiffHunks(text, next, maxPayloadBytes ?? 128 * 1024).map((diff) => ({
|
|
141
|
+
from: diff.index,
|
|
142
|
+
to: diff.index + diff.deleteCount,
|
|
143
|
+
insert: diff.insert,
|
|
144
|
+
}));
|
|
145
|
+
} else if ("update" in next) prepared = next.update;
|
|
146
|
+
else throw new TypeError("edit must return text or a prepared update");
|
|
147
|
+
} else {
|
|
148
|
+
if (typeof next === "string" || "update" in next) throw new TypeError("change must return disjoint edits");
|
|
149
|
+
changes = validateChanges(text, next, maxLength);
|
|
150
|
+
}
|
|
151
|
+
if (!prepared && !changes.length && !options.inverseOf) return { status: "unchanged", text };
|
|
152
|
+
// A request cancelled while the callback ran must not publish a write.
|
|
153
|
+
options.signal?.throwIfAborted();
|
|
154
|
+
const beforeEditState = Y.encodeStateVector(doc);
|
|
155
|
+
const finishHistory = options.actionId ? prepareStructuralHistory(ytext, changes) : undefined;
|
|
156
|
+
if (prepared) {
|
|
157
|
+
// Application-prepared transactions preserve exact item identities and
|
|
158
|
+
// may be replayed after a partial publication/persistence failure.
|
|
159
|
+
Y.applyUpdate(doc, prepared, "server-edit");
|
|
160
|
+
} else
|
|
161
|
+
doc.transact(() => {
|
|
162
|
+
for (let index = changes.length - 1; index >= 0; index--) {
|
|
163
|
+
const change = changes[index]!;
|
|
164
|
+
if (change.to > change.from) ytext.delete(change.from, change.to - change.from);
|
|
165
|
+
if (change.insert.length) ytext.insert(change.from, change.insert);
|
|
166
|
+
}
|
|
167
|
+
}, "cli-edit");
|
|
168
|
+
const actor = options.actor;
|
|
169
|
+
const updateBytes = Y.encodeStateAsUpdate(doc, beforeEditState);
|
|
170
|
+
const receipt = options.actionId ? { actionId: options.actionId, update: updateBytes } : undefined;
|
|
171
|
+
const history: SharedTextStructuralHistory = finishHistory?.() ?? { patches: [], tables: [], ingressBarrier: true };
|
|
172
|
+
if (history && options.inverseOf) {
|
|
173
|
+
history.inverseOf = options.inverseOf;
|
|
174
|
+
history.retained = options.retained?.() ?? false;
|
|
175
|
+
}
|
|
176
|
+
const event: SharedTextSyncEvent = {
|
|
177
|
+
kind: "sync",
|
|
178
|
+
payload: toBase64(updateBytes),
|
|
179
|
+
...(options.actionId ? { actionId: options.actionId } : {}),
|
|
180
|
+
...(history ? { history } : {}),
|
|
181
|
+
restoreRevision: stored.restoreRevision,
|
|
182
|
+
originNodeId: sharedTextNodeId,
|
|
183
|
+
originPeerId: null,
|
|
184
|
+
...(actor ? { actor } : {}),
|
|
185
|
+
};
|
|
186
|
+
if (maxPayloadBytes !== undefined && new TextEncoder().encode(JSON.stringify(event)).length > maxPayloadBytes)
|
|
187
|
+
throw new RangeError("The structural receipt exceeds the document log payload budget");
|
|
188
|
+
await options.validate?.();
|
|
189
|
+
options.signal?.throwIfAborted();
|
|
190
|
+
const published = await handle.publish({
|
|
191
|
+
tenantId: docId,
|
|
192
|
+
...(maxLength !== undefined ? { expectedAfter: target } : {}),
|
|
193
|
+
data: event,
|
|
194
|
+
});
|
|
195
|
+
let persistedDoc: Y.Doc | undefined;
|
|
196
|
+
try {
|
|
197
|
+
persistedDoc = createDoc(stored);
|
|
198
|
+
await replay({
|
|
199
|
+
docId,
|
|
200
|
+
after: stored.cursor,
|
|
201
|
+
until: published.cursor,
|
|
202
|
+
doc: persistedDoc,
|
|
203
|
+
restoreRevision: stored.restoreRevision,
|
|
204
|
+
signal: options.signal,
|
|
205
|
+
});
|
|
206
|
+
const persistedText = persistedDoc.getText(SHARED_TEXT_NAME).toString();
|
|
207
|
+
const credited = new Map(contributors.map((contributor) => [`${contributor.kind}:${contributor.id}`, contributor]));
|
|
208
|
+
if (actor) credited.set(`${actor.kind}:${actor.id}`, { ...actor, lastContributedAt: new Date(requestedAt) });
|
|
209
|
+
const saved = await store.save(
|
|
210
|
+
docId,
|
|
211
|
+
{
|
|
212
|
+
state: Y.encodeStateAsUpdate(persistedDoc),
|
|
213
|
+
text: persistedText,
|
|
214
|
+
cursor: published.cursor,
|
|
215
|
+
restoreRevision: stored.restoreRevision,
|
|
216
|
+
requestedAt,
|
|
217
|
+
actor,
|
|
218
|
+
contributors: [...credited.values()],
|
|
219
|
+
},
|
|
220
|
+
options.context,
|
|
221
|
+
);
|
|
222
|
+
return { status: "edited", text: persistedText, cursor: published.cursor, saved, ...(receipt ? { receipt } : {}) };
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return { status: "persistFailed", cursor: published.cursor, error, ...(receipt ? { receipt } : {}) };
|
|
68
225
|
} finally {
|
|
69
|
-
|
|
226
|
+
persistedDoc?.destroy();
|
|
70
227
|
}
|
|
71
|
-
}
|
|
72
|
-
|
|
228
|
+
} finally {
|
|
229
|
+
doc.destroy();
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
return {
|
|
233
|
+
/** A retained cursor is authority only after document, actor and lineage checks. */
|
|
234
|
+
async inverse(
|
|
73
235
|
docId: string,
|
|
74
|
-
options: {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
236
|
+
options: {
|
|
237
|
+
actor: SharedTextActor;
|
|
238
|
+
context?: Context;
|
|
239
|
+
signal?: AbortSignal;
|
|
240
|
+
actionId: string;
|
|
241
|
+
receiptCursor: string;
|
|
242
|
+
restoreRevision: string;
|
|
243
|
+
maxLength: number;
|
|
244
|
+
validate?: () => Promise<void>;
|
|
245
|
+
},
|
|
80
246
|
): Promise<SharedTextEditResult<Saved>> {
|
|
81
|
-
|
|
247
|
+
if (!events) throw new Error("This document log cannot read structural receipts");
|
|
248
|
+
let retained = false;
|
|
249
|
+
return write(
|
|
250
|
+
docId,
|
|
251
|
+
{ ...options, inverseOf: options.receiptCursor, retained: () => retained },
|
|
252
|
+
async (current) => {
|
|
253
|
+
let receipt: SharedTextSyncEvent | undefined;
|
|
254
|
+
// Exact cursor lookup, independent of snapshot progress. A missing or
|
|
255
|
+
// expired event fails visibly; snapshots do not pin receipt history.
|
|
256
|
+
try {
|
|
257
|
+
for await (const event of events(docId, options.receiptCursor, options.receiptCursor, options.signal)) receipt = event.data;
|
|
258
|
+
} catch {
|
|
259
|
+
throw new TableActionRejectedError();
|
|
260
|
+
}
|
|
261
|
+
if (
|
|
262
|
+
!receipt?.history ||
|
|
263
|
+
!receipt.actionId ||
|
|
264
|
+
receipt.actor?.kind !== options.actor.kind ||
|
|
265
|
+
receipt.actor.id !== options.actor.id ||
|
|
266
|
+
receipt.restoreRevision !== options.restoreRevision ||
|
|
267
|
+
current.restoreRevision !== options.restoreRevision
|
|
268
|
+
)
|
|
269
|
+
throw new TableActionRejectedError();
|
|
270
|
+
const alignmentColumns = receipt.history.alignmentColumns;
|
|
271
|
+
if (alignmentColumns?.length && current.cursor && current.cursor !== options.receiptCursor) {
|
|
272
|
+
try {
|
|
273
|
+
for await (const later of events(docId, options.receiptCursor, current.cursor, options.signal)) {
|
|
274
|
+
const actor = later.data.actor;
|
|
275
|
+
const other = !actor || actor.kind !== options.actor.kind || actor.id !== options.actor.id;
|
|
276
|
+
if (other && later.data.history?.alignmentColumns?.some((column) => alignmentColumns.includes(column)))
|
|
277
|
+
throw new SharedTextInverseDependencyError("alignment");
|
|
278
|
+
}
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error instanceof SharedTextInverseDependencyError) throw error;
|
|
281
|
+
options.signal?.throwIfAborted();
|
|
282
|
+
throw new TableActionRejectedError();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const copy = new Y.Doc();
|
|
286
|
+
try {
|
|
287
|
+
Y.applyUpdate(copy, current.state());
|
|
288
|
+
const planned = planStructuralInverse(copy.getText(SHARED_TEXT_NAME), receipt.history);
|
|
289
|
+
retained = planned.retained;
|
|
290
|
+
return planned.changes;
|
|
291
|
+
} finally {
|
|
292
|
+
copy.destroy();
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
options.maxLength,
|
|
296
|
+
);
|
|
297
|
+
},
|
|
298
|
+
/** Validate before retained publication; rejected bytes remain client-owned. */
|
|
299
|
+
async accept(
|
|
300
|
+
docId: string,
|
|
301
|
+
options: {
|
|
302
|
+
actor: SharedTextActor;
|
|
303
|
+
update: Uint8Array;
|
|
304
|
+
baseCursor: string | null;
|
|
305
|
+
restoreRevision: string;
|
|
306
|
+
originNodeId: string;
|
|
307
|
+
originPeerId: string | null;
|
|
308
|
+
signal?: AbortSignal;
|
|
309
|
+
validate?: () => Promise<boolean>;
|
|
310
|
+
/** Application-owned limit on the merged canonical UTF-16 text. */
|
|
311
|
+
maxLength?: number;
|
|
312
|
+
/** Validate application-owned fields in the fully merged encoded state. */
|
|
313
|
+
validateState?: (state: Uint8Array) => boolean | Promise<boolean>;
|
|
314
|
+
},
|
|
315
|
+
): Promise<{ status: "accepted"; cursor: string } | { status: "recoveryRequired" | "notFound" }> {
|
|
316
|
+
if (options.maxLength !== undefined && (!Number.isSafeInteger(options.maxLength) || options.maxLength < 0))
|
|
317
|
+
throw new RangeError("maxLength must be a nonnegative safe integer");
|
|
318
|
+
Y.decodeUpdate(options.update);
|
|
319
|
+
if (!events) throw new Error("This document log cannot validate ingress");
|
|
82
320
|
const stored = await store.load(docId, { signal: options.signal });
|
|
83
321
|
if (!stored) return { status: "notFound" };
|
|
322
|
+
if (stored.restoreRevision !== options.restoreRevision) return { status: "recoveryRequired" };
|
|
84
323
|
const handle = topic();
|
|
85
324
|
const target = await handle.latestCursor({ tenantId: docId });
|
|
325
|
+
// Idle notes use a shared-topic watermark, which may be ahead of their
|
|
326
|
+
// own last retained event (or have no retained event at all). CAS still
|
|
327
|
+
// targets the tenant head; browser coverage belongs to the snapshot too.
|
|
328
|
+
const covered = maxStreamCursor(stored.cursor, target);
|
|
329
|
+
if (options.baseCursor) {
|
|
330
|
+
try {
|
|
331
|
+
if (!covered || compareStreamCursor(options.baseCursor, covered) > 0) return { status: "recoveryRequired" };
|
|
332
|
+
} catch {
|
|
333
|
+
return { status: "recoveryRequired" };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!target && stored.cursor !== options.baseCursor) return { status: "recoveryRequired" };
|
|
86
337
|
const doc = createDoc(stored);
|
|
87
338
|
try {
|
|
88
|
-
|
|
339
|
+
if (target)
|
|
340
|
+
await replay({
|
|
341
|
+
docId,
|
|
342
|
+
after: stored.cursor,
|
|
343
|
+
until: target,
|
|
344
|
+
doc,
|
|
345
|
+
restoreRevision: stored.restoreRevision,
|
|
346
|
+
signal: options.signal,
|
|
347
|
+
});
|
|
348
|
+
const validation = createStructuralIngressValidation(doc, options.update);
|
|
89
349
|
try {
|
|
90
|
-
if (target
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
350
|
+
if (target && (!options.baseCursor || compareStreamCursor(options.baseCursor, target) < 0)) {
|
|
351
|
+
try {
|
|
352
|
+
for await (const event of events(docId, options.baseCursor, target, options.signal)) {
|
|
353
|
+
options.signal?.throwIfAborted();
|
|
354
|
+
if (event.data.restoreRevision !== undefined && event.data.restoreRevision !== options.restoreRevision) continue;
|
|
355
|
+
// An unreferenced send marker has no domain or text effect.
|
|
356
|
+
// It must not make an otherwise valid pending edit unrecoverable.
|
|
357
|
+
if (event.data.kind === "freeze") continue;
|
|
358
|
+
// Old/unclassified retained events cannot prove that an edit's
|
|
359
|
+
// original table/row targets survived. Do not guess at ingress.
|
|
360
|
+
const history = event.data.history;
|
|
361
|
+
if (!history) return { status: "recoveryRequired" };
|
|
362
|
+
let inverseSource: SharedTextStructuralHistory | undefined;
|
|
363
|
+
if (history.inverseOf && history.patches.some((patch) => patch.inserted.length > 0)) {
|
|
364
|
+
// A pure removal can be recreated by a LATER inverse receipt.
|
|
365
|
+
// Its old IDs must not be acknowledged as a removal of the
|
|
366
|
+
// new target. Exact retained lookup keeps this proof bounded.
|
|
367
|
+
for await (const original of events(docId, history.inverseOf, history.inverseOf, options.signal)) {
|
|
368
|
+
if (original.data.restoreRevision === options.restoreRevision) inverseSource = original.data.history;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (!validation.check(history, inverseSource)) return { status: "recoveryRequired" };
|
|
372
|
+
}
|
|
373
|
+
} catch {
|
|
374
|
+
options.signal?.throwIfAborted();
|
|
375
|
+
return { status: "recoveryRequired" };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} finally {
|
|
379
|
+
validation.dispose();
|
|
95
380
|
}
|
|
381
|
+
// A retained echo confirms input, not a promise that missing causal
|
|
382
|
+
// predecessors might arrive later. Never strand confirmed bytes behind
|
|
383
|
+
// a predecessor which was rejected for requiring recovery.
|
|
384
|
+
const finishHistory = prepareIngressHistory(doc.getText(SHARED_TEXT_NAME));
|
|
385
|
+
Y.applyUpdate(doc, options.update, "ingress-probe");
|
|
386
|
+
if (doc.store.pendingStructs || doc.store.pendingDs) return { status: "recoveryRequired" };
|
|
387
|
+
if (options.maxLength !== undefined && doc.getText(SHARED_TEXT_NAME).length > options.maxLength)
|
|
388
|
+
return { status: "recoveryRequired" };
|
|
389
|
+
if (options.validateState && !(await options.validateState(Y.encodeStateAsUpdate(doc)))) return { status: "recoveryRequired" };
|
|
96
390
|
options.signal?.throwIfAborted();
|
|
97
|
-
|
|
98
|
-
const text = ytext.toString();
|
|
99
|
-
const next = await update({ text, contributors, state: () => Y.encodeStateAsUpdate(doc) });
|
|
100
|
-
if (next === text) return { status: "unchanged", text };
|
|
101
|
-
// A request cancelled while the callback ran must not publish a write.
|
|
391
|
+
if (options.validate && !(await options.validate())) return { status: "recoveryRequired" };
|
|
102
392
|
options.signal?.throwIfAborted();
|
|
103
|
-
const beforeEditState = Y.encodeStateVector(doc);
|
|
104
|
-
if (typeof next === "string") {
|
|
105
|
-
const hunks = textDiffHunks(text, next, maxPayloadBytes);
|
|
106
|
-
doc.transact(() => {
|
|
107
|
-
for (const diff of hunks.reverse()) {
|
|
108
|
-
if (diff.deleteCount > 0) ytext.delete(diff.index, diff.deleteCount);
|
|
109
|
-
if (diff.insert.length > 0) ytext.insert(diff.index, diff.insert);
|
|
110
|
-
}
|
|
111
|
-
}, "cli-edit");
|
|
112
|
-
} else {
|
|
113
|
-
// Application-prepared transactions preserve exact item identities and
|
|
114
|
-
// may be replayed after a partial publication/persistence failure.
|
|
115
|
-
Y.applyUpdate(doc, next.update, "server-edit");
|
|
116
|
-
}
|
|
117
|
-
const actor = options.actor;
|
|
118
393
|
const published = await handle.publish({
|
|
119
394
|
tenantId: docId,
|
|
395
|
+
expectedAfter: target,
|
|
120
396
|
data: {
|
|
121
397
|
kind: "sync",
|
|
122
|
-
payload: toBase64(
|
|
398
|
+
payload: toBase64(options.update),
|
|
399
|
+
history: finishHistory(),
|
|
400
|
+
actor: options.actor,
|
|
401
|
+
restoreRevision: options.restoreRevision,
|
|
402
|
+
originNodeId: options.originNodeId,
|
|
403
|
+
originPeerId: options.originPeerId,
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
return { status: "accepted", cursor: published.cursor };
|
|
407
|
+
} finally {
|
|
408
|
+
doc.destroy();
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
/**
|
|
412
|
+
* Capture an exact ordered boundary. The application commits this cursor,
|
|
413
|
+
* full state and generation with its send transaction; an unreferenced
|
|
414
|
+
* marker has no domain effect. Later updates are outside the captured state.
|
|
415
|
+
*/
|
|
416
|
+
async freeze(
|
|
417
|
+
docId: string,
|
|
418
|
+
options: {
|
|
419
|
+
actor: SharedTextActor;
|
|
420
|
+
restoreRevision: string;
|
|
421
|
+
signal?: AbortSignal;
|
|
422
|
+
validate: (state: Uint8Array) => Promise<void>;
|
|
423
|
+
},
|
|
424
|
+
): Promise<{ state: Uint8Array; text: string; cursor: string; restoreRevision: string } | null> {
|
|
425
|
+
options.signal?.throwIfAborted();
|
|
426
|
+
const stored = await store.load(docId, { signal: options.signal });
|
|
427
|
+
if (!stored) return null;
|
|
428
|
+
if (stored.restoreRevision !== options.restoreRevision) throw new Error("Document generation changed before freeze");
|
|
429
|
+
const handle = topic();
|
|
430
|
+
const target = await handle.latestCursor({ tenantId: docId });
|
|
431
|
+
const doc = createDoc(stored);
|
|
432
|
+
try {
|
|
433
|
+
if (target)
|
|
434
|
+
await replay({
|
|
435
|
+
docId,
|
|
436
|
+
after: stored.cursor,
|
|
437
|
+
until: target,
|
|
438
|
+
doc,
|
|
439
|
+
restoreRevision: stored.restoreRevision,
|
|
440
|
+
signal: options.signal,
|
|
441
|
+
});
|
|
442
|
+
if (doc.store.pendingStructs || doc.store.pendingDs) throw new Error("Document has unresolved dependencies before freeze");
|
|
443
|
+
const state = Y.encodeStateAsUpdate(doc);
|
|
444
|
+
// Give validation a copy: it cannot alter the state returned for sending.
|
|
445
|
+
await options.validate(state.slice());
|
|
446
|
+
options.signal?.throwIfAborted();
|
|
447
|
+
const published = await handle.publish({
|
|
448
|
+
tenantId: docId,
|
|
449
|
+
expectedAfter: target,
|
|
450
|
+
data: {
|
|
451
|
+
kind: "freeze",
|
|
452
|
+
payload: "",
|
|
453
|
+
restoreRevision: stored.restoreRevision,
|
|
454
|
+
actor: options.actor,
|
|
123
455
|
originNodeId: sharedTextNodeId,
|
|
124
456
|
originPeerId: null,
|
|
125
|
-
...(actor ? { actor } : {}),
|
|
126
457
|
},
|
|
127
458
|
});
|
|
128
|
-
|
|
129
|
-
try {
|
|
130
|
-
persistedDoc = createDoc(stored);
|
|
131
|
-
await replay({ docId, after: stored.cursor, until: published.cursor, doc: persistedDoc, signal: options.signal });
|
|
132
|
-
const persistedText = persistedDoc.getText(SHARED_TEXT_NAME).toString();
|
|
133
|
-
const credited = new Map(contributors.map((contributor) => [`${contributor.kind}:${contributor.id}`, contributor]));
|
|
134
|
-
if (actor) credited.set(`${actor.kind}:${actor.id}`, { ...actor, lastContributedAt: new Date(requestedAt) });
|
|
135
|
-
const saved = await store.save(
|
|
136
|
-
docId,
|
|
137
|
-
{
|
|
138
|
-
state: Y.encodeStateAsUpdate(persistedDoc),
|
|
139
|
-
text: persistedText,
|
|
140
|
-
cursor: published.cursor,
|
|
141
|
-
restoreRevision: stored.restoreRevision,
|
|
142
|
-
requestedAt,
|
|
143
|
-
actor,
|
|
144
|
-
contributors: [...credited.values()],
|
|
145
|
-
},
|
|
146
|
-
options.context,
|
|
147
|
-
);
|
|
148
|
-
return { status: "edited", text: persistedText, cursor: published.cursor, saved };
|
|
149
|
-
} catch (error) {
|
|
150
|
-
return { status: "persistFailed", cursor: published.cursor, error };
|
|
151
|
-
} finally {
|
|
152
|
-
persistedDoc?.destroy();
|
|
153
|
-
}
|
|
459
|
+
return { state, text: doc.getText(SHARED_TEXT_NAME).toString(), cursor: published.cursor, restoreRevision: stored.restoreRevision };
|
|
154
460
|
} finally {
|
|
155
461
|
doc.destroy();
|
|
156
462
|
}
|
|
157
463
|
},
|
|
464
|
+
async read(docId: string, options: { signal?: AbortSignal } = {}): Promise<SharedTextSnapshot | null> {
|
|
465
|
+
const stored = await store.load(docId, options);
|
|
466
|
+
if (!stored) return null;
|
|
467
|
+
const latest = await topic().latestCursor({ tenantId: docId });
|
|
468
|
+
if (!latest || (stored.cursor && compareStreamCursor(latest, stored.cursor) <= 0))
|
|
469
|
+
return { state: stored.state, text: stored.text, cursor: stored.cursor, restoreRevision: stored.restoreRevision };
|
|
470
|
+
const doc = createDoc(stored);
|
|
471
|
+
try {
|
|
472
|
+
await replay({ docId, after: stored.cursor, until: latest, doc, restoreRevision: stored.restoreRevision, signal: options.signal });
|
|
473
|
+
return {
|
|
474
|
+
state: Y.encodeStateAsUpdate(doc),
|
|
475
|
+
text: doc.getText(SHARED_TEXT_NAME).toString(),
|
|
476
|
+
cursor: latest,
|
|
477
|
+
restoreRevision: stored.restoreRevision,
|
|
478
|
+
};
|
|
479
|
+
} finally {
|
|
480
|
+
doc.destroy();
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
edit(
|
|
484
|
+
docId: string,
|
|
485
|
+
options: { actor: SharedTextActor | null; context?: Context; signal?: AbortSignal },
|
|
486
|
+
update: (current: {
|
|
487
|
+
text: string;
|
|
488
|
+
contributors: SharedTextContributor[];
|
|
489
|
+
state(): Uint8Array;
|
|
490
|
+
}) => string | { update: Uint8Array } | Promise<string | { update: Uint8Array }>,
|
|
491
|
+
): Promise<SharedTextEditResult<Saved>> {
|
|
492
|
+
return write(docId, options, update);
|
|
493
|
+
},
|
|
494
|
+
/** Disjoint original-source changes, accepted only while the captured tenant head is unchanged. */
|
|
495
|
+
async change(
|
|
496
|
+
docId: string,
|
|
497
|
+
options: { actor: SharedTextActor | null; context?: Context; signal?: AbortSignal; maxLength: number; actionId?: string },
|
|
498
|
+
update: (current: {
|
|
499
|
+
text: string;
|
|
500
|
+
restoreRevision: string;
|
|
501
|
+
contributors: SharedTextContributor[];
|
|
502
|
+
state(): Uint8Array;
|
|
503
|
+
}) => readonly SharedTextChange[] | Promise<readonly SharedTextChange[]>,
|
|
504
|
+
): Promise<SharedTextEditResult<Saved>> {
|
|
505
|
+
if (!Number.isSafeInteger(options.maxLength) || options.maxLength < 0)
|
|
506
|
+
throw new RangeError("maxLength must be a nonnegative safe integer");
|
|
507
|
+
return write(docId, options, update, options.maxLength);
|
|
508
|
+
},
|
|
158
509
|
};
|
|
159
510
|
};
|