@agent-native/core 0.211.0 → 0.211.1-nightly-20261020213022

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 (52) hide show
  1. package/dist/cli/template-baseline.js +18 -5
  2. package/dist/cli/template-sync.js +100 -19
  3. package/dist/collab/__fixtures__/live-copy-tasks.d.ts +1 -0
  4. package/dist/collab/__fixtures__/live-copy-tasks.js +92 -0
  5. package/dist/collab/awareness.d.ts +2 -2
  6. package/dist/collab/bounded-task-bundle.d.ts +17 -0
  7. package/dist/collab/bounded-task-bundle.js +39 -0
  8. package/dist/collab/bounded-task-source.d.ts +14 -0
  9. package/dist/collab/bounded-task-source.js +8 -0
  10. package/dist/collab/bounded-tasks.d.ts +67 -0
  11. package/dist/collab/bounded-tasks.js +439 -0
  12. package/dist/collab/index.d.ts +5 -2
  13. package/dist/collab/index.js +5 -2
  14. package/dist/collab/live-copy-tasks.d.ts +80 -0
  15. package/dist/collab/live-copy-tasks.js +330 -0
  16. package/dist/collab/live-copy.d.ts +188 -0
  17. package/dist/collab/live-copy.js +415 -0
  18. package/dist/collab/routes.d.ts +6 -1
  19. package/dist/collab/routes.js +24 -8
  20. package/dist/collab/storage.d.ts +53 -1
  21. package/dist/collab/storage.js +93 -5
  22. package/dist/collab/ydoc-manager.d.ts +92 -3
  23. package/dist/collab/ydoc-manager.js +447 -38
  24. package/dist/db/client.d.ts +6 -0
  25. package/dist/db/client.js +25 -5
  26. package/dist/deploy/build.js +2 -0
  27. package/dist/extensions/actions.js +1 -0
  28. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  29. package/dist/jobs/actions/manage-recurring-job.d.ts +1 -1
  30. package/dist/mcp/agent-connected.js +5 -1
  31. package/dist/mcp/build-server.js +39 -10
  32. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  33. package/dist/org/handlers.d.ts +1 -1
  34. package/dist/org/index.d.ts +1 -1
  35. package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
  36. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  37. package/dist/resources/handlers.d.ts +1 -1
  38. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  39. package/dist/server/agent-engine-ollama-models-route.d.ts +2 -2
  40. package/dist/server/collab-plugin.d.ts +10 -1
  41. package/dist/server/collab-plugin.js +89 -20
  42. package/dist/server/realtime-token.d.ts +1 -1
  43. package/dist/shared/optional-node-builtins.d.ts +1 -0
  44. package/dist/shared/optional-node-builtins.js +3 -0
  45. package/dist/tracking/providers.d.ts +1 -0
  46. package/dist/tracking/providers.js +2 -1
  47. package/dist/triggers/actions/manage-automation.d.ts +2 -2
  48. package/dist/vite/bounded-task-plugin.d.ts +22 -0
  49. package/dist/vite/bounded-task-plugin.js +44 -0
  50. package/dist/vite/client.js +2 -0
  51. package/dist/vitest-config.js +3 -0
  52. package/package.json +4 -3
