@spooky-sync/core 0.0.1-canary.323 → 0.0.1-canary.324

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/types.d.ts CHANGED
@@ -1580,11 +1580,11 @@ interface Sp00kyConfig<S extends SchemaStructure> {
1580
1580
  streamDebounceTime?: number;
1581
1581
  /**
1582
1582
  * Debounce time in milliseconds for syncing collaborative (CRDT) field
1583
- * changes to the remote database. Local writes happen immediately on
1584
- * every keystroke (so reload/offline works), but the remote UPSERT is
1585
- * coalesced over this window. Lower = snappier remote propagation +
1586
- * more network traffic; higher = less traffic + more lag for other
1587
- * collaborators. Defaults to 500ms.
1583
+ * edits to the server. Local state is kept on every change (so reload and
1584
+ * offline work), but a field's own new ops go out as one edit-log row per
1585
+ * pause of this length (at most 2 s apart while typing continues).
1586
+ * Lower = snappier propagation and more rows; higher = fewer rows and more
1587
+ * lag for other collaborators. Defaults to 300ms.
1588
1588
  */
1589
1589
  crdtDebounceMs?: number;
1590
1590
  /**
@@ -1642,7 +1642,8 @@ interface Sp00kyConfig<S extends SchemaStructure> {
1642
1642
  * page gets live `useQuery` updates over world-readable tables. Requires the
1643
1643
  * server to be deployed with `anonymousLiveQueries: true` in `sp00ky.yml`
1644
1644
  * (this flag must match it). Defaults to `false`: anonymous clients can read
1645
- * one-shot but never sync live.
1645
+ * one-shot but never sync live, and a `preload` made while signed out returns
1646
+ * at once rather than wait for membership that never comes.
1646
1647
  */
1647
1648
  enableAnonymousLiveQueries?: boolean;
1648
1649
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.323",
3
+ "version": "0.0.1-canary.324",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -75,13 +75,13 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@spooky-sync/query-builder": "0.0.1-canary.323",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.323",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.324",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.324",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.0.3",
82
82
  "blurhash": "^2.0.5",
83
83
  "fast-json-patch": "^3.1.1",
84
- "loro-crdt": "^1.5.6",
84
+ "loro-crdt": "1.11.0",
85
85
  "pino": "^10.1.0",
86
86
  "surrealdb": "2.1.0"
87
87
  },
@@ -21,6 +21,7 @@ describe('preload', () => {
21
21
  });
