@spooky-sync/core 0.0.1-canary.345 → 0.0.1-canary.347
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/index.d.ts +13 -0
- package/dist/index.js +31 -6
- package/package.json +3 -3
- package/src/modules/crdt/crdt.test.ts +32 -1
- package/src/modules/crdt/index.ts +30 -2
package/dist/index.d.ts
CHANGED
|
@@ -676,6 +676,19 @@ declare class CrdtManager {
|
|
|
676
676
|
private checkLoaded;
|
|
677
677
|
private tryImport;
|
|
678
678
|
private refetchCanonical;
|
|
679
|
+
/**
|
|
680
|
+
* A first open reads the published document and the log at the same time,
|
|
681
|
+
* and a fold landing between the two hides the field from both: it
|
|
682
|
+
* publishes its snapshot, then deletes the rows the log query was about to
|
|
683
|
+
* return, after the published-document read came back empty. Nothing seen
|
|
684
|
+
* means nothing to verify, so the editor stayed empty until the next edit
|
|
685
|
+
* (a second device opening a just-created thread, under e2e load). Once the
|
|
686
|
+
* log has answered, a field that is still empty reads the published
|
|
687
|
+
* document again: the fold that took its rows committed before deleting
|
|
688
|
+
* them. Once per open; `first` is the first read (null with a local copy or
|
|
689
|
+
* a draft, which need none).
|
|
690
|
+
*/
|
|
691
|
+
private recheckCanonical;
|
|
679
692
|
private scheduleVerify;
|
|
680
693
|
/**
|
|
681
694
|
* After a fold: compare this device's document with the one the server
|
package/dist/index.js
CHANGED
|
@@ -2286,8 +2286,8 @@ function trimHistory(history, cap) {
|
|
|
2286
2286
|
|
|
2287
2287
|
//#endregion
|
|
2288
2288
|
//#region src/modules/devtools/index.ts
|
|
2289
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
2290
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
2289
|
+
const CORE_VERSION = "0.0.1-canary.347";
|
|
2290
|
+
const WASM_VERSION = "0.0.1-canary.347";
|
|
2291
2291
|
const SURREAL_VERSION = "3.1.0";
|
|
2292
2292
|
var DevToolsService = class DevToolsService {
|
|
2293
2293
|
version = CORE_VERSION;
|
|
@@ -13071,7 +13071,8 @@ var CrdtManager = class {
|
|
|
13071
13071
|
presenceSent: false,
|
|
13072
13072
|
ingesting: Promise.resolve(),
|
|
13073
13073
|
canonicalAnswered: false,
|
|
13074
|
-
logSettled: false
|
|
13074
|
+
logSettled: false,
|
|
13075
|
+
recheck: null
|
|
13075
13076
|
};
|
|
13076
13077
|
session.handle = new CrdtField(table, recordId, field, kind, cursor, doc, { pushPresence: (state) => this.pushPresence(session, state) });
|
|
13077
13078
|
if (local.snapshot || fresh) session.handle.markLoaded();
|
|
@@ -13097,13 +13098,16 @@ var CrdtManager = class {
|
|
|
13097
13098
|
const [log, presence] = await Promise.all([logWatch, presenceWatch]);
|
|
13098
13099
|
session.disposers.push(log.stop);
|
|
13099
13100
|
if (presence) session.disposers.push(presence.stop);
|
|
13100
|
-
log.settled.then(() => {
|
|
13101
|
+
log.settled.then(async () => {
|
|
13101
13102
|
session.logSettled = true;
|
|
13102
|
-
session.ingesting
|
|
13103
|
+
await session.ingesting;
|
|
13104
|
+
await this.recheckCanonical(session, canonical);
|
|
13105
|
+
this.checkLoaded(session);
|
|
13103
13106
|
});
|
|
13104
13107
|
if (canonical) await this.serverWait(canonical);
|
|
13105
13108
|
if (!session.handle.hasContent() && !fresh) await this.serverWait(log.settled);
|
|
13106
13109
|
await session.ingesting;
|
|
13110
|
+
if (session.logSettled) await this.serverWait(this.recheckCanonical(session, canonical));
|
|
13107
13111
|
this.scheduleFlush(session, 0);
|
|
13108
13112
|
this.logger.debug({
|
|
13109
13113
|
key,
|
|
@@ -13216,6 +13220,27 @@ var CrdtManager = class {
|
|
|
13216
13220
|
s.refetching = false;
|
|
13217
13221
|
}
|
|
13218
13222
|
}
|
|
13223
|
+
/**
|
|
13224
|
+
* A first open reads the published document and the log at the same time,
|
|
13225
|
+
* and a fold landing between the two hides the field from both: it
|
|
13226
|
+
* publishes its snapshot, then deletes the rows the log query was about to
|
|
13227
|
+
* return, after the published-document read came back empty. Nothing seen
|
|
13228
|
+
* means nothing to verify, so the editor stayed empty until the next edit
|
|
13229
|
+
* (a second device opening a just-created thread, under e2e load). Once the
|
|
13230
|
+
* log has answered, a field that is still empty reads the published
|
|
13231
|
+
* document again: the fold that took its rows committed before deleting
|
|
13232
|
+
* them. Once per open; `first` is the first read (null with a local copy or
|
|
13233
|
+
* a draft, which need none).
|
|
13234
|
+
*/
|
|
13235
|
+
recheckCanonical(s, first) {
|
|
13236
|
+
if (!first) return Promise.resolve();
|
|
13237
|
+
s.recheck ??= (async () => {
|
|
13238
|
+
await first;
|
|
13239
|
+
await s.ingesting;
|
|
13240
|
+
if (!s.handle.hasContent()) await this.refetchCanonical(s);
|
|
13241
|
+
})();
|
|
13242
|
+
return s.recheck;
|
|
13243
|
+
}
|
|
13219
13244
|
scheduleVerify(s) {
|
|
13220
13245
|
if (s.verifyTimer) clearTimeout(s.verifyTimer);
|
|
13221
13246
|
s.verifyTimer = setTimeout(() => {
|
|
@@ -15897,7 +15922,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
15897
15922
|
return new TabsCoordinator({
|
|
15898
15923
|
tabId: s.tabId,
|
|
15899
15924
|
fingerprint: computeTabsFingerprint({
|
|
15900
|
-
coreVersion: "0.0.1-canary.
|
|
15925
|
+
coreVersion: "0.0.1-canary.347",
|
|
15901
15926
|
schemaHash: hash53(config.schemaSurql),
|
|
15902
15927
|
endpoint: config.database.endpoint ?? "",
|
|
15903
15928
|
namespace: config.database.namespace,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.347",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
78
|
+
"@spooky-sync/query-builder": "0.0.1-canary.347",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.347",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
81
|
"@surrealdb/wasm": "^3.1.0",
|
|
82
82
|
"@surrealdb/wasm-native": "^3.3.4",
|
|
@@ -31,6 +31,8 @@ class World {
|
|
|
31
31
|
storeDelayMs = 0;
|
|
32
32
|
/** A connected but slow server: reads and the log's first answer wait for `release()`. */
|
|
33
33
|
slow = false;
|
|
34
|
+
/** Only the log's first answer waits for `release()`; reads answer at once. */
|
|
35
|
+
slowLog = false;
|
|
34
36
|
private held: Array<() => void> = [];
|
|
35
37
|
release() {
|
|
36
38
|
for (const go of this.held.splice(0)) go();
|
|
@@ -72,7 +74,7 @@ class World {
|
|
|
72
74
|
this.watched.push(table);
|
|
73
75
|
const w = { table, where, cb };
|
|
74
76
|
this.watchers.add(w);
|
|
75
|
-
if (this.slow) {
|
|
77
|
+
if (this.slow || this.slowLog) {
|
|
76
78
|
const settled = new Promise<void>((go) =>
|
|
77
79
|
this.held.push(() => {
|
|
78
80
|
cb(this.rowsFor(table, where));
|
|
@@ -419,6 +421,35 @@ describe('CRDT fields', () => {
|
|
|
419
421
|
expect(text(fb)).toBe('Hello world');
|
|
420
422
|
});
|
|
421
423
|
|
|
424
|
+
// threads-e2e 2026-10-22: a second device opening a just-created thread kept
|
|
425
|
+
// an empty editor. Its published-document read came back before the fold,
|
|
426
|
+
// and its log query answered after the fold had deleted the rows.
|
|
427
|
+
it('a first open still gets the content when a fold lands between its two reads', async () => {
|
|
428
|
+
const world = new World();
|
|
429
|
+
const a = world.client('user:a');
|
|
430
|
+
const fa = await a.manager.open('doc', 'doc:1', 'body');
|
|
431
|
+
fa.getDoc().getText('body').insert(0, 'shared seed');
|
|
432
|
+
fa.getDoc().commit();
|
|
433
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
434
|
+
expect(world.log).toHaveLength(1);
|
|
435
|
+
|
|
436
|
+
world.slowLog = true;
|
|
437
|
+
const b = world.client('user:b');
|
|
438
|
+
const opening = b.manager.open('doc', 'doc:1', 'body');
|
|
439
|
+
// B's published-document read answers now (nothing published yet) while
|
|
440
|
+
// its log query is still out.
|
|
441
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
442
|
+
expect(world.published.size).toBe(0);
|
|
443
|
+
await world.fold('doc:1', 'body', LoroDoc.fromSnapshot(fa.getDoc().export({ mode: 'snapshot' })));
|
|
444
|
+
expect(world.log).toHaveLength(0);
|
|
445
|
+
world.slowLog = false;
|
|
446
|
+
world.release();
|
|
447
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
448
|
+
const fb = await opening;
|
|
449
|
+
expect(text(fb)).toBe('shared seed');
|
|
450
|
+
expect(fb.isLoaded()).toBe(true);
|
|
451
|
+
});
|
|
452
|
+
|
|
422
453
|
it('report a document that differs from the published one at the same version', async () => {
|
|
423
454
|
const world = new World();
|
|
424
455
|
const warn = vi.fn();
|
|
@@ -99,6 +99,8 @@ interface Session {
|
|
|
99
99
|
canonicalAnswered: boolean;
|
|
100
100
|
/** The log watch delivered the server's first answer. */
|
|
101
101
|
logSettled: boolean;
|
|
102
|
+
/** The second published-document read of a first open (see `recheckCanonical`). */
|
|
103
|
+
recheck: Promise<void> | null;
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
const LOCAL_TABLE = '_00_crdt_local';
|
|
@@ -307,6 +309,7 @@ export class CrdtManager {
|
|
|
307
309
|
ingesting: Promise.resolve(),
|
|
308
310
|
canonicalAnswered: false,
|
|
309
311
|
logSettled: false,
|
|
312
|
+
recheck: null,
|
|
310
313
|
};
|
|
311
314
|
session.handle = new CrdtField(table, recordId, field, kind, cursor, doc, {
|
|
312
315
|
pushPresence: (state) => this.pushPresence(session, state),
|
|
@@ -342,9 +345,11 @@ export class CrdtManager {
|
|
|
342
345
|
const [log, presence] = await Promise.all([logWatch, presenceWatch]);
|
|
343
346
|
session.disposers.push(log.stop);
|
|
344
347
|
if (presence) session.disposers.push(presence.stop);
|
|
345
|
-
void log.settled.then(() => {
|
|
348
|
+
void log.settled.then(async () => {
|
|
346
349
|
session.logSettled = true;
|
|
347
|
-
|
|
350
|
+
await session.ingesting;
|
|
351
|
+
await this.recheckCanonical(session, canonical);
|
|
352
|
+
this.checkLoaded(session);
|
|
348
353
|
});
|
|
349
354
|
// Offline the published document is read once the connection is back
|
|
350
355
|
// instead of holding the open.
|
|
@@ -355,6 +360,7 @@ export class CrdtManager {
|
|
|
355
360
|
// once the server's rows for it are in.
|
|
356
361
|
if (!session.handle.hasContent() && !fresh) await this.serverWait(log.settled);
|
|
357
362
|
await session.ingesting;
|
|
363
|
+
if (session.logSettled) await this.serverWait(this.recheckCanonical(session, canonical));
|
|
358
364
|
// Edits a previous session made but never sent (closed tab, offline).
|
|
359
365
|
this.scheduleFlush(session, 0);
|
|
360
366
|
this.logger.debug({ key, peer: this.peer, Category: 'sp00ky-client::CrdtManager::open' }, 'CRDT field opened');
|
|
@@ -473,6 +479,28 @@ export class CrdtManager {
|
|
|
473
479
|
}
|
|
474
480
|
}
|
|
475
481
|
|
|
482
|
+
/**
|
|
483
|
+
* A first open reads the published document and the log at the same time,
|
|
484
|
+
* and a fold landing between the two hides the field from both: it
|
|
485
|
+
* publishes its snapshot, then deletes the rows the log query was about to
|
|
486
|
+
* return, after the published-document read came back empty. Nothing seen
|
|
487
|
+
* means nothing to verify, so the editor stayed empty until the next edit
|
|
488
|
+
* (a second device opening a just-created thread, under e2e load). Once the
|
|
489
|
+
* log has answered, a field that is still empty reads the published
|
|
490
|
+
* document again: the fold that took its rows committed before deleting
|
|
491
|
+
* them. Once per open; `first` is the first read (null with a local copy or
|
|
492
|
+
* a draft, which need none).
|
|
493
|
+
*/
|
|
494
|
+
private recheckCanonical(s: Session, first: Promise<void> | null): Promise<void> {
|
|
495
|
+
if (!first) return Promise.resolve();
|
|
496
|
+
s.recheck ??= (async () => {
|
|
497
|
+
await first;
|
|
498
|
+
await s.ingesting;
|
|
499
|
+
if (!s.handle.hasContent()) await this.refetchCanonical(s);
|
|
500
|
+
})();
|
|
501
|
+
return s.recheck;
|
|
502
|
+
}
|
|
503
|
+
|
|
476
504
|
// --- checking against the published document ---------------------
|
|
477
505
|
|
|
478
506
|
private scheduleVerify(s: Session): void {
|