@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 +18 -0
- package/dist/index.js +75 -15
- package/dist/types.d.ts +3 -0
- package/package.json +3 -3
- package/src/modules/auth/auth.local-first.test.ts +45 -0
- package/src/modules/auth/index.ts +8 -1
- package/src/modules/crdt/crdt-field.ts +38 -0
- package/src/modules/crdt/crdt.test.ts +56 -0
- package/src/modules/crdt/index.ts +26 -1
- package/src/services/database/remote.ts +6 -0
- package/src/services/tabs/broker-client.ts +3 -2
- package/src/services/tabs/coordinator.ts +12 -4
- package/src/state/reducers.test.ts +4 -2
- package/src/state/reducers.ts +4 -2
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.
|
|
2192
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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
|
|
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)
|
|
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())
|
|
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(
|
|
13344
|
-
this.
|
|
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 =
|
|
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),
|
|
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.
|
|
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.
|
|
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.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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())
|
|
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
|
-
|
|
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 =
|
|
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
|
|
257
|
-
//
|
|
258
|
-
// the
|
|
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
|
-
|
|
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);
|
package/src/state/reducers.ts
CHANGED
|
@@ -325,7 +325,8 @@ export const clearMembershipDirty =
|
|
|
325
325
|
|
|
326
326
|
// ---- versions --------------------------------------------------------------
|
|
327
327
|
|
|
328
|
-
/** Record local body versions; dirties every query whose membership
|
|
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
|
-
|
|
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
|
}
|