22
22
  it('never resolved: waits for settled (membership + bodies + first render), or fails when the registration failed', async () => {
23
23
  const hash = await sha256Hex(queryHashInput(input, null));
24
+ const signedIn = buildState([], R.setIdentity({ userId: 'user:alice' }));
24
25
  const settleIt = (ctx: any) => {
25
26
  ctx.state = R.compose(
26
27
  R.commitMembership(hash, [['thing:1', 1]], true),
@@ -29,10 +30,12 @@ describe('preload', () => {
29
30
  )(ctx.state);
30
31
  };
31
32
  const out = await runPure(preload(env, input), {
33
+ state: signedIn,
32
34
  handlers: { 'local.getById': () => null, 'ssp.register': sspOk, 'state.wait': (_e, ctx) => settleIt(ctx) },
33
35
  });
34
36
  expect(out.result).toEqual({ hash, waited: true });
35
37
  const failed = runPure(preload(env, input), {
38
+ state: signedIn,
36
39
  handlers: {
37
40
  'local.getById': () => null,
38
41
  'ssp.register': sspOk,
@@ -41,6 +44,7 @@ describe('preload', () => {
41
44
  });
42
45
  await expect(failed).rejects.toBeInstanceOf(PreloadFailedError);
43
46
  const gone = runPure(preload(env, input), {
47
+ state: signedIn,
44
48
  handlers: { 'local.getById': () => null, 'ssp.register': sspOk, 'state.wait': (_e, ctx) => void (ctx.state = R.removeQuery(hash)(ctx.state)) },
45
49
  });
46
50
  await expect(gone).rejects.toThrow(/could not settle/);
@@ -48,4 +52,29 @@ describe('preload', () => {
48
52
  const cached = await runPure(preload(env, input), { state: already });
49
53
  expect(cached.result.waited).toBe(false);
50
54
  });
55
+ it('signed out without anonymous live queries: registers but never waits, since no membership can reach this session', async () => {
56
+ const out = await runPure(preload(env, input), {
57
+ handlers: { 'local.getById': () => null, 'ssp.register': sspOk },
58
+ });
59
+ expect(out.result.waited).toBe(false);
60
+ expect(out.log.filter((e) => e.kind === 'state.wait')).toHaveLength(0);
61
+ expect(out.log.some((e) => e.kind === 'dispatch' && (e as any).event?.type === 'EnsureRegistered')).toBe(true);
62
+
63
+ // With anonymous live queries a signed-out session is sent its membership
64
+ // on `_00_list_ref_anon`, so it waits like a signed-in one.
65
+ const hash = await sha256Hex(queryHashInput(input, null));
66
+ const anon = await runPure(preload(defaultEnv(env.schema, { anonLive: true }), input), {
67
+ handlers: {
68
+ 'local.getById': () => null,
69
+ 'ssp.register': sspOk,
70
+ 'state.wait': (_e, ctx) =>
71
+ void (ctx.state = R.compose(
72
+ R.commitMembership(hash, [['thing:1', 1]], true),
73
+ R.setVersions([['thing:1', 1]]),
74
+ R.setRecords(hash, [{ id: 'thing:1' }], true, 1)
75
+ )(ctx.state)),
76
+ },
77
+ });
78
+ expect(anon.result).toEqual({ hash, waited: true });
79
+ });
51
80
  });
@@ -3,6 +3,7 @@ import type { Saga } from '../kernel/saga';
3
3
  import { fx } from '../kernel/effects';
4
4
  import { settleFailed, settled } from '../state/selectors';
5
5
  import type { SagaEnv } from '../query/env';
6
+ import { receivesMembership } from '../query/env';
6
7
  import type { RegisterInput } from '../query/register.saga';
7
8
  import { registerLocal } from '../query/register.saga';
8
9
 
@@ -19,11 +20,19 @@ export class PreloadFailedError extends Error {
19
20
  * Never resolved: blocks until the server's membership and every body are
20
21
  * local and the first materialization ran. The entry is evicted like any
21
22
  * other query a ttl after it was last watched (or registered).
23
+ *
24
+ * A session that cannot be sent membership (signed out, without anonymous
25
+ * live queries) does not wait either: a view with rows would never settle,
26
+ * and an app that gates its first paint on a preload showed its loading
27
+ * screen forever, signed-out landing page included.
22
28
  */
23
29
  export function* preload(env: SagaEnv, input: RegisterInput): Saga<{ hash: QueryHash; waited: boolean }> {
24
30
  const hash = yield* registerLocal(env, input);
25
- const phase = (yield fx.state.read((s) => s.queries.get(hash)?.lifecycle.phase)) as string | undefined;
26
- if (phase !== 'cold') return { hash, waited: false };
31
+ const [phase, reachable] = (yield fx.state.read((s) => [s.queries.get(hash)?.lifecycle.phase, receivesMembership(env, s)])) as [
32
+ string | undefined,
33
+ boolean,
34
+ ];
35
+ if (phase !== 'cold' || !reachable) return { hash, waited: false };
27
36
  yield fx.state.wait((s) => settled(s, hash) || settleFailed(s, hash));
28
37
  const failed = (yield fx.state.read((s) => settleFailed(s, hash))) as boolean;
29
38
  if (failed) throw new PreloadFailedError(hash);
@@ -176,7 +176,7 @@ export function createServices<S extends SchemaStructure>(config: Sp00kyConfig<S
176
176
  streamProcessor.configureCircuitPersistence(config.persistCircuit ?? config.localEngine === 'sqlite', config.circuitCheckpointMs);
177
177
  streamProcessor.configureProjection(config.circuitProjection ?? true);
178
178
  const migrator = new LocalMigrator(local, logger);
179
- const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 500);
179
+ const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 300);
180
180
  const auth = new AuthService(config.schema, remote, persistence, logger);
181
181
  const tabId = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
182
182
  return {
@@ -18,6 +18,7 @@ const flush = async (n = 6) => {
18
18
  function makeClient(over: Parameters<typeof fakeAdapters>[0] = {}) {
19
19
  const services = fakeServiceBundle<any>();
20
20
  const a = fakeAdapters({
21
+ ...over,
21
22
  services: {
22
23
  'hint.read': () => 'u1',
23
24
  'local.usesSurqlSchema': () => false,
@@ -26,7 +27,6 @@ function makeClient(over: Parameters<typeof fakeAdapters>[0] = {}) {
26
27
  'remote.connect': () => new Promise(() => {}),
27
28
  ...over.services,
28
29
  },
29
- ...over,
30
30
  });
31
31
  const runtime = new Runtime({ env: defaultEnv(schema, { materializeDebounceMs: 1 }), adapters: a.adapters, logger: services.logger, tabId: 'tab-test' });
32
32
  const client = new Sp00kyClient<any>(config, { services, runtime });
@@ -155,7 +155,10 @@ describe('Sp00kyClient facade', () => {
155
155
 
156
156
  it('preload: cold waits for membership + bodies; cached returns at once; abort rejects', async () => {
157
157
  const edges = [{ out: new RecordId('thing', '1'), version: 1 }];
158
+ // Signed in: a signed-out session without anonymous live queries is never
159
+ // sent membership, so its preload does not wait (see `preload`).
158
160
  const { client, a } = makeClient({
161
+ services: { 'auth.restoreSession': async () => 'user:u1', 'auth.sessionAuthId': () => 'user:u1' },
159
162
  remote: {
160
163
  queryResponses: async (sql: string) => {
161
164
  if (sql.startsWith('fn::query::register')) return [ok(null), ok(edges), ok({ rowCount: 1, state: 'ready' }), ok([])];
@@ -142,6 +142,29 @@ export class Sp00kyClient<S extends SchemaStructure> {
142
142
  this.runtime.subscribe(hash, cb, options),
143
143
  };
144
144
  const remoteRegisterPort = { enqueueDownEvent: () => void this.runtime.dispatch({ type: 'EnsureRegistered' }) };
145
+ // CRDT edit logs ride ordinary queries and the ordinary outbox.
146
+ s.crdt.attach({
147
+ watch: async (table, where, onRows) => {
148
+ const { hash } = await (this.query as any)(table, {}).where(where).build().run();
149
+ const stopRows = this.runtime.subscribe(hash, onRows, { immediate: true });
150
+ let known = false;
151
+ let idle = false;
152
+ let done!: () => void;
153
+ const settled = new Promise<void>((resolve) => (done = resolve));
154
+ const check = () => known && idle && done();
155
+ const stopAuthority = this.runtime.subscribeAuthority(hash, (k) => ((known = k), check()), { immediate: true });
156
+ const stopStatus = this.runtime.subscribeStatus(hash, (st) => ((idle = st === 'idle'), check()), { immediate: true });
157
+ return {
158
+ stop: () => {
159
+ stopRows();
160
+ stopAuthority();
161
+ stopStatus();
162
+ },
163
+ settled,
164
+ };
165
+ },
166
+ create: (id, data) => this.create(id, data),
167
+ });
145
168
  this.featureFlags = new FeatureFlagModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
146
169
  this.appReleases = new AppReleaseModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
147
170
  this.webPush = new WebPushModule({
@@ -567,12 +590,22 @@ export class Sp00kyClient<S extends SchemaStructure> {
567
590
  return this.appReleases.release(app, options);
568
591
  }
569
592
 
570
- async openCrdtField(table: string, recordId: string, field: string, fallbackText?: string): Promise<CrdtField> {
571
- return this.services.crdt.open(table, recordId, field, fallbackText);
593
+ /** Open a `@crdt` field for editing. `draft` (from {@link createCrdtDraft})
594
+ * hands over edits made before the record existed. */
595
+ async openCrdtField(table: string, recordId: string, field: string, draft?: CrdtField): Promise<CrdtField> {
596
+ return this.services.crdt.open(table, recordId, field, draft);
597
+ }
598
+
599
+ /** A detached `@crdt` document for a record not created yet; see
600
+ * {@link openCrdtField}. */
601
+ async createCrdtDraft(table: string, field: string): Promise<CrdtField> {
602
+ return this.services.crdt.createDraft(table, field);
572
603
  }
573
604
 
574
- closeCrdtField(table: string, recordId: string, field: string): void {
575
- this.services.crdt.close(table, recordId, field);
605
+ /** Close a `@crdt` field. The last close resolves once its unsent edits
606
+ * are queued in the outbox. */
607
+ closeCrdtField(table: string, recordId: string, field: string): Promise<void> {
608
+ return this.services.crdt.close(table, recordId, field);
576
609
  }
577
610
 
578
611
  bucket<B extends BucketNames<S>>(name: B): BucketHandle {
@@ -0,0 +1,75 @@
1
+ import type * as Loro from 'loro-crdt';
2
+
3
+ /**
4
+ * Which edit-log rows may enter a document.
5
+ *
6
+ * Every client and the SSP fold (`packages/ssp-node/src/crdt/acceptance.rs`)
7
+ * apply this exact rule before importing a row, so they import the same rows
8
+ * and converge on the same document: a row one side accepted and another
9
+ * refused would leave them different forever (a CRDT never forgets an op).
10
+ *
11
+ * - the blob decodes as a Loro **update** (a snapshot could smuggle in
12
+ * arbitrary history from any peer);
13
+ * - it is no larger than the column allows;
14
+ * - every op in it was made by the row's `peer`, which the database already
15
+ * checked belongs to the row's author (`_00_crdt_peer`).
16
+ *
17
+ * Loro's decoder can trap on crafted input with a valid checksum, so a raw
18
+ * header check rejects non-update blobs before decoding, and decoding runs
19
+ * under `try`.
20
+ */
21
+
22
+ export type Rejection =
23
+ | 'empty'
24
+ | 'too_large'
25
+ | 'bad_peer'
26
+ | 'header'
27
+ | 'undecodable'
28
+ | 'not_an_update'
29
+ | 'no_ops'
30
+ | 'foreign_peer';
31
+
32
+ export interface Accepted {
33
+ peer: `${number}`;
34
+ /** Exclusive end counter of the row's ops for `peer`. */
35
+ end: number;
36
+ }
37
+
38
+ export type Verdict = { ok: true; accepted: Accepted } | { ok: false; reason: Rejection };
39
+
40
+ /** The largest blob a row may carry (the generated column's cap). */
41
+ export const MAX_DELTA_BYTES = 256 * 1024;
42
+
43
+ const PEER = /^\d{1,20}$/;
44
+ /** `"loro"`: every Loro blob starts with it. */
45
+ const MAGIC = [0x6c, 0x6f, 0x72, 0x6f];
46
+ /** Header bytes 20..22 (big-endian) carry the encoding mode; 4 is an update. */
47
+ const MODE_UPDATES = 4;
48
+
49
+ export function check(
50
+ loro: Pick<typeof Loro, 'decodeImportBlobMeta'>,
51
+ blob: Uint8Array | undefined,
52
+ peer: unknown,
53
+ maxBytes = MAX_DELTA_BYTES
54
+ ): Verdict {
55
+ if (!blob || blob.length === 0) return { ok: false, reason: 'empty' };
56
+ if (blob.length > maxBytes) return { ok: false, reason: 'too_large' };
57
+ if (typeof peer !== 'string' || !PEER.test(peer)) return { ok: false, reason: 'bad_peer' };
58
+ if (blob.length < 22 || MAGIC.some((b, i) => blob[i] !== b)) return { ok: false, reason: 'header' };
59
+ if (((blob[20] << 8) | blob[21]) !== MODE_UPDATES) return { ok: false, reason: 'not_an_update' };
60
+ let meta: ReturnType<typeof Loro.decodeImportBlobMeta>;
61
+ try {
62
+ meta = loro.decodeImportBlobMeta(blob, true);
63
+ } catch {
64
+ return { ok: false, reason: 'undecodable' };
65
+ }
66
+ if (meta.mode !== 'update') return { ok: false, reason: 'not_an_update' };
67
+ const start = meta.partialStartVersionVector.toJSON();
68
+ const authors: Array<[string, number]> = [];
69
+ for (const [p, end] of meta.partialEndVersionVector.toJSON()) {
70
+ if (end > (start.get(p) ?? 0)) authors.push([String(p), end]);
71
+ }
72
+ if (authors.length === 0) return { ok: false, reason: 'no_ops' };
73
+ if (authors.length > 1 || BigInt(authors[0][0]) !== BigInt(peer)) return { ok: false, reason: 'foreign_peer' };
74
+ return { ok: true, accepted: { peer: peer as `${number}`, end: authors[0][1] } };
75
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Byte columns reach the client in several shapes depending on the engine and
3
+ * the path: `Uint8Array` (SQLite, a fresh local write), `ArrayBuffer` (the
4
+ * surrealdb SDK's CBOR decode), a typed-array view, or a `number[]` (bytes
5
+ * nested in a FLEXIBLE object on the SurrealDB-WASM engine). Normalize all of
6
+ * them; anything else is not bytes.
7
+ */
8
+ export function asBytes(value: unknown): Uint8Array | undefined {
9
+ if (value instanceof Uint8Array) return value;
10
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
11
+ if (ArrayBuffer.isView(value)) {
12
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
13
+ }
14
+ if (Array.isArray(value) && value.length > 0 && value.every((n) => typeof n === 'number')) {
15
+ return Uint8Array.from(value as number[]);
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ export function decodeBase64(b64: string): Uint8Array {
21
+ if (typeof atob === 'function') {
22
+ const binary = atob(b64);
23
+ const bytes = new Uint8Array(binary.length);
24
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
25
+ return bytes;
26
+ }
27
+ return new Uint8Array(Buffer.from(b64, 'base64'));
28
+ }
29
+
30
+ export function encodeBase64(bytes: Uint8Array): string {
31
+ if (typeof btoa === 'function') {
32
+ let binary = '';
33
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
34
+ return btoa(binary);
35
+ }
36
+ return Buffer.from(bytes).toString('base64');
37
+ }
38
+
39
+ /**
40
+ * A random Loro peer id below 2^53 (so it is exact as a JS number), as the
41
+ * decimal string Loro uses for `PeerID`. Each tab picks a new one per session
42
+ * and claims it in `_00_crdt_peer` before any of its edits are sent.
43
+ */
44
+ export function randomPeer(): `${number}` {
45
+ const words = new Uint32Array(2);
46
+ crypto.getRandomValues(words);
47
+ const high = words[0] & 0x1fffff; // 21 bits
48
+ const id = high * 0x100000000 + words[1];
49
+ return String(id === 0 ? 1 : id) as `${number}`;
50
+ }