@@ -0,0 +1,415 @@
1
+ import { getDbExec, getScopedDbExec, insideDbTransactionCallback, withDbExec, } from "../db/client.js";
2
+ import { BoundedTaskError } from "./bounded-tasks.js";
3
+ import { LIVE_COPY_TASK, } from "./live-copy-tasks.js";
4
+ import { CollabStateMalformedError, collabStateDigest, ensureTable, loadYDocRecordStrict, loadYDocStampWithClient, trySaveYDocStateWithClient, } from "./storage.js";
5
+ // A live copy is a collab document whose Yjs state is the only writable copy
6
+ // of a body. Every commit derives the body's text from that state and writes
7
+ // both in one transaction, so the stored text is always derive(live copy) at
8
+ // the stored version. This side only moves bytes: the Yjs work runs in the
9
+ // binding's bounded runner (see live-copy-tasks.ts).
10
+ export const LIVE_COPY_DEFAULT_FIELD = "default";
11
+ const DEFAULT_MAX_STATE_BYTES = 4 * 1024 * 1024;
12
+ const DEFAULT_MAX_WAITING_WRITES = 16;
13
+ export class LiveCopyError extends Error {
14
+ code;
15
+ docId;
16
+ /**
17
+ * Refusal reason from the task, the malformed-state reason, the bounded
18
+ * task error code, or the refused path, depending on `code`.
19
+ */
20
+ reason;
21
+ constructor(code, docId, message, options = {}) {
22
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
23
+ this.name = "LiveCopyError";
24
+ this.code = code;
25
+ this.docId = docId;
26
+ if (options.reason !== undefined)
27
+ this.reason = options.reason;
28
+ }
29
+ }
30
+ const LIVE_COPY_BINDINGS_KEY = "__agentNativeLiveCopies__";
31
+ const bindingsGlobal = globalThis;
32
+ const bindings = (bindingsGlobal[LIVE_COPY_BINDINGS_KEY] ??= new Map());
33
+ /**
34
+ * Declares which collab documents are live copies and how their text is
35
+ * derived. While a binding exists, core's other write paths (text, JSON,
36
+ * search-replace, prepared mutations, and the text/JSON/XML seeders) refuse
37
+ * documents it covers.
38
+ */
39
+ export function defineLiveCopy(options) {
40
+ if (typeof options.name !== "string" || options.name.length === 0) {
41
+ throw new TypeError("defineLiveCopy needs a name");
42
+ }
43
+ if (typeof options.appliesTo !== "function") {
44
+ throw new TypeError("defineLiveCopy needs an appliesTo(docId) function");
45
+ }
46
+ if (!options.runner || typeof options.runner.run !== "function") {
47
+ throw new TypeError("defineLiveCopy needs a bounded task runner");
48
+ }
49
+ const maxStateBytes = options.maxStateBytes ?? DEFAULT_MAX_STATE_BYTES;
50
+ if (!Number.isFinite(maxStateBytes) || maxStateBytes <= 0) {
51
+ throw new RangeError("defineLiveCopy maxStateBytes must be positive");
52
+ }
53
+ const maxWaitingWrites = options.maxWaitingWrites ?? DEFAULT_MAX_WAITING_WRITES;
54
+ if (!Number.isSafeInteger(maxWaitingWrites) || maxWaitingWrites < 0) {
55
+ throw new RangeError("defineLiveCopy maxWaitingWrites must be a non-negative integer");
56
+ }
57
+ const binding = Object.freeze({
58
+ name: options.name,
59
+ appliesTo: options.appliesTo,
60
+ runner: options.runner,
61
+ field: options.field ?? LIVE_COPY_DEFAULT_FIELD,
62
+ maxStateBytes,
63
+ maxWaitingWrites,
64
+ ...(options.persist ? { persist: options.persist } : {}),
65
+ });
66
+ bindings.set(binding.name, binding);
67
+ return binding;
68
+ }
69
+ /** Synchronous, so a caller can keep main's behavior when none is defined. */
70
+ export function liveCopiesDefined() {
71
+ return bindings.size > 0;
72
+ }
73
+ /**
74
+ * Removes a binding so its documents take ordinary collab writes again. A
75
+ * binding already replaced under its name leaves the replacement in place.
76
+ */
77
+ export function releaseLiveCopy(binding) {
78
+ if (bindings.get(binding.name) === binding)
79
+ bindings.delete(binding.name);
80
+ }
81
+ export async function liveCopyBindingFor(docId) {
82
+ if (bindings.size === 0)
83
+ return null;
84
+ for (const binding of bindings.values()) {
85
+ if (await binding.appliesTo(docId))
86
+ return binding;
87
+ }
88
+ return null;
89
+ }
90
+ /**
91
+ * Throws `write_refused` when a live copy covers `docId`. With no binding
92
+ * defined it returns synchronously, so a write that has not taken its lock
93
+ * yet keeps its place in line.
94
+ */
95
+ export function refuseLiveCopyWrite(docId, path) {
96
+ if (bindings.size === 0)
97
+ return undefined;
98
+ return liveCopyBindingFor(docId).then((binding) => {
99
+ if (binding)
100
+ throw liveCopyWriteRefused(docId, path);
101
+ });
102
+ }
103
+ export function liveCopyWriteRefused(docId, path) {
104
+ return new LiveCopyError("write_refused", docId, `Document ${docId} is a live copy; ${path} would write it without deriving its text.`, { reason: path });
105
+ }
106
+ export async function assertLiveCopy(binding, docId) {
107
+ const covers = await binding.appliesTo(docId);
108
+ assertActiveLiveCopy(binding, docId);
109
+ if (!covers) {
110
+ throw new LiveCopyError("not_live_copy", docId, `Document ${docId} is not a live copy for this binding.`);
111
+ }
112
+ }
113
+ /** Throws unless `binding` is still the one registered under its name. */
114
+ export function assertActiveLiveCopy(binding, docId) {
115
+ if (bindings.get(binding.name) === binding)
116
+ return;
117
+ throw new LiveCopyError("not_live_copy", docId, `Live copy binding ${binding.name} was released or replaced; use the binding defineLiveCopy returned last.`, { reason: "binding_inactive" });
118
+ }
119
+ /**
120
+ * A commit updates this instance's cache and notifies connected editors as
121
+ * soon as it lands, so it must own its transaction: inside a caller's, a
122
+ * later rollback would leave editors holding state that was never saved.
123
+ */
124
+ export function assertOwnTransaction(docId, path) {
125
+ if (getScopedDbExec() === undefined && !insideDbTransactionCallback()) {
126
+ return;
127
+ }
128
+ throw new LiveCopyError("write_refused", docId, `${path} was called for live copy ${docId} inside a database transaction. Call it outside the transaction, and write the app's rows in the binding's persist hook, or in mutateLiveCopy's persist option for rows that belong to one edit.`, { reason: "caller_transaction" });
129
+ }
130
+ /**
131
+ * The stored bytes, read strictly: unreadable base64 or a bad version throws
132
+ * `malformed_state` instead of reading as empty. The bytes are not decoded
133
+ * as Yjs here; the tasks do that in the runner.
134
+ */
135
+ export async function readStoredLiveCopy(binding, client, docId) {
136
+ let record;
137
+ try {
138
+ record = await loadYDocRecordStrict(client, docId, binding.maxStateBytes);
139
+ }
140
+ catch (error) {
141
+ if (error instanceof CollabStateMalformedError) {
142
+ throw new LiveCopyError("malformed_state", docId, `Stored live copy for ${docId} is unreadable (${error.reason}).`, { reason: error.reason, cause: error });
143
+ }
144
+ throw error;
145
+ }
146
+ if (record.kind === "absent")
147
+ return { kind: "absent" };
148
+ if (record.kind === "too_large") {
149
+ throw new LiveCopyError("state_too_large", docId, `Stored live copy ${docId} is about ${record.bytes} bytes; the limit is ${binding.maxStateBytes}.`, { reason: String(record.bytes) });
150
+ }
151
+ assertStateSize(binding, docId, record.state, "Stored live copy");
152
+ return {
153
+ kind: "stored",
154
+ state: record.state,
155
+ version: record.version,
156
+ digest: await collabStateDigest(record.state),
157
+ };
158
+ }
159
+ function assertStateSize(binding, docId, state, what) {
160
+ if (state.length > binding.maxStateBytes) {
161
+ throw new LiveCopyError("state_too_large", docId, `${what} ${docId} is ${state.length} bytes; the limit is ${binding.maxStateBytes}.`, { reason: String(state.length) });
162
+ }
163
+ }
164
+ const TASK_FAILURE_CODES = new Set([
165
+ "malformed_state",
166
+ "unsupported_content",
167
+ "state_too_large",
168
+ "not_seeded",
169
+ "derive_refused",
170
+ "derive_failed",
171
+ "build_refused",
172
+ "build_failed",
173
+ ]);
174
+ /**
175
+ * Runs a live-copy task and returns its result once it has a shape `kinds`
176
+ * allows. Runner failures become `failure` with the runner's error code as
177
+ * the reason; a task's typed failure becomes a `LiveCopyError` with its code.
178
+ */
179
+ async function runLiveCopyTask(binding, docId, task, input, failure, kinds) {
180
+ let output;
181
+ try {
182
+ output = await binding.runner.run(task, {
183
+ ...input,
184
+ field: binding.field,
185
+ maxStateBytes: binding.maxStateBytes,
186
+ });
187
+ }
188
+ catch (error) {
189
+ if (error instanceof BoundedTaskError) {
190
+ throw new LiveCopyError(failure, docId, `Live copy task ${task} for ${docId} failed: ${error.message}`, { reason: error.code, cause: error });
191
+ }
192
+ throw error;
193
+ }
194
+ const result = output;
195
+ const kind = typeof result === "object" && result ? result.kind : undefined;
196
+ if (kind === "failed") {
197
+ const { code, reason, message } = result;
198
+ if (typeof code === "string" &&
199
+ TASK_FAILURE_CODES.has(code) &&
200
+ typeof reason === "string") {
201
+ throw new LiveCopyError(code, docId, `Live copy ${docId} ${String(message)}.`, { reason });
202
+ }
203
+ }
204
+ else if (typeof kind === "string" && kinds.includes(kind)) {
205
+ const checked = result;
206
+ const { state, text, update } = checked;
207
+ const textOk = text === undefined || typeof text === "string";
208
+ if (kind !== "changed" && textOk)
209
+ return checked;
210
+ if (state instanceof Uint8Array &&
211
+ typeof text === "string" &&
212
+ (update === undefined || update instanceof Uint8Array)) {
213
+ assertStateSize(binding, docId, state, "Live copy");
214
+ return checked;
215
+ }
216
+ }
217
+ throw new LiveCopyError(failure, docId, `Live copy task ${task} for ${docId} returned a result it may not; build the runner's module with defineLiveCopyTasks.`, { reason: "invalid_output" });
218
+ }
219
+ function required(docId, value, check, failure) {
220
+ if (check(value))
221
+ return value;
222
+ throw new LiveCopyError(failure, docId, `Live copy task for ${docId} left out part of its result.`, { reason: "invalid_output" });
223
+ }
224
+ const isString = (value) => typeof value === "string";
225
+ const isBytes = (value) => value instanceof Uint8Array;
226
+ function catchUpOf(docId, result, failure) {
227
+ return {
228
+ stateVector: required(docId, result.stateVector, isBytes, failure),
229
+ deletions: required(docId, result.deletions, isBytes, failure),
230
+ };
231
+ }
232
+ /** Validates stored state in the runner; derives its text when asked. */
233
+ export async function readLiveCopyState(binding, docId, state, derive) {
234
+ const input = {
235
+ state,
236
+ derive,
237
+ };
238
+ const result = await runLiveCopyTask(binding, docId, LIVE_COPY_TASK.read, input, "derive_failed", ["unseeded", "live"]);
239
+ const catchUp = catchUpOf(docId, result, "derive_failed");
240
+ if (result.kind === "unseeded")
241
+ return { kind: "unseeded", catchUp };
242
+ return {
243
+ kind: "live",
244
+ text: derive
245
+ ? required(docId, result.text, isString, "derive_failed")
246
+ : null,
247
+ catchUp,
248
+ };
249
+ }
250
+ /** Merges a client update in the runner; null state means absent. */
251
+ export async function applyLiveCopyUpdate(binding, docId, state, update) {
252
+ const input = {
253
+ state,
254
+ update,
255
+ };
256
+ const result = await runLiveCopyTask(binding, docId, LIVE_COPY_TASK.apply, input, "derive_failed", ["unchanged", "changed"]);
257
+ if (result.kind === "unchanged")
258
+ return { kind: "unchanged" };
259
+ return {
260
+ kind: "changed",
261
+ state: result.state,
262
+ text: result.text,
263
+ catchUp: catchUpOf(docId, result, "derive_failed"),
264
+ };
265
+ }
266
+ /** Seeds an absent or unseeded copy; a live one comes back with its text. */
267
+ export async function seedLiveCopyState(binding, docId, state, source) {
268
+ const input = {
269
+ state,
270
+ source,
271
+ };
272
+ const result = await runLiveCopyTask(binding, docId, LIVE_COPY_TASK.seed, input, "build_failed", ["live", "changed"]);
273
+ if (result.kind === "live") {
274
+ return {
275
+ kind: "live",
276
+ text: required(docId, result.text, isString, "build_failed"),
277
+ };
278
+ }
279
+ return {
280
+ kind: "changed",
281
+ state: result.state,
282
+ text: result.text,
283
+ update: required(docId, result.update, isBytes, "build_failed"),
284
+ catchUp: catchUpOf(docId, result, "build_failed"),
285
+ };
286
+ }
287
+ /** Runs a named edit from the runner's module on seeded state. */
288
+ export async function editLiveCopyState(binding, docId, state, edit, editInput) {
289
+ const input = {
290
+ state,
291
+ edit,
292
+ input: editInput,
293
+ };
294
+ const result = await runLiveCopyTask(binding, docId, LIVE_COPY_TASK.edit, input, "build_failed", ["unchanged", "changed"]);
295
+ if (result.kind === "unchanged") {
296
+ return {
297
+ kind: "unchanged",
298
+ text: required(docId, result.text, isString, "build_failed"),
299
+ };
300
+ }
301
+ return {
302
+ kind: "changed",
303
+ state: result.state,
304
+ text: result.text,
305
+ update: required(docId, result.update, isBytes, "build_failed"),
306
+ catchUp: catchUpOf(docId, result, "build_failed"),
307
+ };
308
+ }
309
+ class CommitVersionConflict extends Error {
310
+ }
311
+ function persistOutcome(docId, value) {
312
+ if (value === undefined)
313
+ return { liveCopy: "derived" };
314
+ if (typeof value === "object" && value !== null) {
315
+ const { derived, reason } = value;
316
+ if (derived === true)
317
+ return { liveCopy: "derived" };
318
+ if (derived === false && typeof reason === "string" && reason !== "") {
319
+ return { liveCopy: "not_derived", liveCopyReason: reason };
320
+ }
321
+ }
322
+ throw new TypeError(`Live copy persist for ${docId} must return nothing, { derived: true }, or { derived: false, reason }.`);
323
+ }
324
+ async function runLiveCopyTransaction(docId, write) {
325
+ const exec = getDbExec();
326
+ if (typeof exec.transaction !== "function") {
327
+ throw new LiveCopyError("write_refused", docId, `Live copy ${docId} needs a database that supports transactions.`, { reason: "no_transactions" });
328
+ }
329
+ await exec.transaction(write);
330
+ }
331
+ async function runCallPersist(docId, tx, persist) {
332
+ const value = await withDbExec(tx, () => persist(tx));
333
+ if (value !== undefined) {
334
+ throw new TypeError(`The persist passed for live copy ${docId} must return nothing.`);
335
+ }
336
+ }
337
+ export function sameStamp(a, b) {
338
+ return a?.version === b?.version && a?.digest === b?.digest;
339
+ }
340
+ /**
341
+ * Commits a derived state: the binding's persist hook, the caller's, then
342
+ * the `_collab_docs` write, in one transaction, at the row `base` names
343
+ * (null: no row). Returns null when another writer changed the row first;
344
+ * nothing is written in that case.
345
+ */
346
+ export async function commitLiveCopy(binding, commit, base, callPersist) {
347
+ assertOwnTransaction(commit.docId, "commitLiveCopy");
348
+ await ensureTable();
349
+ let outcome = { liveCopy: "derived" };
350
+ try {
351
+ await runLiveCopyTransaction(commit.docId, async (tx) => {
352
+ const persist = binding.persist;
353
+ if (persist) {
354
+ // The pool's own transaction does not scope getDbExec(); without
355
+ // this, app helpers inside persist query a second connection,
356
+ // outside the row locks persist holds.
357
+ const value = await withDbExec(tx, () => persist(tx, commit));
358
+ outcome = persistOutcome(commit.docId, value);
359
+ }
360
+ if (callPersist) {
361
+ // The caller's persist checks the app's rows against this edit, so a
362
+ // peer that committed first must stop the attempt here, as a retry,
363
+ // not there, as a refusal. Exact once the binding's persist holds
364
+ // the lock every commit of the document takes.
365
+ const current = await loadYDocStampWithClient(tx, commit.docId);
366
+ if (!sameStamp(current, base))
367
+ throw new CommitVersionConflict();
368
+ await runCallPersist(commit.docId, tx, callPersist);
369
+ }
370
+ const saved = await trySaveYDocStateWithClient(tx, commit.docId, commit.state, commit.text, base?.version ?? null, base?.digest);
371
+ if (!saved)
372
+ throw new CommitVersionConflict();
373
+ // After the last statement: a binding released or replaced while its
374
+ // work, its persist or its save ran rolls back with all of them.
375
+ assertActiveLiveCopy(binding, commit.docId);
376
+ });
377
+ return outcome;
378
+ }
379
+ catch (error) {
380
+ if (!(error instanceof CommitVersionConflict))
381
+ throw error;
382
+ // A released or replaced binding says so, not a retry that cannot help.
383
+ assertActiveLiveCopy(binding, commit.docId);
384
+ return null;
385
+ }
386
+ }
387
+ /**
388
+ * Runs a caller's persist by itself, for a write that committed nothing,
389
+ * while the copy is still the row `base` names. Returns false, with nothing
390
+ * written, when another writer changed it first.
391
+ */
392
+ export async function persistLiveCopyCall(binding, docId, base, callPersist) {
393
+ assertOwnTransaction(docId, "persistLiveCopyCall");
394
+ try {
395
+ await runLiveCopyTransaction(docId, async (tx) => {
396
+ await runCallPersist(docId, tx, callPersist);
397
+ // Locked after the app's rows, as a commit locks them, and held to the
398
+ // end so no commit can slip in before this one lands.
399
+ const current = await loadYDocStampWithClient(tx, docId, {
400
+ forShare: true,
401
+ });
402
+ if (!sameStamp(current, base))
403
+ throw new CommitVersionConflict();
404
+ // After the last statement, as in commitLiveCopy.
405
+ assertActiveLiveCopy(binding, docId);
406
+ });
407
+ return true;
408
+ }
409
+ catch (error) {
410
+ if (!(error instanceof CommitVersionConflict))
411
+ throw error;
412
+ assertActiveLiveCopy(binding, docId);
413
+ return false;
414
+ }
415
+ }
@@ -13,8 +13,13 @@ export declare const getCollabState: import("h3").EventHandlerWithFetch<import("
13
13
  };
