@spooky-sync/core 0.0.1-canary.103 → 0.0.1-canary.105
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 +180 -3
- package/dist/index.js +2212 -1620
- package/dist/types.d.ts +15 -4
- package/package.json +3 -3
- package/src/modules/cache/index.ts +35 -2
- package/src/modules/crdt/crdt-field.ts +9 -2
- package/src/modules/crdt/index.ts +7 -2
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.status.test.ts +143 -2
- package/src/modules/data/index.ts +229 -13
- package/src/modules/devtools/index.ts +8 -5
- package/src/modules/ref-tables.test.ts +26 -1
- package/src/modules/ref-tables.ts +19 -0
- package/src/modules/sync/queue/queue-down.ts +6 -0
- package/src/modules/sync/queue/queue-up.ts +14 -0
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +33 -4
- package/src/modules/sync/sync.ts +76 -5
- package/src/services/database/local-migrator.ts +6 -6
- package/src/services/database/local.test.ts +31 -0
- package/src/services/database/local.ts +298 -72
- package/src/services/stream-processor/index.ts +48 -2
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/sp00ky.auth-order.test.ts +27 -0
- package/src/sp00ky.ts +132 -2
- package/src/types.ts +15 -4
package/src/modules/sync/sync.ts
CHANGED
|
@@ -101,6 +101,10 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
101
101
|
// LIVE is delivering events and speeds it back up when LIVE quiets.
|
|
102
102
|
private listRefPollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
103
103
|
private listRefPollRunning: boolean = false;
|
|
104
|
+
// The currently-executing poll tick, if any. `stopListRefPoll` only stops
|
|
105
|
+
// future ticks; a bucket switch must also AWAIT the in-flight one so its
|
|
106
|
+
// local writes land in the store it started against.
|
|
107
|
+
private listRefPollInFlight: Promise<void> | null = null;
|
|
104
108
|
public readonly refSyncIntervalMs: number;
|
|
105
109
|
|
|
106
110
|
// Consecutive poll cycles that observed NO list_ref change. Drives the
|
|
@@ -389,6 +393,47 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
389
393
|
}
|
|
390
394
|
}
|
|
391
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Quiesce all sync activity ahead of a local-bucket switch. After this
|
|
398
|
+
* resolves, nothing in the sync module writes to the local store: the poll
|
|
399
|
+
* loop is stopped AND its in-flight tick awaited, LIVE is killed, debounce
|
|
400
|
+
* timers are cancelled (their outbox rows are already persisted), and the
|
|
401
|
+
* scheduler has drained its in-flight queue item — including that item's
|
|
402
|
+
* outbox-row delete, which must land in the OLD bucket. Queued down-events
|
|
403
|
+
* are dropped (they reference old-bucket query rows; the post-switch rebind
|
|
404
|
+
* re-enqueues registrations). The old user's un-pushed outbox is deliberately
|
|
405
|
+
* NOT drained: the remote session already belongs to the next user.
|
|
406
|
+
*/
|
|
407
|
+
public async prepareBucketSwitch(): Promise<void> {
|
|
408
|
+
this.stopSelfHeal();
|
|
409
|
+
this.stopListRefPoll();
|
|
410
|
+
if (this.listRefPollInFlight) await this.listRefPollInFlight;
|
|
411
|
+
await this.killRefLiveQuery();
|
|
412
|
+
this.upQueue.clearDebounceTimers();
|
|
413
|
+
await this.scheduler.pause();
|
|
414
|
+
this.downQueue.clear();
|
|
415
|
+
this.stillRemoteStreaks.clear();
|
|
416
|
+
this.logger.info(
|
|
417
|
+
{ Category: 'sp00ky-client::Sp00kySync::prepareBucketSwitch' },
|
|
418
|
+
'Sync quiesced for bucket switch'
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Resume syncing against the freshly-opened bucket: reload the mutation
|
|
424
|
+
* outbox from ITS `_00_pending_mutations` (the new user's own un-pushed
|
|
425
|
+
* offline work) and restart the scheduler. LIVE + the list_ref poll restart
|
|
426
|
+
* via the `setCurrentUserId` call that follows in the auth listener.
|
|
427
|
+
*/
|
|
428
|
+
public async completeBucketSwitch(): Promise<void> {
|
|
429
|
+
await this.upQueue.loadFromDatabase();
|
|
430
|
+
this.scheduler.resume();
|
|
431
|
+
this.logger.info(
|
|
432
|
+
{ Category: 'sp00ky-client::Sp00kySync::completeBucketSwitch' },
|
|
433
|
+
'Sync resumed after bucket switch'
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
392
437
|
/**
|
|
393
438
|
* Push the authenticated user's record id from the parent client's
|
|
394
439
|
* auth subscription. Tears down the existing `_00_list_ref` LIVE (if
|
|
@@ -461,9 +506,14 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
461
506
|
this.listRefPollTimer = setTimeout(async () => {
|
|
462
507
|
if (!this.listRefPollRunning) return;
|
|
463
508
|
let changed = false;
|
|
464
|
-
|
|
509
|
+
const tick = (async () => {
|
|
465
510
|
changed = await this.pollListRefForActiveQueries();
|
|
511
|
+
})();
|
|
512
|
+
this.listRefPollInFlight = tick.catch(() => {});
|
|
513
|
+
try {
|
|
514
|
+
await tick;
|
|
466
515
|
} finally {
|
|
516
|
+
this.listRefPollInFlight = null;
|
|
467
517
|
if (!this.listRefPollRunning) return;
|
|
468
518
|
// Reset the idle streak on any observed change so the poll snaps
|
|
469
519
|
// back to the fast base cadence; otherwise grow it so a quiet page
|
|
@@ -1037,7 +1087,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1037
1087
|
|
|
1038
1088
|
const fetching = diff.added.length + diff.updated.length > 0;
|
|
1039
1089
|
if (fetching) {
|
|
1040
|
-
this.dataModule.
|
|
1090
|
+
this.dataModule.beginFetching(hash);
|
|
1041
1091
|
}
|
|
1042
1092
|
try {
|
|
1043
1093
|
const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
|
|
@@ -1079,7 +1129,18 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1079
1129
|
}
|
|
1080
1130
|
} finally {
|
|
1081
1131
|
if (fetching) {
|
|
1082
|
-
|
|
1132
|
+
// Land the coalesced result BEFORE flipping to idle: the final stream
|
|
1133
|
+
// update sits on a debounce timer, and an `idle` that races ahead of it
|
|
1134
|
+
// would let consumers treat a partially-filled window as authoritative.
|
|
1135
|
+
try {
|
|
1136
|
+
await this.dataModule.flushPendingStreamUpdate(hash);
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
this.logger.warn(
|
|
1139
|
+
{ err, hash, Category: 'sp00ky-client::Sp00kySync::runSyncForQuery' },
|
|
1140
|
+
'Failed to flush pending stream update before idle'
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
this.dataModule.endFetching(hash);
|
|
1083
1144
|
}
|
|
1084
1145
|
}
|
|
1085
1146
|
}
|
|
@@ -1114,6 +1175,12 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1114
1175
|
}
|
|
1115
1176
|
|
|
1116
1177
|
private async registerQuery(queryHash: string) {
|
|
1178
|
+
// Hold `fetching` across the WHOLE registration (remote view creation +
|
|
1179
|
+
// initial sync + post-sync notify). A query is born `fetching` in
|
|
1180
|
+
// createNewQuery; this refcounted cycle is what resolves it to `idle` — so
|
|
1181
|
+
// consumers (e.g. useQuery's `isSettled`) never see an idle query whose
|
|
1182
|
+
// window is still empty/partially materialized.
|
|
1183
|
+
this.dataModule.beginFetching(queryHash);
|
|
1117
1184
|
try {
|
|
1118
1185
|
this.logger.debug(
|
|
1119
1186
|
{ queryHash, Category: 'sp00ky-client::Sp00kySync::registerQuery' },
|
|
@@ -1121,8 +1188,10 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1121
1188
|
);
|
|
1122
1189
|
await this.createRemoteQuery(queryHash);
|
|
1123
1190
|
await this.syncQuery(queryHash);
|
|
1124
|
-
//
|
|
1125
|
-
// where no stream updates fire but the UI needs to
|
|
1191
|
+
// Land any still-debounced stream result, then always notify — handles
|
|
1192
|
+
// empty result sets where no stream updates fire but the UI needs to
|
|
1193
|
+
// stop loading.
|
|
1194
|
+
await this.dataModule.flushPendingStreamUpdate(queryHash);
|
|
1126
1195
|
await this.dataModule.notifyQuerySynced(queryHash);
|
|
1127
1196
|
} catch (e) {
|
|
1128
1197
|
this.logger.error(
|
|
@@ -1130,6 +1199,8 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1130
1199
|
'registerQuery error'
|
|
1131
1200
|
);
|
|
1132
1201
|
throw e;
|
|
1202
|
+
} finally {
|
|
1203
|
+
this.dataModule.endFetching(queryHash);
|
|
1133
1204
|
}
|
|
1134
1205
|
}
|
|
1135
1206
|
|
|
@@ -71,7 +71,7 @@ export class LocalMigrator {
|
|
|
71
71
|
{ Category: 'sp00ky-client::LocalMigrator::provision' },
|
|
72
72
|
`[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`
|
|
73
73
|
);
|
|
74
|
-
await this.localDb.
|
|
74
|
+
await this.localDb.queryUngated(statement);
|
|
75
75
|
this.logger.info(
|
|
76
76
|
{ Category: 'sp00ky-client::LocalMigrator::provision' },
|
|
77
77
|
`[Provisioning] (${i + 1}/${statements.length}) Done`
|
|
@@ -90,7 +90,7 @@ export class LocalMigrator {
|
|
|
90
90
|
|
|
91
91
|
private async isSchemaUpToDate(hash: string): Promise<boolean> {
|
|
92
92
|
try {
|
|
93
|
-
const [lastSchemaRecord] = await this.localDb.
|
|
93
|
+
const [lastSchemaRecord] = await this.localDb.queryUngated<any>(
|
|
94
94
|
`SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`
|
|
95
95
|
);
|
|
96
96
|
return lastSchemaRecord?.hash === hash;
|
|
@@ -101,13 +101,13 @@ export class LocalMigrator {
|
|
|
101
101
|
|
|
102
102
|
private async recreateDatabase(database: string) {
|
|
103
103
|
try {
|
|
104
|
-
await this.localDb.
|
|
104
|
+
await this.localDb.queryUngated(`DEFINE DATABASE _00_temp;`);
|
|
105
105
|
} catch (_e) {
|
|
106
106
|
// Ignore if exists
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
try {
|
|
110
|
-
await this.localDb.
|
|
110
|
+
await this.localDb.queryUngated(`
|
|
111
111
|
USE DB _00_temp;
|
|
112
112
|
REMOVE DATABASE ${database};
|
|
113
113
|
`);
|
|
@@ -115,7 +115,7 @@ export class LocalMigrator {
|
|
|
115
115
|
// Ignore error if database doesn't exist
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
await this.localDb.
|
|
118
|
+
await this.localDb.queryUngated(`
|
|
119
119
|
DEFINE DATABASE ${database};
|
|
120
120
|
USE DB ${database};
|
|
121
121
|
`);
|
|
@@ -195,7 +195,7 @@ export class LocalMigrator {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
private async createHashRecord(hash: string) {
|
|
198
|
-
await this.localDb.
|
|
198
|
+
await this.localDb.queryUngated(
|
|
199
199
|
`UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`,
|
|
200
200
|
{ hash }
|
|
201
201
|
);
|
|
@@ -31,3 +31,34 @@ describe('isLocalStoreOpenError', () => {
|
|
|
31
31
|
expect(isLocalStoreOpenError(undefined)).toBe(false);
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
// Per-user local buckets: URL/name derivation and the SCOPE of the tier-2
|
|
36
|
+
// corruption drop. The drop must only ever match the failing bucket's store —
|
|
37
|
+
// a substring wipe would take every user's cache AND their un-pushed mutation
|
|
38
|
+
// outboxes down with one corrupt store.
|
|
39
|
+
import { bucketStoreName, bucketStoreUrl, matchesBucketStore } from './local';
|
|
40
|
+
|
|
41
|
+
describe('bucket store naming', () => {
|
|
42
|
+
it('derives the store url/name from the bucket id', () => {
|
|
43
|
+
expect(bucketStoreUrl('abc')).toBe('indxdb://sp00ky-abc');
|
|
44
|
+
expect(bucketStoreName('abc')).toBe('sp00ky-abc');
|
|
45
|
+
expect(bucketStoreUrl('anon')).toBe('indxdb://sp00ky-anon');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('matchesBucketStore', () => {
|
|
50
|
+
it('matches the exact store and derived names', () => {
|
|
51
|
+
expect(matchesBucketStore('sp00ky-abc', 'sp00ky-abc')).toBe(true);
|
|
52
|
+
expect(matchesBucketStore('sp00ky-abc-wal', 'sp00ky-abc')).toBe(true);
|
|
53
|
+
expect(matchesBucketStore('surrealdb/sp00ky-abc', 'sp00ky-abc')).toBe(true);
|
|
54
|
+
expect(matchesBucketStore('SP00KY-ABC', 'sp00ky-abc')).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("never matches another bucket's store", () => {
|
|
58
|
+
expect(matchesBucketStore('sp00ky-abcdef', 'sp00ky-abc')).toBe(false);
|
|
59
|
+
expect(matchesBucketStore('sp00ky-anon', 'sp00ky-abc')).toBe(false);
|
|
60
|
+
expect(matchesBucketStore('sp00ky', 'sp00ky-abc')).toBe(false);
|
|
61
|
+
// The legacy shared store is not any bucket's store.
|
|
62
|
+
expect(matchesBucketStore('sp00ky', 'sp00ky-anon')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|