@spooky-sync/core 0.0.1-canary.344 → 0.0.1-canary.346

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 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.344";
2290
- const WASM_VERSION = "0.0.1-canary.344";
2289
+ const CORE_VERSION = "0.0.1-canary.346";
2290
+ const WASM_VERSION = "0.0.1-canary.346";
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.then(() => this.checkLoaded(session));
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.344",
15925
+ coreVersion: "0.0.1-canary.346",
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.344",
3
+ "version": "0.0.1-canary.346",
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.344",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.344",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.346",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.346",
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
- void session.ingesting.then(() => this.checkLoaded(session));
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 {