@agent-native/core 0.211.0 → 0.212.0-nightly-20261021010049
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/README.md +1 -1
- package/dist/app-config/analytics.d.ts +1 -0
- package/dist/app-config/analytics.js +7 -0
- package/dist/app-config/schema.d.ts +1 -0
- package/dist/chat-threads/store.d.ts +28 -2
- package/dist/chat-threads/store.js +75 -13
- package/dist/cli/template-baseline.js +18 -5
- package/dist/cli/template-sync.js +100 -19
- package/dist/client/agent-chat/index.d.ts +2 -1
- package/dist/client/agent-chat/index.js +2 -1
- package/dist/client/chat/agentkit-protocol.js +21 -1
- package/dist/client/chat/run-outcome.d.ts +1 -0
- package/dist/client/chat/runtime.js +6 -0
- package/dist/client/sharing/index.d.ts +2 -1
- package/dist/client/sharing/index.js +2 -1
- package/dist/client/sharing/share-controller-helpers.d.ts +14 -1
- package/dist/client/sharing/share-controller-helpers.js +46 -1
- package/dist/client/sharing/useShareButtonController.d.ts +12 -0
- package/dist/client/sharing/useShareButtonController.js +76 -57
- package/dist/client/sharing/useShareDialogController.d.ts +3 -0
- package/dist/client/sharing/useShareDialogController.js +77 -49
- package/dist/client/sharing/useShareVisibilityTransition.d.ts +68 -0
- package/dist/client/sharing/useShareVisibilityTransition.js +224 -0
- package/dist/client/use-chat-thread-list.d.ts +40 -0
- package/dist/client/use-chat-thread-list.js +210 -0
- package/dist/client/use-chat-threads.d.ts +67 -0
- package/dist/client/use-chat-threads.js +141 -23
- package/dist/client/use-db-sync.d.ts +1 -0
- package/dist/client/use-db-sync.js +13 -1
- 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/client.js +2 -1
- 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 +5 -0
- 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/struct-routes.d.ts +1 -1
- 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/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/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/org/index.d.ts +2 -2
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +16 -16
- package/dist/provider-api/actions/provider-api.d.ts +10 -10
- package/dist/server/agent-chat-plugin.js +80 -17
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-engine-default-model-route.d.ts +1 -1
- package/dist/server/analytics.d.ts +1 -0
- package/dist/server/analytics.js +18 -1
- package/dist/server/collab-plugin.d.ts +10 -1
- package/dist/server/collab-plugin.js +89 -20
- package/dist/server/index.d.ts +1 -1
- 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/sharing/access.d.ts +1 -0
- package/dist/sharing/access.js +113 -95
- 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/dist/workspace-connections/actions/list-workspace-user-groups.d.ts +1 -1
- package/docs/content/deployment-environment-variables.mdx +1 -0
- package/docs/content/environment-variables.mdx +1 -0
- package/docs/content/sharing.mdx +36 -0
- package/docs/content/toolkit-sharing.mdx +98 -12
- package/package.json +5 -3
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as Y from "yjs";
|
|
2
|
+
import { getDbExec } from "../db/client.js";
|
|
2
3
|
import { emitCollabUpdate } from "./emitter.js";
|
|
3
4
|
import { applyJsonDiff, applyJsonPatch, yDocToJson, initYDocWithJson, } from "./json-to-yjs.js";
|
|
4
|
-
import {
|
|
5
|
+
import { applyLiveCopyUpdate, assertLiveCopy, assertOwnTransaction, commitLiveCopy, editLiveCopyState, liveCopiesDefined, liveCopyBindingFor, LiveCopyError, persistLiveCopyCall, readLiveCopyState, readStoredLiveCopy, liveCopyWriteRefused, refuseLiveCopyWrite, sameStamp, seedLiveCopyState, } from "./live-copy.js";
|
|
6
|
+
import { collabStateDigest, ensureTable, loadYDocRecord, loadYDocRecordWithClient, loadYDocStamp, loadYDocVersion, saveYDocState as saveStoredYDocState, trySaveYDocState, trySaveYDocStateWithClient, } from "./storage.js";
|
|
5
7
|
import { uint8ArrayToBase64 } from "./storage.js";
|
|
6
8
|
import { applyTextToYDoc, initYDocWithText } from "./text-to-yjs.js";
|
|
7
9
|
import { searchAndReplaceInYXml, extractTextFromYXml } from "./xml-ops.js";
|
|
@@ -46,6 +48,8 @@ export function computeTextEditDescriptor(oldText, newText) {
|
|
|
46
48
|
const COMPACTION_RATIO = 4;
|
|
47
49
|
const _cache = new Map();
|
|
48
50
|
const _writeLocks = new Map();
|
|
51
|
+
/** Writes holding or waiting for each document's lock. */
|
|
52
|
+
const _writesPending = new Map();
|
|
49
53
|
const _refreshLocks = new Map();
|
|
50
54
|
const _loadLocks = new Map();
|
|
51
55
|
function evictIfNeeded() {
|
|
@@ -65,7 +69,16 @@ function evictIfNeeded() {
|
|
|
65
69
|
_cache.delete(oldest);
|
|
66
70
|
}
|
|
67
71
|
}
|
|
68
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Runs `fn` alone among this instance's writes to `docId`. A live copy's
|
|
74
|
+
* writes queue here, ahead of its runner, so the runner's own queue never
|
|
75
|
+
* sees them; the binding bounds them instead.
|
|
76
|
+
*/
|
|
77
|
+
async function withDocWriteLock(docId, fn, refusePath) {
|
|
78
|
+
// A write takes its place in line before its first await, so writes keep
|
|
79
|
+
// their call order and a slow `appliesTo` cannot let a burst past the bound.
|
|
80
|
+
const ahead = _writesPending.get(docId) ?? 0;
|
|
81
|
+
_writesPending.set(docId, ahead + 1);
|
|
69
82
|
const previous = _writeLocks.get(docId) ?? Promise.resolve();
|
|
70
83
|
let release;
|
|
71
84
|
const current = new Promise((resolve) => {
|
|
@@ -73,15 +86,43 @@ async function withDocWriteLock(docId, fn) {
|
|
|
73
86
|
});
|
|
74
87
|
const chained = previous.catch(() => { }).then(() => current);
|
|
75
88
|
_writeLocks.set(docId, chained);
|
|
76
|
-
|
|
89
|
+
// Not in `finally`: a refused write releases while earlier writes still run.
|
|
90
|
+
void chained.then(() => {
|
|
91
|
+
if (_writeLocks.get(docId) === chained)
|
|
92
|
+
_writeLocks.delete(docId);
|
|
93
|
+
});
|
|
77
94
|
try {
|
|
95
|
+
if (liveCopiesDefined()) {
|
|
96
|
+
const binding = await liveCopyBindingFor(docId);
|
|
97
|
+
// Refused outright, not as a full queue a retry could get past.
|
|
98
|
+
if (binding && refusePath !== undefined) {
|
|
99
|
+
throw liveCopyWriteRefused(docId, refusePath);
|
|
100
|
+
}
|
|
101
|
+
// Before waiting: from a binding's persist, which runs in its commit's
|
|
102
|
+
// transaction, this write would wait on the lock that commit holds.
|
|
103
|
+
if (binding)
|
|
104
|
+
assertOwnTransaction(docId, "This write");
|
|
105
|
+
if (binding && ahead > binding.maxWaitingWrites) {
|
|
106
|
+
throw new LiveCopyError("build_failed", docId, `Live copy ${docId} already has ${binding.maxWaitingWrites} writes waiting; try again shortly.`, { reason: "queue_full" });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
await previous.catch(() => { });
|
|
110
|
+
// Checked under the lock so a binding defined while this write waited
|
|
111
|
+
// still refuses it.
|
|
112
|
+
if (refusePath !== undefined) {
|
|
113
|
+
const refusal = refuseLiveCopyWrite(docId, refusePath);
|
|
114
|
+
if (refusal)
|
|
115
|
+
await refusal;
|
|
116
|
+
}
|
|
78
117
|
return await fn();
|
|
79
118
|
}
|
|
80
119
|
finally {
|
|
81
120
|
release();
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
121
|
+
const pending = (_writesPending.get(docId) ?? 1) - 1;
|
|
122
|
+
if (pending > 0)
|
|
123
|
+
_writesPending.set(docId, pending);
|
|
124
|
+
else
|
|
125
|
+
_writesPending.delete(docId);
|
|
85
126
|
}
|
|
86
127
|
}
|
|
87
128
|
export async function withPreparedYDocMutation(docId, requestSource, run) {
|
|
@@ -133,7 +174,7 @@ export async function withPreparedYDocMutation(docId, requestSource, run) {
|
|
|
133
174
|
candidate.destroy();
|
|
134
175
|
throw error;
|
|
135
176
|
}
|
|
136
|
-
});
|
|
177
|
+
}, "withPreparedYDocMutation");
|
|
137
178
|
}
|
|
138
179
|
function buildStateToStore(doc, storedByteCount) {
|
|
139
180
|
const encoded = Y.encodeStateAsUpdate(doc);
|
|
@@ -244,7 +285,7 @@ async function loadDoc(docId) {
|
|
|
244
285
|
_loadLocks.delete(docId);
|
|
245
286
|
}
|
|
246
287
|
}
|
|
247
|
-
|
|
288
|
+
async function getDocForWrite(docId) {
|
|
248
289
|
const cached = _cache.get(docId);
|
|
249
290
|
if (cached) {
|
|
250
291
|
cached.lastAccess = Date.now();
|
|
@@ -257,42 +298,360 @@ async function awaitPendingWrite(docId) {
|
|
|
257
298
|
if (pending)
|
|
258
299
|
await pending.catch(() => { });
|
|
259
300
|
}
|
|
260
|
-
|
|
301
|
+
async function readTarget(docId) {
|
|
302
|
+
// A live copy is read from SQL, never from a write in flight here, so it
|
|
303
|
+
// skips the write lock: a binding's persist runs under that lock and may
|
|
304
|
+
// read its own document.
|
|
305
|
+
if (liveCopiesDefined()) {
|
|
306
|
+
const binding = await liveCopyBindingFor(docId);
|
|
307
|
+
if (binding)
|
|
308
|
+
return { kind: "live", binding };
|
|
309
|
+
}
|
|
261
310
|
await awaitPendingWrite(docId);
|
|
311
|
+
// Looked up again: the write waited for may have made it a live copy.
|
|
312
|
+
const binding = await liveCopyBindingFor(docId);
|
|
313
|
+
if (binding)
|
|
314
|
+
return { kind: "live", binding };
|
|
262
315
|
const cached = _cache.get(docId);
|
|
263
316
|
if (cached) {
|
|
264
317
|
cached.lastAccess = Date.now();
|
|
265
318
|
await mergeNewerStoredState(docId, cached);
|
|
266
|
-
return cached.doc;
|
|
319
|
+
return { kind: "doc", doc: cached.doc };
|
|
267
320
|
}
|
|
268
|
-
return loadDoc(docId);
|
|
321
|
+
return { kind: "doc", doc: await loadDoc(docId) };
|
|
322
|
+
}
|
|
323
|
+
/** The cached document, for callers that only read it. */
|
|
324
|
+
async function readDoc(docId, path) {
|
|
325
|
+
const target = await readTarget(docId);
|
|
326
|
+
if (target.kind === "doc")
|
|
327
|
+
return target.doc;
|
|
328
|
+
throw new LiveCopyError("read_refused", docId, `Document ${docId} is a live copy, which is never decoded on the request thread, so ${path} cannot read it; use readLiveCopy or getState.`, { reason: path });
|
|
269
329
|
}
|
|
330
|
+
/** Refuses live copies with `read_refused`; read those with `readLiveCopy`. */
|
|
331
|
+
export async function getDoc(docId) {
|
|
332
|
+
return readDoc(docId, "getDoc");
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Commits a client update. Once any live copy is defined, the result says
|
|
336
|
+
* whether the app's rows were derived from it; with none defined it is
|
|
337
|
+
* undefined, as before live copies existed.
|
|
338
|
+
*/
|
|
270
339
|
export async function applyUpdate(docId, update, requestSource) {
|
|
271
340
|
return withDocWriteLock(docId, async () => {
|
|
341
|
+
const liveCopy = await liveCopyBindingFor(docId);
|
|
342
|
+
if (liveCopy) {
|
|
343
|
+
return commitLiveCopyUpdate(liveCopy, docId, update, requestSource);
|
|
344
|
+
}
|
|
272
345
|
const doc = await getDocForWrite(docId);
|
|
273
346
|
Y.applyUpdate(doc, update);
|
|
274
347
|
await persistMergedState(docId, doc, () => doc.getText(DEFAULT_FIELD).toString());
|
|
275
348
|
emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);
|
|
349
|
+
return liveCopiesDefined()
|
|
350
|
+
? { liveCopy: "not_derived", liveCopyReason: "not_live_copy" }
|
|
351
|
+
: undefined;
|
|
276
352
|
});
|
|
277
353
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
354
|
+
const LIVE_COPY_COMMIT_ATTEMPTS = 5;
|
|
355
|
+
// An empty Yjs update: no structs, no deletions.
|
|
356
|
+
const EMPTY_STATE = new Uint8Array([0, 0]);
|
|
357
|
+
const EMPTY_CATCH_UP = {
|
|
358
|
+
stateVector: new Uint8Array([0]),
|
|
359
|
+
deletions: EMPTY_STATE,
|
|
360
|
+
};
|
|
361
|
+
// Live copies are cached as validated bytes, never as a Y.Doc.
|
|
362
|
+
const _liveCopyCache = new Map();
|
|
363
|
+
/** Caches the bytes `stored` holds; null caches an absent copy. */
|
|
364
|
+
async function cacheLiveCopyState(binding, docId, stored, catchUp, text) {
|
|
365
|
+
const entry = {
|
|
366
|
+
binding,
|
|
367
|
+
state: stored && stored.state.length > 0 ? stored.state : EMPTY_STATE,
|
|
368
|
+
catchUp,
|
|
369
|
+
stamp: stored && {
|
|
370
|
+
version: stored.version,
|
|
371
|
+
digest: await collabStateDigest(stored.state),
|
|
372
|
+
},
|
|
373
|
+
text,
|
|
374
|
+
lastAccess: Date.now(),
|
|
375
|
+
};
|
|
376
|
+
const stale = _cache.get(docId);
|
|
377
|
+
if (stale) {
|
|
378
|
+
stale.doc.destroy();
|
|
379
|
+
_cache.delete(docId);
|
|
286
380
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
381
|
+
_liveCopyCache.set(docId, entry);
|
|
382
|
+
evictLiveCopyCache();
|
|
383
|
+
return entry;
|
|
384
|
+
}
|
|
385
|
+
function evictLiveCopyCache() {
|
|
386
|
+
if (_liveCopyCache.size <= MAX_CACHE)
|
|
387
|
+
return;
|
|
388
|
+
let oldest = null;
|
|
389
|
+
let oldestTime = Infinity;
|
|
390
|
+
for (const [id, entry] of _liveCopyCache) {
|
|
391
|
+
if (entry.lastAccess < oldestTime) {
|
|
392
|
+
oldestTime = entry.lastAccess;
|
|
393
|
+
oldest = id;
|
|
291
394
|
}
|
|
292
395
|
}
|
|
293
|
-
|
|
396
|
+
if (oldest)
|
|
397
|
+
_liveCopyCache.delete(oldest);
|
|
294
398
|
}
|
|
295
|
-
|
|
399
|
+
function servedState(state) {
|
|
400
|
+
return state.length > 0 ? state.slice() : EMPTY_STATE.slice();
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* The cached entry while the row still holds the bytes it was cached from.
|
|
404
|
+
* The digest matters as much as the version: a row deleted and inserted
|
|
405
|
+
* again, by any instance, starts back at version 0.
|
|
406
|
+
*/
|
|
407
|
+
function cachedLiveCopy(binding, docId, stamp) {
|
|
408
|
+
const cached = _liveCopyCache.get(docId);
|
|
409
|
+
if (cached?.binding !== binding || !sameStamp(cached.stamp, stamp)) {
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
cached.lastAccess = Date.now();
|
|
413
|
+
return cached;
|
|
414
|
+
}
|
|
415
|
+
/** Cache misses being filled, shared by readers that saw the same row. */
|
|
416
|
+
const _liveCopyLoads = new Map();
|
|
417
|
+
/**
|
|
418
|
+
* The stored copy to change. When this instance cached the stored row, its
|
|
419
|
+
* bytes are used instead of re-reading it; the tasks get a copy, so a
|
|
420
|
+
* refused or conflicting attempt never touches the cache.
|
|
421
|
+
*/
|
|
422
|
+
async function loadLiveCopyState(binding, docId) {
|
|
423
|
+
const cached = cachedLiveCopy(binding, docId, await loadYDocStamp(docId));
|
|
424
|
+
if (cached) {
|
|
425
|
+
return cached.stamp === null
|
|
426
|
+
? { kind: "absent" }
|
|
427
|
+
: { kind: "stored", state: cached.state, ...cached.stamp };
|
|
428
|
+
}
|
|
429
|
+
return readStoredLiveCopy(binding, getDbExec(), docId);
|
|
430
|
+
}
|
|
431
|
+
function stampOf(stored) {
|
|
432
|
+
return stored.kind === "absent"
|
|
433
|
+
? null
|
|
434
|
+
: { version: stored.version, digest: stored.digest };
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The bytes a live copy serves to editors. A version this instance has not
|
|
438
|
+
* seen is checked in the runner first, so malformed state fails here instead
|
|
439
|
+
* of reaching an editor as an empty or broken document.
|
|
440
|
+
*/
|
|
441
|
+
async function liveCopyStateForRead(binding, docId) {
|
|
442
|
+
await ensureTable();
|
|
443
|
+
const stamp = await loadYDocStamp(docId);
|
|
444
|
+
const cached = cachedLiveCopy(binding, docId, stamp);
|
|
445
|
+
if (cached)
|
|
446
|
+
return cached;
|
|
447
|
+
// One runner task per row, however many editors open it at once; the
|
|
448
|
+
// load reads the row again, so a joiner never gets older bytes than it saw.
|
|
449
|
+
const pending = _liveCopyLoads.get(docId);
|
|
450
|
+
if (pending?.binding === binding && sameStamp(pending.stamp, stamp)) {
|
|
451
|
+
return pending.load;
|
|
452
|
+
}
|
|
453
|
+
const load = loadLiveCopyForRead(binding, docId).finally(() => {
|
|
454
|
+
if (_liveCopyLoads.get(docId)?.load === load)
|
|
455
|
+
_liveCopyLoads.delete(docId);
|
|
456
|
+
});
|
|
457
|
+
_liveCopyLoads.set(docId, { binding, stamp, load });
|
|
458
|
+
return load;
|
|
459
|
+
}
|
|
460
|
+
async function loadLiveCopyForRead(binding, docId) {
|
|
461
|
+
const stored = await readStoredLiveCopy(binding, getDbExec(), docId);
|
|
462
|
+
if (stored.kind === "absent") {
|
|
463
|
+
return cacheLiveCopyState(binding, docId, null, EMPTY_CATCH_UP, null);
|
|
464
|
+
}
|
|
465
|
+
const read = await readLiveCopyState(binding, docId, stored.state, true);
|
|
466
|
+
return cacheLiveCopyState(binding, docId, stored, read.catchUp, read.kind === "live" ? read.text : null);
|
|
467
|
+
}
|
|
468
|
+
async function commitLiveCopyUpdate(binding, docId, update, requestSource) {
|
|
469
|
+
assertOwnTransaction(docId, "applyUpdate");
|
|
470
|
+
await ensureTable();
|
|
471
|
+
for (let attempt = 0; attempt < LIVE_COPY_COMMIT_ATTEMPTS; attempt += 1) {
|
|
472
|
+
const stored = await loadLiveCopyState(binding, docId);
|
|
473
|
+
const result = await applyLiveCopyUpdate(binding, docId, stored.kind === "absent" ? null : stored.state, update);
|
|
474
|
+
if (result.kind === "unchanged")
|
|
475
|
+
return { liveCopy: "unchanged" };
|
|
476
|
+
const previousVersion = stored.kind === "absent" ? null : stored.version;
|
|
477
|
+
const version = previousVersion === null ? 0 : previousVersion + 1;
|
|
478
|
+
const outcome = await commitLiveCopy(binding, {
|
|
479
|
+
docId,
|
|
480
|
+
origin: "update",
|
|
481
|
+
previousVersion,
|
|
482
|
+
version,
|
|
483
|
+
text: result.text,
|
|
484
|
+
state: result.state,
|
|
485
|
+
...(requestSource ? { requestSource } : {}),
|
|
486
|
+
}, stampOf(stored));
|
|
487
|
+
if (!outcome)
|
|
488
|
+
continue;
|
|
489
|
+
await cacheLiveCopyState(binding, docId, { state: result.state, version }, result.catchUp, result.text);
|
|
490
|
+
emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);
|
|
491
|
+
return outcome;
|
|
492
|
+
}
|
|
493
|
+
throw new LiveCopyError("version_conflict", docId, `Live copy ${docId} kept changing while the update was being committed; it was not saved.`, { reason: String(LIVE_COPY_COMMIT_ATTEMPTS) });
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* The stored live copy and derive(state). Absent (no row), unseeded (a row
|
|
497
|
+
* with nothing in the field, including a zero-byte state) and malformed
|
|
498
|
+
* (throws `malformed_state`) are never conflated.
|
|
499
|
+
*/
|
|
500
|
+
export async function readLiveCopy(binding, docId) {
|
|
501
|
+
await assertLiveCopy(binding, docId);
|
|
502
|
+
const { stamp, state, text } = await liveCopyStateForRead(binding, docId);
|
|
503
|
+
if (stamp === null)
|
|
504
|
+
return { kind: "absent" };
|
|
505
|
+
if (text === null) {
|
|
506
|
+
return {
|
|
507
|
+
kind: "unseeded",
|
|
508
|
+
version: stamp.version,
|
|
509
|
+
state: servedState(state),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
kind: "live",
|
|
514
|
+
version: stamp.version,
|
|
515
|
+
state: servedState(state),
|
|
516
|
+
text,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Applies a named edit to a seeded live copy and commits it with its derived
|
|
521
|
+
* text, retrying on `_collab_docs.version` conflicts. Throws `not_seeded`
|
|
522
|
+
* for an absent or unseeded copy; seed it with `seedLiveCopy` first.
|
|
523
|
+
*/
|
|
524
|
+
export async function mutateLiveCopy(binding, docId, options) {
|
|
525
|
+
const maxAttempts = options.maxAttempts ?? LIVE_COPY_COMMIT_ATTEMPTS;
|
|
526
|
+
if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1) {
|
|
527
|
+
throw new RangeError("mutateLiveCopy maxAttempts must be a positive integer");
|
|
528
|
+
}
|
|
529
|
+
if (typeof options.edit !== "string") {
|
|
530
|
+
throw new TypeError("mutateLiveCopy edit must name an edit in the runner's module");
|
|
531
|
+
}
|
|
532
|
+
const callPersist = options.persist;
|
|
533
|
+
if (callPersist !== undefined && typeof callPersist !== "function") {
|
|
534
|
+
throw new TypeError("mutateLiveCopy persist must be a function");
|
|
535
|
+
}
|
|
536
|
+
// Refused before joining the line: a caller's transaction waiting in it
|
|
537
|
+
// could hold rows the write ahead needs to commit.
|
|
538
|
+
assertOwnTransaction(docId, "mutateLiveCopy");
|
|
539
|
+
return withDocWriteLock(docId, async () => {
|
|
540
|
+
// In line, so a slow appliesTo cannot reorder writes or unbound them.
|
|
541
|
+
await assertLiveCopy(binding, docId);
|
|
542
|
+
await ensureTable();
|
|
543
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
544
|
+
const stored = await loadLiveCopyState(binding, docId);
|
|
545
|
+
if (stored.kind === "absent") {
|
|
546
|
+
throw new LiveCopyError("not_seeded", docId, `Live copy ${docId} is absent; seed it before editing.`, { reason: "absent" });
|
|
547
|
+
}
|
|
548
|
+
const result = await editLiveCopyState(binding, docId, stored.state, options.edit, options.input);
|
|
549
|
+
if (result.kind === "unchanged") {
|
|
550
|
+
if (callPersist &&
|
|
551
|
+
!(await persistLiveCopyCall(binding, docId, { version: stored.version, digest: stored.digest }, (tx) => callPersist(tx, {
|
|
552
|
+
changed: false,
|
|
553
|
+
version: stored.version,
|
|
554
|
+
text: result.text,
|
|
555
|
+
})))) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
changed: false,
|
|
560
|
+
liveCopy: "unchanged",
|
|
561
|
+
version: stored.version,
|
|
562
|
+
state: servedState(stored.state),
|
|
563
|
+
text: result.text,
|
|
564
|
+
update: new Uint8Array(0),
|
|
565
|
+
attempts: attempt,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
const version = stored.version + 1;
|
|
569
|
+
const outcome = await commitLiveCopy(binding, {
|
|
570
|
+
docId,
|
|
571
|
+
origin: "mutation",
|
|
572
|
+
previousVersion: stored.version,
|
|
573
|
+
version,
|
|
574
|
+
text: result.text,
|
|
575
|
+
state: result.state,
|
|
576
|
+
...(options.requestSource
|
|
577
|
+
? { requestSource: options.requestSource }
|
|
578
|
+
: {}),
|
|
579
|
+
}, stampOf(stored), callPersist &&
|
|
580
|
+
((tx) => callPersist(tx, { changed: true, version, text: result.text })));
|
|
581
|
+
if (!outcome)
|
|
582
|
+
continue;
|
|
583
|
+
await cacheLiveCopyState(binding, docId, { state: result.state, version }, result.catchUp, result.text);
|
|
584
|
+
emitCollabUpdate(docId, uint8ArrayToBase64(result.update), options.requestSource);
|
|
585
|
+
touchAgentPresence(docId, options.requestSource, {
|
|
586
|
+
descriptor: { kind: "doc" },
|
|
587
|
+
});
|
|
588
|
+
return {
|
|
589
|
+
...outcome,
|
|
590
|
+
changed: true,
|
|
591
|
+
version,
|
|
592
|
+
state: result.state.slice(),
|
|
593
|
+
text: result.text,
|
|
594
|
+
update: result.update,
|
|
595
|
+
attempts: attempt,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
throw new LiveCopyError("version_conflict", docId, `Live copy ${docId} kept changing during ${maxAttempts} edit attempts; the edit was not saved.`, { reason: String(maxAttempts) });
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Seeds an absent or unseeded live copy from `source`. A live copy that
|
|
603
|
+
* already has content comes back unchanged with `seeded: false`; malformed
|
|
604
|
+
* stored state throws `malformed_state` and is never overwritten.
|
|
605
|
+
*/
|
|
606
|
+
export async function seedLiveCopy(binding, docId, options) {
|
|
607
|
+
assertOwnTransaction(docId, "seedLiveCopy");
|
|
608
|
+
return withDocWriteLock(docId, async () => {
|
|
609
|
+
await assertLiveCopy(binding, docId);
|
|
610
|
+
await ensureTable();
|
|
611
|
+
for (let attempt = 0; attempt < LIVE_COPY_COMMIT_ATTEMPTS; attempt += 1) {
|
|
612
|
+
const stored = await loadLiveCopyState(binding, docId);
|
|
613
|
+
const result = await seedLiveCopyState(binding, docId, stored.kind === "absent" ? null : stored.state, options.source);
|
|
614
|
+
if (result.kind === "live") {
|
|
615
|
+
if (stored.kind === "absent") {
|
|
616
|
+
throw new LiveCopyError("build_failed", docId, `The seed task for ${docId} called an absent copy live.`, { reason: "invalid_output" });
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
seeded: false,
|
|
620
|
+
liveCopy: "unchanged",
|
|
621
|
+
version: stored.version,
|
|
622
|
+
state: servedState(stored.state),
|
|
623
|
+
text: result.text,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
const previousVersion = stored.kind === "absent" ? null : stored.version;
|
|
627
|
+
const version = previousVersion === null ? 0 : previousVersion + 1;
|
|
628
|
+
const outcome = await commitLiveCopy(binding, {
|
|
629
|
+
docId,
|
|
630
|
+
origin: "seed",
|
|
631
|
+
previousVersion,
|
|
632
|
+
version,
|
|
633
|
+
text: result.text,
|
|
634
|
+
state: result.state,
|
|
635
|
+
...(options.requestSource
|
|
636
|
+
? { requestSource: options.requestSource }
|
|
637
|
+
: {}),
|
|
638
|
+
}, stampOf(stored));
|
|
639
|
+
if (!outcome)
|
|
640
|
+
continue;
|
|
641
|
+
await cacheLiveCopyState(binding, docId, { state: result.state, version }, result.catchUp, result.text);
|
|
642
|
+
emitCollabUpdate(docId, uint8ArrayToBase64(result.update), options.requestSource);
|
|
643
|
+
return {
|
|
644
|
+
...outcome,
|
|
645
|
+
seeded: true,
|
|
646
|
+
version,
|
|
647
|
+
state: result.state.slice(),
|
|
648
|
+
text: result.text,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
throw new LiveCopyError("version_conflict", docId, `Live copy ${docId} kept changing while its seed was being committed.`, { reason: String(LIVE_COPY_COMMIT_ATTEMPTS) });
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
function assertXmlFragmentSeed(seedUpdate) {
|
|
296
655
|
const candidate = new Y.Doc();
|
|
297
656
|
try {
|
|
298
657
|
const decoded = Y.decodeUpdate(seedUpdate);
|
|
@@ -310,7 +669,33 @@ export async function seedXmlFragmentIfEmpty(docId, seedUpdate, requestSource) {
|
|
|
310
669
|
finally {
|
|
311
670
|
candidate.destroy();
|
|
312
671
|
}
|
|
672
|
+
}
|
|
673
|
+
function isEmptyXmlSeedTarget(fragment) {
|
|
674
|
+
if (fragment.length === 0)
|
|
675
|
+
return true;
|
|
676
|
+
if (fragment.length !== 1)
|
|
677
|
+
return false;
|
|
678
|
+
const first = fragment.get(0);
|
|
679
|
+
if (!(first instanceof Y.XmlElement) || first.nodeName !== "paragraph") {
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
for (let index = 0; index < first.length; index += 1) {
|
|
683
|
+
const child = first.get(index);
|
|
684
|
+
if (!(child instanceof Y.XmlText) || child.toString().length > 0) {
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
export async function seedXmlFragmentIfEmpty(docId, seedUpdate, requestSource) {
|
|
691
|
+
// Once a live copy exists, decode only after the lock's refusal check, so
|
|
692
|
+
// a covered document never integrates a seed on this thread.
|
|
693
|
+
const validateInLock = liveCopiesDefined();
|
|
694
|
+
if (!validateInLock)
|
|
695
|
+
assertXmlFragmentSeed(seedUpdate);
|
|
313
696
|
return withDocWriteLock(docId, async () => {
|
|
697
|
+
if (validateInLock)
|
|
698
|
+
assertXmlFragmentSeed(seedUpdate);
|
|
314
699
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
315
700
|
const stored = await loadYDocRecord(docId);
|
|
316
701
|
const committed = new Y.Doc();
|
|
@@ -344,7 +729,7 @@ export async function seedXmlFragmentIfEmpty(docId, seedUpdate, requestSource) {
|
|
|
344
729
|
}
|
|
345
730
|
}
|
|
346
731
|
throw new CollabBaseVersionConflictError(`Document ${docId} kept changing while its initial XmlFragment seed was being committed.`);
|
|
347
|
-
});
|
|
732
|
+
}, "seedXmlFragmentIfEmpty");
|
|
348
733
|
}
|
|
349
734
|
export async function applyText(docId, newText, fieldName = DEFAULT_FIELD, requestSource, options = {}) {
|
|
350
735
|
return withDocWriteLock(docId, async () => {
|
|
@@ -381,7 +766,7 @@ export async function applyText(docId, newText, fieldName = DEFAULT_FIELD, reque
|
|
|
381
766
|
descriptor: computeTextEditDescriptor(oldText, newText),
|
|
382
767
|
});
|
|
383
768
|
return doc.getText(fieldName).toString();
|
|
384
|
-
});
|
|
769
|
+
}, "applyText");
|
|
385
770
|
}
|
|
386
771
|
export async function searchAndReplace(docId, find, replace, requestSource) {
|
|
387
772
|
return withDocWriteLock(docId, async () => {
|
|
@@ -409,19 +794,33 @@ export async function searchAndReplace(docId, find, replace, requestSource) {
|
|
|
409
794
|
},
|
|
410
795
|
});
|
|
411
796
|
return { found: true, update };
|
|
412
|
-
});
|
|
797
|
+
}, "searchAndReplace");
|
|
413
798
|
}
|
|
414
799
|
export async function getText(docId, fieldName = DEFAULT_FIELD) {
|
|
415
|
-
const doc = await
|
|
800
|
+
const doc = await readDoc(docId, "getText");
|
|
416
801
|
return doc.getText(fieldName).toString();
|
|
417
802
|
}
|
|
418
803
|
export async function getState(docId) {
|
|
419
|
-
const
|
|
420
|
-
|
|
804
|
+
const target = await readTarget(docId);
|
|
805
|
+
if (target.kind === "doc")
|
|
806
|
+
return Y.encodeStateAsUpdate(target.doc);
|
|
807
|
+
return (await liveCopyStateForRead(target.binding, docId)).state.slice();
|
|
421
808
|
}
|
|
422
809
|
export async function getIncUpdate(docId, clientStateVector) {
|
|
423
|
-
const
|
|
424
|
-
|
|
810
|
+
const target = await readTarget(docId);
|
|
811
|
+
if (target.kind === "doc") {
|
|
812
|
+
return Y.encodeStateAsUpdate(target.doc, clientStateVector);
|
|
813
|
+
}
|
|
814
|
+
const { state, catchUp } = await liveCopyStateForRead(target.binding, docId);
|
|
815
|
+
// Diffing against the client's vector would decode the copy on this
|
|
816
|
+
// thread. A client at the same vector lacks at most deletions; any other
|
|
817
|
+
// gets the whole state, which Yjs applies idempotently.
|
|
818
|
+
return sameBytes(clientStateVector, catchUp.stateVector)
|
|
819
|
+
? catchUp.deletions.slice()
|
|
820
|
+
: state.slice();
|
|
821
|
+
}
|
|
822
|
+
function sameBytes(a, b) {
|
|
823
|
+
return a.length === b.length && a.every((byte, index) => byte === b[index]);
|
|
425
824
|
}
|
|
426
825
|
export async function seedFromText(docId, text, fieldName = DEFAULT_FIELD, client) {
|
|
427
826
|
return withDocWriteLock(docId, async () => {
|
|
@@ -458,7 +857,7 @@ export async function seedFromText(docId, text, fieldName = DEFAULT_FIELD, clien
|
|
|
458
857
|
lastAccess: Date.now(),
|
|
459
858
|
syncedVersion: existing ? existing.version + 1 : 0,
|
|
460
859
|
});
|
|
461
|
-
});
|
|
860
|
+
}, "seedFromText");
|
|
462
861
|
}
|
|
463
862
|
export async function applyJson(docId, newJson, fieldName = "data", _type = "map", requestSource) {
|
|
464
863
|
return withDocWriteLock(docId, async () => {
|
|
@@ -469,7 +868,7 @@ export async function applyJson(docId, newJson, fieldName = "data", _type = "map
|
|
|
469
868
|
await persistMergedState(docId, doc, () => JSON.stringify(yDocToJson(doc, fieldName)));
|
|
470
869
|
emitCollabUpdate(docId, uint8ArrayToBase64(update), requestSource);
|
|
471
870
|
touchAgentPresence(docId, requestSource, { descriptor: { kind: "doc" } });
|
|
472
|
-
});
|
|
871
|
+
}, "applyJson");
|
|
473
872
|
}
|
|
474
873
|
export async function applyPatchOps(docId, ops, fieldName = "data", requestSource) {
|
|
475
874
|
return withDocWriteLock(docId, async () => {
|
|
@@ -485,10 +884,10 @@ export async function applyPatchOps(docId, ops, fieldName = "data", requestSourc
|
|
|
485
884
|
paths: ops.slice(0, 5).map((op) => op.path),
|
|
486
885
|
},
|
|
487
886
|
});
|
|
488
|
-
});
|
|
887
|
+
}, "applyPatchOps");
|
|
489
888
|
}
|
|
490
889
|
export async function getJson(docId, fieldName = "data") {
|
|
491
|
-
const doc = await
|
|
890
|
+
const doc = await readDoc(docId, "getJson");
|
|
492
891
|
return yDocToJson(doc, fieldName);
|
|
493
892
|
}
|
|
494
893
|
export async function seedFromJson(docId, json, fieldName = "data", type = "map", client) {
|
|
@@ -526,9 +925,19 @@ export async function seedFromJson(docId, json, fieldName = "data", type = "map"
|
|
|
526
925
|
lastAccess: Date.now(),
|
|
527
926
|
syncedVersion: existing ? existing.version + 1 : 0,
|
|
528
927
|
});
|
|
529
|
-
});
|
|
928
|
+
}, "seedFromJson");
|
|
929
|
+
}
|
|
930
|
+
/** Overwrites a document's stored state and text. Refuses live copies. */
|
|
931
|
+
export function saveYDocState(docId, state, textSnapshot) {
|
|
932
|
+
if (!liveCopiesDefined()) {
|
|
933
|
+
return saveStoredYDocState(docId, state, textSnapshot);
|
|
934
|
+
}
|
|
935
|
+
// Refused under the lock: checked outside it, a seed that makes the
|
|
936
|
+
// document a live copy could land between the check and this overwrite.
|
|
937
|
+
return withDocWriteLock(docId, () => saveStoredYDocState(docId, state, textSnapshot), "saveYDocState");
|
|
530
938
|
}
|
|
531
939
|
export function releaseDoc(docId) {
|
|
940
|
+
_liveCopyCache.delete(docId);
|
|
532
941
|
const entry = _cache.get(docId);
|
|
533
942
|
if (entry) {
|
|
534
943
|
entry.doc.destroy();
|
package/dist/db/client.d.ts
CHANGED
|
@@ -165,5 +165,11 @@ export declare function createDbExec(config?: DbExecConfig): Promise<DbExec>;
|
|
|
165
165
|
export declare function annotateMissingTable(err: unknown, sql: unknown): unknown;
|
|
166
166
|
export declare function withDbExec<T>(exec: DbExec, run: () => T): T;
|
|
167
167
|
export declare function getScopedDbExec(): DbExec | undefined;
|
|
168
|
+
/**
|
|
169
|
+
* True while a `getDbExec().transaction()` callback runs. That callback leaves
|
|
170
|
+
* `getScopedDbExec()` unset, so code that must not run inside a caller's
|
|
171
|
+
* transaction asks this as well.
|
|
172
|
+
*/
|
|
173
|
+
export declare function insideDbTransactionCallback(): boolean;
|
|
168
174
|
export declare function getDbExec(): DbExec;
|
|
169
175
|
export declare function closeDbExec(): Promise<void>;
|