@spooky-sync/core 0.0.1-canary.329 → 0.0.1-canary.330

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
@@ -539,10 +539,25 @@ declare class CrdtField {
539
539
  private readonly host;
540
540
  private _onCursorUpdate;
541
541
  private pendingCursorUpdate;
542
+ private loaded;
543
+ private loadedListeners;
542
544
  constructor(table: string, recordId: string, fieldName: string, kind: CrdtKind, cursorsEnabled: boolean, doc: LoroDoc, host: CrdtFieldHost);
543
545
  getDoc(): LoroDoc;
544
546
  /** Whether the document holds anything yet (local state or edits). */
545
547
  hasContent(): boolean;
548
+ /**
549
+ * Whether the document is known: this device had it, it was handed a draft,
550
+ * or the server answered (with content, or that it has none yet). Until then
551
+ * an empty document means "not loaded", not "empty": show the column's text
552
+ * instead, and do not bind an editor (an editor binding an empty document
553
+ * creates structure that competes with the real one).
554
+ */
555
+ isLoaded(): boolean;
556
+ /** Runs `cb` once the field is loaded (at once if it already is). Returns
557
+ * an unsubscribe. */
558
+ onLoaded(cb: () => void): () => void;
559
+ /** @internal The manager calls this when the document became known. */
560
+ markLoaded(): void;
546
561
  /** Publish this session's presence (an `EphemeralStore` encoding, e.g. the
547
562
  * editor's cursor). No-op unless the field is `@cursor`. */
548
563
  pushCursorState(encoded: Uint8Array): void;
@@ -651,6 +666,9 @@ declare class CrdtManager {
651
666
  */
652
667
  private serverWait;
653
668
  private ingest;
669
+ /** Mark the field loaded once its content arrived, or the server answered
670
+ * both reads (the published document and the log) with nothing. */
671
+ private checkLoaded;
654
672
  private tryImport;
655
673
  private refetchCanonical;
656
674
  private scheduleVerify;
package/dist/index.js CHANGED
@@ -2188,8 +2188,8 @@ function trimHistory(history, cap) {
2188
2188
 
2189
2189
  //#endregion
2190
2190
  //#region src/modules/devtools/index.ts
2191
- const CORE_VERSION = "0.0.1-canary.329";
2192
- const WASM_VERSION = "0.0.1-canary.329";
2191
+ const CORE_VERSION = "0.0.1-canary.330";
2192
+ const WASM_VERSION = "0.0.1-canary.330";
2193
2193
  const SURREAL_VERSION = "3.1.0";
2194
2194
  var DevToolsService = class DevToolsService {
2195
2195
  version = CORE_VERSION;
@@ -3350,7 +3350,8 @@ const clearMembershipDirty = (hashes) => (s) => ({
3350
3350
  ...s,
3351
3351
  membershipDirty: withoutAll(s.membershipDirty, hashes)
3352
3352
  });
3353
- /** Record local body versions; dirties every query whose membership names one of them. */
3353
+ /** Record local body versions; dirties every query whose membership or
3354
+ * related rows (subquery children, e.g. a thread's author) name one of them. */
3354
3355
  const setVersions = (entries) => (s) => {
3355
3356
  if (entries.length === 0) return s;
3356
3357
  const versions = new Map(s.versions);
@@ -3361,7 +3362,10 @@ const setVersions = (entries) => (s) => {
3361
3362
  }
3362
3363
  if (changed.size === 0) return s;
3363
3364
  const dirty = [];
3364
- for (const [hash, entry] of s.queries) if (entry.remoteArray.some(([id]) => changed.has(id)) || entry.localArray.some(([id]) => changed.has(id))) dirty.push(hash);
3365
+ for (const [hash, entry] of s.queries) {
3366
+ const names = (rows) => rows.some(([id]) => changed.has(id));
3367
+ if (names(entry.remoteArray) || names(entry.localArray) || names(entry.subqueryRemoteArray)) dirty.push(hash);
3368
+ }
3365
3369
  return {
3366
3370
  ...s,
3367
3371
  versions,
@@ -8570,6 +8574,11 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
8570
8574
  }, "forceClose failed; treating the socket as gone anyway");
8571
8575
  }
8572
8576
  }
8577
+ /** Resolve once no connect is in flight, for calls that go to the SDK
8578
+ * client directly (sign-in, sign-up) and so skip `beforeQuery`. */
8579
+ whenConnected() {
8580
+ return this.beforeQuery();
8581
+ }
8573
8582
  /**
8574
8583
  * Hold statements while a connect is in flight.
8575
8584
  *
@@ -12277,9 +12286,10 @@ var AuthService = class {
12277
12286
  if (missingParams.length > 0) throw new Error(`Missing required signup params for '${accessName}': ${missingParams.join(", ")}`);
12278
12287
  this.logger.info({
12279
12288
  accessName,
12280
- runtimeParams,
12289
+ params: Object.keys(runtimeParams),
12281
12290
  Category: "sp00ky-client::AuthService::signUp"
12282
12291
  }, "Attempting signup");
12292
+ await this.remote.whenConnected();
12283
12293
  const { access } = await this.remote.getClient().signup({
12284
12294
  access: accessName,
12285
12295
  variables: runtimeParams
@@ -12297,6 +12307,7 @@ var AuthService = class {
12297
12307
  accessName,
12298
12308
  Category: "sp00ky-client::AuthService::signIn"
12299
12309
  }, "Attempting signin");
12310
+ await this.remote.whenConnected();
12300
12311
  const { access } = await this.remote.getClient().signin({
12301
12312
  access: accessName,
12302
12313
  variables: runtimeParams
@@ -12434,6 +12445,8 @@ function cursorColorFromName(name) {
12434
12445
  var CrdtField = class {
12435
12446
  _onCursorUpdate = null;
12436
12447
  pendingCursorUpdate = null;
12448
+ loaded = false;
12449
+ loadedListeners = /* @__PURE__ */ new Set();
12437
12450
  constructor(table, recordId, fieldName, kind, cursorsEnabled, doc, host) {
12438
12451
  this.table = table;
12439
12452
  this.recordId = recordId;
@@ -12450,6 +12463,35 @@ var CrdtField = class {
12450
12463
  hasContent() {
12451
12464
  return this.doc.oplogVersion().length() > 0;
12452
12465
  }
12466
+ /**
12467
+ * Whether the document is known: this device had it, it was handed a draft,
12468
+ * or the server answered (with content, or that it has none yet). Until then
12469
+ * an empty document means "not loaded", not "empty": show the column's text
12470
+ * instead, and do not bind an editor (an editor binding an empty document
12471
+ * creates structure that competes with the real one).
12472
+ */
12473
+ isLoaded() {
12474
+ return this.loaded;
12475
+ }
12476
+ /** Runs `cb` once the field is loaded (at once if it already is). Returns
12477
+ * an unsubscribe. */
12478
+ onLoaded(cb) {
12479
+ if (this.loaded) {
12480
+ cb();
12481
+ return () => {};
12482
+ }
12483
+ this.loadedListeners.add(cb);
12484
+ return () => this.loadedListeners.delete(cb);
12485
+ }
12486
+ /** @internal The manager calls this when the document became known. */
12487
+ markLoaded() {
12488
+ if (this.loaded) return;
12489
+ this.loaded = true;
12490
+ for (const cb of [...this.loadedListeners]) try {
12491
+ cb();
12492
+ } catch {}
12493
+ this.loadedListeners.clear();
12494
+ }
12453
12495
  /** Publish this session's presence (an `EphemeralStore` encoding, e.g. the
12454
12496
  * editor's cursor). No-op unless the field is `@cursor`. */
12455
12497
  pushCursorState(encoded) {
@@ -12608,7 +12650,10 @@ var CrdtManager = class {
12608
12650
  this.debounceMs = debounceMs;
12609
12651
  this.logger = logger.child({ service: "CrdtManager" });
12610
12652
  this.connectionUnsubscribes.push(this.remote.subscribeConnection("connected", () => {
12611
- for (const s of this.sessions.values()) this.scheduleFlush(s, 0);
12653
+ for (const s of this.sessions.values()) {
12654
+ this.scheduleFlush(s, 0);
12655
+ if (!s.handle.isLoaded() && !s.canonicalAnswered) this.refetchCanonical(s);
12656
+ }
12612
12657
  }));
12613
12658
  }
12614
12659
  /** Hand over the runtime ports. Called once by the client facade. */
@@ -12730,9 +12775,12 @@ var CrdtManager = class {
12730
12775
  presenceTimer: null,
12731
12776
  presencePending: null,
12732
12777
  presenceSent: false,
12733
- ingesting: Promise.resolve()
12778
+ ingesting: Promise.resolve(),
12779
+ canonicalAnswered: false,
12780
+ logSettled: false
12734
12781
  };
12735
12782
  session.handle = new CrdtField(table, recordId, field, kind, cursor, doc, { pushPresence: (state) => this.pushPresence(session, state) });
12783
+ if (local.snapshot || fresh) session.handle.markLoaded();
12736
12784
  this.sessions.set(key, session);
12737
12785
  this.ensureClaimed();
12738
12786
  if (!local.snapshot && !fresh) await this.serverWait(this.refetchCanonical(session));
@@ -12749,6 +12797,10 @@ var CrdtManager = class {
12749
12797
  session.ingesting = session.ingesting.then(() => this.ingest(session, rows));
12750
12798
  });
12751
12799
  session.disposers.push(log.stop);
12800
+ log.settled.then(() => {
12801
+ session.logSettled = true;
12802
+ session.ingesting.then(() => this.checkLoaded(session));
12803
+ });
12752
12804
  if (cursor) {
12753
12805
  const presence = await ports.watch(`${table}${PRESENCE_SUFFIX}`, {
12754
12806
  doc: recordId,
@@ -12822,9 +12874,15 @@ var CrdtManager = class {
12822
12874
  if (blobs.length === 0) return;
12823
12875
  let pending = false;
12824
12876
  for (const blob of blobs) if (this.tryImport(s.doc, blob, s.key)?.pending) pending = true;
12877
+ this.checkLoaded(s);
12825
12878
  this.schedulePersist(s);
12826
12879
  if (pending) this.refetchCanonical(s);
12827
12880
  }
12881
+ /** Mark the field loaded once its content arrived, or the server answered
12882
+ * both reads (the published document and the log) with nothing. */
12883
+ checkLoaded(s) {
12884
+ if (s.handle.hasContent() || s.canonicalAnswered && s.logSettled) s.handle.markLoaded();
12885
+ }
12828
12886
  tryImport(doc, blob, key) {
12829
12887
  try {
12830
12888
  const status = doc.import(blob);
@@ -12852,6 +12910,8 @@ var CrdtManager = class {
12852
12910
  s.local.rev = Math.max(s.local.rev, row?.rev ?? 0);
12853
12911
  this.schedulePersist(s);
12854
12912
  }
12913
+ s.canonicalAnswered = true;
12914
+ this.checkLoaded(s);
12855
12915
  } catch (e) {
12856
12916
  this.logger.debug({
12857
12917
  key: s.key,
@@ -13340,8 +13400,8 @@ var TabBrokerClient = class {
13340
13400
  lastPingAt = 0;
13341
13401
  closed = false;
13342
13402
  helloState = null;
13343
- constructor(workerUrl, tabId, events, logger) {
13344
- this.workerUrl = workerUrl;
13403
+ constructor(createWorker, tabId, events, logger) {
13404
+ this.createWorker = createWorker;
13345
13405
  this.tabId = tabId;
13346
13406
  this.events = events;
13347
13407
  this.logger = logger;
@@ -13365,10 +13425,7 @@ var TabBrokerClient = class {
13365
13425
  };
13366
13426
  let worker;
13367
13427
  try {
13368
- worker = new SharedWorker(this.workerUrl, {
13369
- type: "module",
13370
- name: "sp00ky-tabs-broker"
13371
- });
13428
+ worker = this.createWorker();
13372
13429
  } catch (e) {
13373
13430
  reject(e);
13374
13431
  return;
@@ -13748,7 +13805,10 @@ var TabsCoordinator = class {
13748
13805
  this.deps = deps;
13749
13806
  this.fingerprint = deps.fingerprint;
13750
13807
  this.bucketId = "anon";
13751
- this.broker = new TabBrokerClient(new URL("./tabs-broker-worker.js", import.meta.url), deps.tabId, {
13808
+ this.broker = new TabBrokerClient(() => new SharedWorker(new URL("./tabs-broker-worker.js", import.meta.url), {
13809
+ type: "module",
13810
+ name: "sp00ky-tabs-broker"
13811
+ }), deps.tabId, {
13752
13812
  onBecomeLeader: (msg) => this.enqueue(() => this.promote(msg)),
13753
13813
  onDemote: (leadershipId) => this.enqueue(() => this.demote(leadershipId)),
13754
13814
  onLeaderReady: (leadershipId, leaderTabId) => {
@@ -15393,7 +15453,7 @@ function createTabsCoordinator(config, s, host) {
15393
15453
  return new TabsCoordinator({
15394
15454
  tabId: s.tabId,
15395
15455
  fingerprint: computeTabsFingerprint({
15396
- coreVersion: "0.0.1-canary.329",
15456
+ coreVersion: "0.0.1-canary.330",
15397
15457
  schemaHash: hash53(config.schemaSurql),
15398
15458
  endpoint: config.database.endpoint ?? "",
15399
15459
  namespace: config.database.namespace,
package/dist/types.d.ts CHANGED
@@ -748,6 +748,9 @@ declare class RemoteDatabaseService extends AbstractDatabaseService {
748
748
  * `disconnected`, which is what drives the supervisor's revive loop.
749
749
  */
750
750
  forceClose(): Promise<void>;
751
+ /** Resolve once no connect is in flight, for calls that go to the SDK
752
+ * client directly (sign-in, sign-up) and so skip `beforeQuery`. */
753
+ whenConnected(): Promise<void>;
751
754
  /**
752
755
  * Hold statements while a connect is in flight.
753
756
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.329",
3
+ "version": "0.0.1-canary.330",
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.329",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.329",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.330",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.330",
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",
@@ -181,3 +181,48 @@ describe('the announced user id', () => {
181
181
  );
182
182
  });
183
183
 
184
+
185
+ describe('signIn and signUp', () => {
186
+ function makeSigning() {
187
+ let connect!: () => void;
188
+ const connected = new Promise<void>((resolve) => (connect = resolve));
189
+ const sent: string[] = [];
190
+ const token = jwt({ AC: 'account', ID: 'user:abc' });
191
+ const remote = {
192
+ query: vi.fn(async () => [[{ id: new RecordId('user', 'abc') }]]),
193
+ setAuthToken: vi.fn(),
194
+ whenConnected: vi.fn(() => connected),
195
+ getClient: () => ({
196
+ signin: vi.fn(async () => (sent.push('signin'), { access: token })),
197
+ signup: vi.fn(async () => (sent.push('signup'), { access: token })),
198
+ authenticate: vi.fn(async () => undefined),
199
+ invalidate: vi.fn(async () => undefined),
200
+ }),
201
+ } as any;
202
+ const persistence = { get: vi.fn(async () => null), set: vi.fn(async () => undefined), remove: vi.fn(async () => undefined) } as any;
203
+ const params = { username: {}, password: {} };
204
+ const schema = { access: { account: { signIn: { params }, signup: { params } } } } as any;
205
+ const logger = silentLogger();
206
+ return { auth: new AuthService(schema, remote, persistence, logger), connect, sent, logger };
207
+ }
208
+
209
+ it('wait for a connect in flight: a socket without its namespace refuses them', async () => {
210
+ for (const call of ['signIn', 'signUp'] as const) {
211
+ const { auth, connect, sent } = makeSigning();
212
+ const done = auth[call]('account' as never, { username: 'u', password: 'secret' } as never).catch(() => undefined);
213
+ for (let i = 0; i < 5; i++) await Promise.resolve();
214
+ expect(sent).toEqual([]);
215
+ connect();
216
+ await done;
217
+ expect(sent).toEqual([call === 'signIn' ? 'signin' : 'signup']);
218
+ }
219
+ });
220
+
221
+ it('never log the password', async () => {
222
+ const { auth, connect, logger } = makeSigning();
223
+ connect();
224
+ await auth.signUp('account' as never, { username: 'u', password: 'hunter2' } as never).catch(() => undefined);
225
+ const logged = JSON.stringify([...logger.info.mock.calls, ...logger.debug.mock.calls]);
226
+ expect(logged).not.toContain('hunter2');
227
+ });
228
+ });
@@ -658,11 +658,17 @@ export class AuthService<S extends SchemaStructure> {
658
658
  );
659
659
  }
660
660
 
661
+ // Parameter names only: the values hold the password (and whatever else
662
+ // the app signs up with), and logs may leave the device.
661
663
  this.logger.info(
662
- { accessName, runtimeParams, Category: 'sp00ky-client::AuthService::signUp' },
664
+ { accessName, params: Object.keys(runtimeParams), Category: 'sp00ky-client::AuthService::signUp' },
663
665
  'Attempting signup'
664
666
  );
665
667
 
668
+ // Straight to the SDK client, so wait out a connect in flight: on a socket
669
+ // without its namespace yet the call fails with "There is no namespace
670
+ // and/or database selected" (a sign-up right after the page loaded).
671
+ await this.remote.whenConnected();
666
672
  const { access } = await this.remote.getClient().signup({
667
673
  access: accessName,
668
674
  variables: runtimeParams,
@@ -704,6 +710,7 @@ export class AuthService<S extends SchemaStructure> {
704
710
  'Attempting signin'
705
711
  );
706
712
 
713
+ await this.remote.whenConnected(); // see signUp
707
714
  const { access } = await this.remote.getClient().signin({
708
715
  access: accessName,
709
716
  variables: runtimeParams,
@@ -34,6 +34,8 @@ export interface CrdtFieldHost {
34
34
  export class CrdtField {
35
35
  private _onCursorUpdate: ((data: Uint8Array) => void) | null = null;
36
36
  private pendingCursorUpdate: Uint8Array | null = null;
37
+ private loaded = false;
38
+ private loadedListeners = new Set<() => void>();
37
39
 
38
40
  constructor(
39
41
  readonly table: string,
@@ -54,6 +56,42 @@ export class CrdtField {
54
56
  return this.doc.oplogVersion().length() > 0;
55
57
  }
56
58
 
59
+ /**
60
+ * Whether the document is known: this device had it, it was handed a draft,
61
+ * or the server answered (with content, or that it has none yet). Until then
62
+ * an empty document means "not loaded", not "empty": show the column's text
63
+ * instead, and do not bind an editor (an editor binding an empty document
64
+ * creates structure that competes with the real one).
65
+ */
66
+ isLoaded(): boolean {
67
+ return this.loaded;
68
+ }
69
+
70
+ /** Runs `cb` once the field is loaded (at once if it already is). Returns
71
+ * an unsubscribe. */
72
+ onLoaded(cb: () => void): () => void {
73
+ if (this.loaded) {
74
+ cb();
75
+ return () => {};
76
+ }
77
+ this.loadedListeners.add(cb);
78
+ return () => this.loadedListeners.delete(cb);
79
+ }
80
+
81
+ /** @internal The manager calls this when the document became known. */
82
+ markLoaded(): void {
83
+ if (this.loaded) return;
84
+ this.loaded = true;
85
+ for (const cb of [...this.loadedListeners]) {
86
+ try {
87
+ cb();
88
+ } catch {
89
+ /* a listener's failure is its own */
90
+ }
91
+ }
92
+ this.loadedListeners.clear();
93
+ }
94
+
57
95
  /** Publish this session's presence (an `EphemeralStore` encoding, e.g. the
58
96
  * editor's cursor). No-op unless the field is `@cursor`. */
59
97
  pushCursorState(encoded: Uint8Array): void {
@@ -29,6 +29,12 @@ class World {
29
29
  hang = false;
30
30
  /** A slow device store: each local write takes this long (real timers). */
31
31
  storeDelayMs = 0;
32
+ /** A connected but slow server: reads and the log's first answer wait for `release()`. */
33
+ slow = false;
34
+ private held: Array<() => void> = [];
35
+ release() {
36
+ for (const go of this.held.splice(0)) go();
37
+ }
32
38
  private watchers = new Set<{ table: string; where: Record<string, unknown>; cb: (rows: any[]) => void }>();
33
39
 
34
40
  client(user: string, store = new Map<string, any>(), logger: any = silent) {
@@ -44,6 +50,7 @@ class World {
44
50
  subscribeConnection: () => () => {},
45
51
  getStatus: () => (this.online ? 'connected' : 'disconnected'),
46
52
  query: async (sql: string, vars: any) => {
53
+ if (this.slow) await new Promise<void>((go) => this.held.push(go));
47
54
  if (!this.online && this.hang) return new Promise(() => {});
48
55
  if (!this.online) throw new Error('not connected');
49
56
  if (sql.startsWith('CREATE')) {
@@ -62,6 +69,15 @@ class World {
62
69
  watch: async (table, where, cb) => {
63
70
  const w = { table, where, cb };
64
71
  this.watchers.add(w);
72
+ if (this.slow) {
73
+ const settled = new Promise<void>((go) =>
74
+ this.held.push(() => {
75
+ cb(this.rowsFor(table, where));
76
+ go();
77
+ })
78
+ );
79
+ return { stop: () => void this.watchers.delete(w), settled };
80
+ }
65
81
  cb(this.rowsFor(table, where));
66
82
  return { stop: () => void this.watchers.delete(w), settled: Promise.resolve() };
67
83
  },
@@ -220,6 +236,46 @@ describe('CRDT fields', () => {
220
236
  expect([...store.values()].some((row) => row.snapshot)).toBe(true);
221
237
  });
222
238
 
239
+ it('a field is loaded once the server answers, and at once with a local copy or a draft', async () => {
240
+ const world = new World();
241
+ const server = new LoroDoc();
242
+ server.getText('body').insert(0, 'from the server');
243
+ server.commit();
244
+ await world.fold('doc:1', 'body', server);
245
+ world.slow = true;
246
+ const store = new Map<string, any>();
247
+ const a = world.client('user:a', store);
248
+ const opening = a.manager.open('doc', 'doc:1', 'body');
249
+ await vi.advanceTimersByTimeAsync(9000);
250
+ const f = await opening;
251
+ // Handed out after the bounded wait, but empty only because nothing came.
252
+ expect(text(f)).toBe('');
253
+ expect(f.isLoaded()).toBe(false);
254
+ let loaded = 0;
255
+ f.onLoaded(() => loaded++);
256
+ world.release();
257
+ await realWait();
258
+ expect(text(f)).toBe('from the server');
259
+ expect(f.isLoaded()).toBe(true);
260
+ expect(loaded).toBe(1);
261
+ await a.manager.close('doc', 'doc:1', 'body');
262
+
263
+ // This device has it now: loaded before the server says anything.
264
+ const again = await (async () => {
265
+ const opening2 = a.manager.open('doc', 'doc:1', 'body');
266
+ await vi.advanceTimersByTimeAsync(9000);
267
+ return opening2;
268
+ })();
269
+ expect(again.isLoaded()).toBe(true);
270
+ world.release();
271
+
272
+ const draft = await a.manager.createDraft('doc', 'body');
273
+ draft.getDoc().getText('body').insert(0, 'new');
274
+ draft.getDoc().commit();
275
+ const fresh = await a.manager.open('doc', 'doc:2', 'body', draft);
276
+ expect(fresh.isLoaded()).toBe(true);
277
+ });
278
+
223
279
  it('a reopen right after a close starts from what the close saved', async () => {
224
280
  vi.useRealTimers();
225
281
  const world = new World();
@@ -90,6 +90,10 @@ interface Session {
90
90
  presenceSent: boolean;
91
91
  /** The latest import of delivered rows (chained, so awaiting it awaits all). */
92
92
  ingesting: Promise<void>;
93
+ /** The server answered the published-document read (with a row or none). */
94
+ canonicalAnswered: boolean;
95
+ /** The log watch delivered the server's first answer. */
96
+ logSettled: boolean;
93
97
  }
94
98
 
95
99
  const LOCAL_TABLE = '_00_crdt_local';
@@ -130,7 +134,11 @@ export class CrdtManager {
130
134
  this.logger = logger.child({ service: 'CrdtManager' });
131
135
  this.connectionUnsubscribes.push(
132
136
  this.remote.subscribeConnection('connected', () => {
133
- for (const s of this.sessions.values()) this.scheduleFlush(s, 0);
137
+ for (const s of this.sessions.values()) {
138
+ this.scheduleFlush(s, 0);
139
+ // Opened while the server could not answer: read it now.
140
+ if (!s.handle.isLoaded() && !s.canonicalAnswered) void this.refetchCanonical(s);
141
+ }
134
142
  })
135
143
  );
136
144
  }
@@ -289,10 +297,14 @@ export class CrdtManager {
289
297
  presencePending: null,
290
298
  presenceSent: false,
291
299
  ingesting: Promise.resolve(),
300
+ canonicalAnswered: false,
301
+ logSettled: false,
292
302
  };
293
303
  session.handle = new CrdtField(table, recordId, field, kind, cursor, doc, {
294
304
  pushPresence: (state) => this.pushPresence(session, state),
295
305
  });
306
+ // Known from the start: this device's copy, or a record just created here.
307
+ if (local.snapshot || fresh) session.handle.markLoaded();
296
308
  this.sessions.set(key, session);
297
309
 
298
310
  // Claimed now, so the first edits do not wait on the round trip.
@@ -315,6 +327,10 @@ export class CrdtManager {
315
327
  session.ingesting = session.ingesting.then(() => this.ingest(session, rows));
316
328
  });
317
329
  session.disposers.push(log.stop);
330
+ void log.settled.then(() => {
331
+ session.logSettled = true;
332
+ void session.ingesting.then(() => this.checkLoaded(session));
333
+ });
318
334
  if (cursor) {
319
335
  const presence = await ports.watch(`${table}${PRESENCE_SUFFIX}`, { doc: recordId, field }, (rows) =>
320
336
  this.ingestPresence(session, rows)
@@ -398,12 +414,19 @@ export class CrdtManager {
398
414
  const status = this.tryImport(s.doc, blob, s.key);
399
415
  if (status?.pending) pending = true;
400
416
  }
417
+ this.checkLoaded(s);
401
418
  this.schedulePersist(s);
402
419
  // Ops depending on rows that were folded away before this device saw
403
420
  // them: the published snapshot holds those predecessors.
404
421
  if (pending) void this.refetchCanonical(s);
405
422
  }
406
423
 
424
+ /** Mark the field loaded once its content arrived, or the server answered
425
+ * both reads (the published document and the log) with nothing. */
426
+ private checkLoaded(s: Session): void {
427
+ if (s.handle.hasContent() || (s.canonicalAnswered && s.logSettled)) s.handle.markLoaded();
428
+ }
429
+
407
430
  private tryImport(doc: LoroDoc, blob: Uint8Array, key: string): { pending: boolean } | null {
408
431
  try {
409
432
  const status = doc.import(blob);
@@ -428,6 +451,8 @@ export class CrdtManager {
428
451
  s.local.rev = Math.max(s.local.rev, row?.rev ?? 0);
429
452
  this.schedulePersist(s);
430
453
  }
454
+ s.canonicalAnswered = true;
455
+ this.checkLoaded(s);
431
456
  } catch (e) {
432
457
  // Offline or no published document yet: the log rows still arrive.
433
458
  this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::refetch' }, 'No canonical CRDT document fetched');
@@ -259,6 +259,12 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
259
259
  }
260
260
  }
261
261
 
262
+ /** Resolve once no connect is in flight, for calls that go to the SDK
263
+ * client directly (sign-in, sign-up) and so skip `beforeQuery`. */
264
+ whenConnected(): Promise<void> {
265
+ return this.beforeQuery();
266
+ }
267
+
262
268
  /**
263
269
  * Hold statements while a connect is in flight.
264
270
  *
@@ -62,7 +62,8 @@ export class TabBrokerClient {
62
62
  private helloState: BrokerHello | null = null;
63
63
 
64
64
  constructor(
65
- private workerUrl: URL,
65
+ /** Starts (or joins) the broker SharedWorker. */
66
+ private createWorker: () => SharedWorker,
66
67
  readonly tabId: TabId,
67
68
  private events: BrokerClientEvents,
68
69
  private logger: Logger
@@ -88,7 +89,7 @@ export class TabBrokerClient {
88
89
  };
89
90
  let worker: SharedWorker;
90
91
  try {
91
- worker = new SharedWorker(this.workerUrl, { type: 'module', name: 'sp00ky-tabs-broker' });
92
+ worker = this.createWorker();
92
93
  } catch (e) {
93
94
  reject(e);
94
95
  return;
@@ -253,11 +253,19 @@ export class TabsCoordinator {
253
253
  ) {
254
254
  this.fingerprint = deps.fingerprint;
255
255
  this.bucketId = 'anon';
256
- // The URL is built HERE (same directory as the worker source) so the
257
- // published flat bundle's rewritten './tabs-broker-worker.js' resolves at
258
- // the dist top level, exactly like the sqlite worker URL does.
256
+ // The worker is created HERE (same directory as its source) and in this
257
+ // exact shape, `new SharedWorker(new URL('./x.ts', import.meta.url))`:
258
+ // the published flat bundle rewrites the name to './tabs-broker-worker.js'
259
+ // at the dist top level, like the sqlite worker, and a bundler that builds
260
+ // core from source (Vite) only compiles a worker written in one expression.
261
+ // Passing the URL around instead shipped the raw `.ts` file there, which no
262
+ // browser runs, so every tab fell back to running alone.
259
263
  this.broker = new TabBrokerClient(
260
- new URL('./tabs-broker-worker.ts', import.meta.url),
264
+ () =>
265
+ new SharedWorker(new URL('./tabs-broker-worker.ts', import.meta.url), {
266
+ type: 'module',
267
+ name: 'sp00ky-tabs-broker',
268
+ }),
261
269
  deps.tabId,
262
270
  {
263
271
  onBecomeLeader: (msg) => this.enqueue(() => this.promote(msg)),
@@ -175,10 +175,12 @@ describe('versions', () => {
175
175
  e('a', { remoteArray: [['thing:1', 1]] }),
176
176
  e('b', { localArray: [['thing:2', 1]] }),
177
177
  e('c'),
178
+ // A related row (a thread's author): a new body must re-render the list.
179
+ e('d', { subqueryRemoteArray: [['user:1', 1]] }),
178
180
  ]);
179
181
  expect(R.setVersions([])(s0)).toBe(s0);
180
- const s1 = R.setVersions([['thing:1', 1], ['thing:2', 1], ['thing:9', 1]])(s0);
181
- expect([...s1.dirty].sort()).toEqual(['a', 'b']);
182
+ const s1 = R.setVersions([['thing:1', 1], ['thing:2', 1], ['thing:9', 1], ['user:1', 2]])(s0);
183
+ expect([...s1.dirty].sort()).toEqual(['a', 'b', 'd']);
182
184
  expect(R.setVersions([['thing:1', 1]])(s1)).toBe(s1);
183
185
  const s2 = R.deleteVersions(['thing:9', 'nope'])(s1);
184
186
  expect(s2.versions.has('thing:9')).toBe(false);
@@ -325,7 +325,8 @@ export const clearMembershipDirty =
325
325
 
326
326
  // ---- versions --------------------------------------------------------------
327
327
 
328
- /** Record local body versions; dirties every query whose membership names one of them. */
328
+ /** Record local body versions; dirties every query whose membership or
329
+ * related rows (subquery children, e.g. a thread's author) name one of them. */
329
330
  export const setVersions =
330
331
  (entries: ReadonlyArray<readonly [string, number]>): Reducer =>
331
332
  (s) => {
@@ -341,7 +342,8 @@ export const setVersions =
341
342
  if (changed.size === 0) return s;
342
343
  const dirty: QueryHash[] = [];
343
344
  for (const [hash, entry] of s.queries) {
344
- if (entry.remoteArray.some(([id]) => changed.has(id)) || entry.localArray.some(([id]) => changed.has(id))) {
345
+ const names = (rows: RecordVersionArray) => rows.some(([id]) => changed.has(id));
346
+ if (names(entry.remoteArray) || names(entry.localArray) || names(entry.subqueryRemoteArray)) {
345
347
  dirty.push(hash);
346
348
  }
347
349
  }