@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.
- package/dist/cli/template-baseline.js +18 -5
- package/dist/cli/template-sync.js +100 -19
- package/dist/collab/__fixtures__/live-copy-tasks.d.ts +1 -0
- package/dist/collab/__fixtures__/live-copy-tasks.js +92 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/bounded-task-bundle.d.ts +17 -0
- package/dist/collab/bounded-task-bundle.js +39 -0
- package/dist/collab/bounded-task-source.d.ts +14 -0
- package/dist/collab/bounded-task-source.js +8 -0
- package/dist/collab/bounded-tasks.d.ts +67 -0
- package/dist/collab/bounded-tasks.js +439 -0
- package/dist/collab/index.d.ts +5 -2
- package/dist/collab/index.js +5 -2
- package/dist/collab/live-copy-tasks.d.ts +80 -0
- package/dist/collab/live-copy-tasks.js +330 -0
- package/dist/collab/live-copy.d.ts +188 -0
- package/dist/collab/live-copy.js +415 -0
- package/dist/collab/routes.d.ts +6 -1
- package/dist/collab/routes.js +24 -8
- package/dist/collab/storage.d.ts +53 -1
- package/dist/collab/storage.js +93 -5
- package/dist/collab/ydoc-manager.d.ts +92 -3
- package/dist/collab/ydoc-manager.js +447 -38
- package/dist/db/client.d.ts +6 -0
- package/dist/db/client.js +25 -5
- package/dist/deploy/build.js +2 -0
- package/dist/extensions/actions.js +1 -0
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/jobs/actions/manage-recurring-job.d.ts +1 -1
- package/dist/mcp/agent-connected.js +5 -1
- package/dist/mcp/build-server.js +39 -10
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/org/handlers.d.ts +1 -1
- package/dist/org/index.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
- package/dist/provider-api/actions/provider-api.d.ts +11 -11
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-engine-ollama-models-route.d.ts +2 -2
- package/dist/server/collab-plugin.d.ts +10 -1
- package/dist/server/collab-plugin.js +89 -20
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/shared/optional-node-builtins.d.ts +1 -0
- package/dist/shared/optional-node-builtins.js +3 -0
- package/dist/tracking/providers.d.ts +1 -0
- package/dist/tracking/providers.js +2 -1
- package/dist/triggers/actions/manage-automation.d.ts +2 -2
- package/dist/vite/bounded-task-plugin.d.ts +22 -0
- package/dist/vite/bounded-task-plugin.js +44 -0
- package/dist/vite/client.js +2 -0
- package/dist/vitest-config.js +3 -0
- 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
|
+
}
|
package/dist/collab/routes.d.ts
CHANGED
|
@@ -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;
|
package/dist/collab/routes.js
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
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");
|
package/dist/collab/storage.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/collab/storage.js
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
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) {
|