14
14
  }>>;
15
15
  export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
16
- ok?: undefined;
17
16
  error: string;
17
+ ok?: undefined;
18
+ } | {
19
+ liveCopy: "derived" | "not_derived" | "unchanged";
20
+ liveCopyReason?: string;
21
+ error?: undefined;
22
+ ok: boolean;
18
23
  } | {
19
24
  error?: undefined;
20
25
  ok: boolean;
@@ -1,4 +1,5 @@
1
1
  import { defineEventHandler, setResponseStatus, setResponseHeader, getRouterParam, getQuery, getRequestHeader, } from "h3";
2
+ import * as Y from "yjs";
2
3
  import { readBody } from "../server/h3-helpers.js";
3
4
  import { uint8ArrayToBase64, base64ToUint8Array } from "./storage.js";
4
5
  import * as manager from "./ydoc-manager.js";
@@ -16,6 +17,22 @@ function enforcePayloadLimit(event, body) {
16
17
  }
17
18
  return true;
18
19
  }
20
+ /**
21
+ * The client's state vector, or null when it is not one. A vector is a flat
22
+ * list of clocks, read in one pass and bounded by the URL, so reading it
23
+ * here is not the document decode live copies keep off this thread.
24
+ */
25
+ function readStateVector(encoded) {
26
+ try {
27
+ const stateVector = base64ToUint8Array(encoded);
28
+ Y.decodeStateVector(stateVector);
29
+ return stateVector;
30
+ // coercion-ok: null is this reader's typed "not a state vector"; the route answers 400
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
19
36
  export const getCollabState = defineEventHandler(async (event) => {
20
37
  setResponseHeader(event, "Cache-Control", "private, no-store");
21
38
  const docId = getRouterParam(event, "docId");
@@ -39,14 +56,13 @@ export const getCollabState = defineEventHandler(async (event) => {
39
56
  }
40
57
  }
41
58
  let state;
42
- if (encodedStateVector) {
43
- try {
44
- state = await manager.getIncUpdate(docId, base64ToUint8Array(encodedStateVector));
45
- }
46
- catch {
59
+ if (encodedStateVector !== null) {
60
+ const stateVector = readStateVector(encodedStateVector);
61
+ if (!stateVector) {
47
62
  setResponseStatus(event, 400);
48
- return { error: "stateVector must be base64-encoded" };
63
+ return { error: "stateVector must be a base64-encoded Yjs state vector" };
49
64
  }
65
+ state = await manager.getIncUpdate(docId, stateVector);
50
66
  }
51
67
  else {
52
68
  state = await manager.getState(docId);
@@ -73,8 +89,8 @@ export const postCollabUpdate = defineEventHandler(async (event) => {
73
89
  return { error: "update (base64) required" };
74
90
  }
75
91
  const binary = base64ToUint8Array(update);
76
- await manager.applyUpdate(docId, binary, requestSource);
77
- return { ok: true };
92
+ const outcome = await manager.applyUpdate(docId, binary, requestSource);
93
+ return outcome ? { ok: true, ...outcome } : { ok: true };
78
94
  });
79
95
  export const postCollabText = defineEventHandler(async (event) => {
80
96
  const docId = getRouterParam(event, "docId");
@@ -7,10 +7,62 @@ export interface YDocStateRecord {
7
7
  }
8
8
  export declare function loadYDocRecord(docId: string): Promise<YDocStateRecord | null>;
9
9
  export declare function loadYDocRecordWithClient(client: DbExec, docId: string): Promise<YDocStateRecord | null>;
10
+ export type CollabStateMalformedReason = "base64" | "version" | "yjs";
11
+ /** A stored `_collab_docs` row that cannot be read. Never treat it as empty. */
12
+ export declare class CollabStateMalformedError extends Error {
13
+ readonly code = "collab_state_malformed";
14
+ readonly docId: string;
15
+ readonly reason: CollabStateMalformedReason;
16
+ constructor(docId: string, reason: CollabStateMalformedReason, message: string, options?: {
17
+ cause?: unknown;
18
+ });
19
+ }
20
+ /**
21
+ * Absent means no row. A row whose `yjs_state` is `''` is present with a
22
+ * zero-byte state; callers that seed must update it at its version rather
23
+ * than insert.
24
+ */
25
+ export type StrictYDocRecord = {
26
+ kind: "absent";
27
+ } | {
28
+ kind: "present";
29
+ state: Uint8Array;
30
+ version: number;
31
+ }
32
+ /** Longer than `maxBytes` allows; its text was never read. */
33
+ | {
34
+ kind: "too_large";
35
+ bytes: number;
36
+ };
37
+ /**
38
+ * Decodes canonical base64, or returns null; `Buffer` silently skips junk.
39
+ * A round trip instead of a pattern: a repeated-group regex overflows the
40
+ * stack on a few megabytes.
41
+ */
42
+ export declare function strictBase64ToUint8Array(value: string): Uint8Array | null;
43
+ export declare function loadYDocRecordStrict(client: DbExec, docId: string, maxBytes: number, options?: {
44
+ forUpdate?: boolean;
45
+ }): Promise<StrictYDocRecord>;
10
46
  export declare function loadYDocVersion(docId: string): Promise<number | null>;
47
+ /**
48
+ * A row's version and a SHA-256 of its stored text. A version alone repeats:
49
+ * a row deleted and inserted again starts back at 0.
50
+ */
51
+ export interface YDocStamp {
52
+ version: number;
53
+ digest: string;
54
+ }
55
+ export declare function loadYDocStamp(docId: string): Promise<YDocStamp | null>;
56
+ export declare function loadYDocStampWithClient(client: DbExec, docId: string, options?: {
57
+ forShare?: boolean;
58
+ }): Promise<YDocStamp | null>;
59
+ /** The digest `loadYDocStamp` reports for a row this module wrote `state` to. */
60
+ export declare function collabStateDigest(state: Uint8Array): Promise<string>;
11
61
  export declare function loadYDocState(docId: string): Promise<Uint8Array | null>;
12
62
  export declare function trySaveYDocState(docId: string, state: Uint8Array, textSnapshot: string, expectedVersion: number | null): Promise<boolean>;
13
- export declare function trySaveYDocStateWithClient(client: DbExec, docId: string, state: Uint8Array, textSnapshot: string, expectedVersion: number | null): Promise<boolean>;
63
+ export declare function trySaveYDocStateWithClient(client: DbExec, docId: string, state: Uint8Array, textSnapshot: string, expectedVersion: number | null,
64
+ /** Also requires the stored text to have this digest; see `YDocStamp`. */
65
+ expectedDigest?: string): Promise<boolean>;
14
66
  export declare function saveYDocState(docId: string, state: Uint8Array, textSnapshot: string): Promise<void>;
15
67
  export declare function hasCollabState(docId: string): Promise<boolean>;
16
68
  export declare function deleteCollabState(docId: string): Promise<void>;
@@ -42,6 +42,62 @@ export async function loadYDocRecordWithClient(client, docId) {
42
42
  version: Number(rows[0].version ?? 0),
43
43
  };
44
44
  }
45
+ /** A stored `_collab_docs` row that cannot be read. Never treat it as empty. */
46
+ export class CollabStateMalformedError extends Error {
47
+ code = "collab_state_malformed";
48
+ docId;
49
+ reason;
50
+ constructor(docId, reason, message, options) {
51
+ super(message, options);
52
+ this.name = "CollabStateMalformedError";
53
+ this.docId = docId;
54
+ this.reason = reason;
55
+ }
56
+ }
57
+ /**
58
+ * Decodes canonical base64, or returns null; `Buffer` silently skips junk.
59
+ * A round trip instead of a pattern: a repeated-group regex overflows the
60
+ * stack on a few megabytes.
61
+ */
62
+ export function strictBase64ToUint8Array(value) {
63
+ let bytes;
64
+ try {
65
+ bytes = base64ToUint8Array(value);
66
+ // coercion-ok: null is this reader's typed "not base64"; callers raise malformed_state
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ return uint8ArrayToBase64(bytes) === value ? bytes : null;
72
+ }
73
+ export async function loadYDocRecordStrict(client, docId, maxBytes, options = {}) {
74
+ // The longest canonical base64 of maxBytes bytes. Longer text is left in
75
+ // the database, so a huge row costs neither the transfer nor the decode.
76
+ const maxLength = 4 * Math.ceil(maxBytes / 3);
77
+ const { rows } = await client.execute({
78
+ sql: `SELECT CASE WHEN length(yjs_state) <= ? THEN yjs_state END AS yjs_state, length(yjs_state) AS encoded_length, version FROM _collab_docs WHERE doc_id = ?${options.forUpdate ? " FOR UPDATE" : ""}`,
79
+ args: [maxLength, docId],
80
+ });
81
+ if (rows.length === 0)
82
+ return { kind: "absent" };
83
+ const encodedLength = Number(rows[0].encoded_length);
84
+ if (encodedLength > maxLength) {
85
+ return { kind: "too_large", bytes: Math.floor((encodedLength * 3) / 4) };
86
+ }
87
+ const stored = rows[0].yjs_state;
88
+ if (typeof stored !== "string") {
89
+ throw new CollabStateMalformedError(docId, "base64", `_collab_docs.yjs_state for ${docId} is not text`);
90
+ }
91
+ const state = strictBase64ToUint8Array(stored);
92
+ if (!state) {
93
+ throw new CollabStateMalformedError(docId, "base64", `_collab_docs.yjs_state for ${docId} is not valid base64`);
94
+ }
95
+ const version = Number(rows[0].version);
96
+ if (!Number.isSafeInteger(version) || version < 0) {
97
+ throw new CollabStateMalformedError(docId, "version", `_collab_docs.version for ${docId} is unreadable: ${String(rows[0].version)}`);
98
+ }
99
+ return { kind: "present", state, version };
100
+ }
45
101
  export async function loadYDocVersion(docId) {
46
102
  await ensureTable();
47
103
  const client = getDbExec();
@@ -57,6 +113,31 @@ export async function loadYDocVersion(docId) {
57
113
  }
58
114
  return version;
59
115
  }
116
+ const STATE_DIGEST_SQL = `encode(sha256(convert_to(yjs_state, 'UTF8')), 'hex')`;
117
+ export async function loadYDocStamp(docId) {
118
+ await ensureTable();
119
+ return loadYDocStampWithClient(getDbExec(), docId);
120
+ }
121
+ export async function loadYDocStampWithClient(client, docId, options = {}) {
122
+ const { rows } = await client.execute({
123
+ sql: `SELECT version, ${STATE_DIGEST_SQL} AS digest FROM _collab_docs WHERE doc_id = ?${options.forShare ? " FOR SHARE" : ""}`,
124
+ args: [docId],
125
+ });
126
+ if (rows.length === 0)
127
+ return null;
128
+ const version = Number(rows[0].version);
129
+ const digest = rows[0].digest;
130
+ if (!Number.isFinite(version) || typeof digest !== "string") {
131
+ throw new Error(`_collab_docs row for ${docId} is unreadable: version ${String(rows[0].version)}`);
132
+ }
133
+ return { version, digest };
134
+ }
135
+ /** The digest `loadYDocStamp` reports for a row this module wrote `state` to. */
136
+ export async function collabStateDigest(state) {
137
+ const text = new TextEncoder().encode(uint8ArrayToBase64(state));
138
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", text);
139
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
140
+ }
60
141
  export async function loadYDocState(docId) {
61
142
  const record = await loadYDocRecord(docId);
62
143
  return record?.state ?? null;
@@ -65,7 +146,9 @@ export async function trySaveYDocState(docId, state, textSnapshot, expectedVersi
65
146
  await ensureTable();
66
147
  return trySaveYDocStateWithClient(getDbExec(), docId, state, textSnapshot, expectedVersion);
67
148
  }
68
- export async function trySaveYDocStateWithClient(client, docId, state, textSnapshot, expectedVersion) {
149
+ export async function trySaveYDocStateWithClient(client, docId, state, textSnapshot, expectedVersion,
150
+ /** Also requires the stored text to have this digest; see `YDocStamp`. */
151
+ expectedDigest) {
69
152
  const b64 = uint8ArrayToBase64(state);
70
153
  const nowExpr = "NOW()::text";
71
154
  if (expectedVersion === null) {
@@ -75,10 +158,15 @@ export async function trySaveYDocStateWithClient(client, docId, state, textSnaps
75
158
  });
76
159
  return result.rowsAffected > 0;
77
160
  }
78
- const result = await client.execute({
79
- sql: `UPDATE _collab_docs SET yjs_state = ?, text_snapshot = ?, version = version + 1, updated_at = ${nowExpr} WHERE doc_id = ? AND version = ?`,
80
- args: [b64, textSnapshot, docId, expectedVersion],
81
- });
161
+ const result = await client.execute(expectedDigest === undefined
162
+ ? {
163
+ sql: `UPDATE _collab_docs SET yjs_state = ?, text_snapshot = ?, version = version + 1, updated_at = ${nowExpr} WHERE doc_id = ? AND version = ?`,
164
+ args: [b64, textSnapshot, docId, expectedVersion],
165
+ }
166
+ : {
167
+ sql: `UPDATE _collab_docs SET yjs_state = ?, text_snapshot = ?, version = version + 1, updated_at = ${nowExpr} WHERE doc_id = ? AND version = ? AND ${STATE_DIGEST_SQL} = ?`,
168
+ args: [b64, textSnapshot, docId, expectedVersion, expectedDigest],
169
+ });
82
170
  return result.rowsAffected > 0;
83
171
  }
84
172
  export async function saveYDocState(docId, state, textSnapshot) {