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

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
@@ -733,6 +733,9 @@ declare class RemoteDatabaseService extends AbstractDatabaseService {
733
733
  * from-scratch connect reproduce that state.
734
734
  */
735
735
  setAuthToken(token: string | null): void;
736
+ /** The token the session is signed in with (or the configured one), for
737
+ * calls that leave the socket: `r2` bucket files go to the SSP over HTTP. */
738
+ getAuthToken(): string | null;
736
739
  /** Resolved reconnect tunables; the supervisor reads its own knobs here. */
737
740
  getReconnectConfig(): Required<ReconnectConfig>;
738
741
  /** Current transport state as reported by the SDK. */
@@ -1591,14 +1594,20 @@ interface Sp00kyConfig<S extends SchemaStructure> {
1591
1594
  */
1592
1595
  streamDebounceTime?: number;
1593
1596
  /**
1594
- * Debounce time in milliseconds for syncing collaborative (CRDT) field
1595
- * edits to the server. Local state is kept on every change (so reload and
1596
- * offline work), but a field's own new ops go out as one edit-log row per
1597
- * pause of this length (at most 2 s apart while typing continues).
1598
- * Lower = snappier propagation and more rows; higher = fewer rows and more
1599
- * lag for other collaborators. Defaults to 300ms.
1597
+ * How long in milliseconds the first edit to a collaborative (CRDT) field
1598
+ * after a quiet spell waits before it goes out, so a burst of keystrokes
1599
+ * travels as one edit-log row. Local state is kept on every change (so
1600
+ * reload and offline work). Defaults to 50ms.
1600
1601
  */
1601
1602
  crdtDebounceMs?: number;
1603
+ /**
1604
+ * The shortest gap in milliseconds between two edit-log rows of one CRDT
1605
+ * field: while someone keeps typing, their edits go out on this cadence
1606
+ * instead of waiting for a pause. Lower = other collaborators see the text
1607
+ * sooner and more rows are written; higher = fewer rows and more lag.
1608
+ * Defaults to 200ms.
1609
+ */
1610
+ crdtSendIntervalMs?: number;
1602
1611
  /**
1603
1612
  * Enable collaborative CRDT fields. When `true`, the `loro-crdt` engine is
1604
1613
  * preloaded at client startup (fetched as a separate chunk on page load) so
@@ -1632,15 +1641,16 @@ interface Sp00kyConfig<S extends SchemaStructure> {
1632
1641
  * back for it.
1633
1642
  *
1634
1643
  * The client holds exactly one live subscription, on its `_00_list_ref`
1635
- * table, and today it is a doorbell: the notification is reduced to the
1636
- * query hash and the row is then pulled with `SELECT * FROM $ids`. The edge
1644
+ * table. Without this, the notification names the row and the client then
1645
+ * pulls it with `SELECT * FROM $ids`. The edge
1637
1646
  * is a real graph edge, so `FETCH out` makes SurrealDB resolve the row into
1638
1647
  * the notification and the body fetch disappears.
1639
1648
  *
1640
1649
  * The push stays advisory: the body is landed exactly the way a fetched body
1641
1650
  * is (store write, circuit ingest, version recorded), so a notification the
1642
- * server drops costs nothing but the old round trip. Membership is still
1643
- * read back; only the body fetch is saved.
1651
+ * server drops costs nothing but the old round trip. A row joining a live
1652
+ * view is committed to membership from the notification either way (no
1653
+ * re-read); with this on, the body fetch is saved as well.
1644
1654
  *
1645
1655
  * Default `false`. The cost is wire bytes: one edit bumps the edge in every
1646
1656
  * view holding the row, and under `FETCH` each of those notifications
@@ -1707,6 +1717,16 @@ interface Sp00kyConfig<S extends SchemaStructure> {
1707
1717
  componentX?: number;
1708
1718
  componentY?: number;
1709
1719
  };
1720
+ /**
1721
+ * Buckets declared `BACKEND "r2"` keep their files with the SSP (R2 on
1722
+ * Cloudflare, a directory under `spky dev`) instead of in SurrealDB, which
1723
+ * on Surreal Cloud can only hold memory buckets. `endpoint` is the SSP's
1724
+ * URL: `spky dev` and `spky deploy` hand it to Vite as
1725
+ * `VITE_SPKY_FILES_ENDPOINT`.
1726
+ */
1727
+ files?: {
1728
+ endpoint?: string;
1729
+ };
1710
1730
  /**
1711
1731
  * Deadline (ms) for a single outgoing mutation push. Tighter than
1712
1732
  * {@link Sp00kyConfig.database.queryTimeoutMs} because the up-queue drains
@@ -1860,6 +1880,17 @@ interface InlineRow {
1860
1880
  version: number;
1861
1881
  record: Record<string, unknown>;
1862
1882
  }
1883
+ /**
1884
+ * A row that joined a query's view, or moved to a newer version, as one LIVE
1885
+ * notification on `_00_list_ref` reports it: the edge's `out` and `version`.
1886
+ * The client commits it to the query's membership straight away instead of
1887
+ * re-reading the edges (see `sync/live.saga.ts`).
1888
+ */
1889
+ interface EdgeDelta {
1890
+ hash: QueryHash;
1891
+ id: string;
1892
+ version: number;
1893
+ }
1863
1894
  type RecordVersionArray = Array<[string, number]>;
1864
1895
  /**
1865
1896
  * Represents the difference between two record version sets.
@@ -2162,4 +2193,4 @@ interface DebounceOptions {
2162
2193
  flushOnHide?: boolean;
2163
2194
  }
2164
2195
  //#endregion
2165
- export { WebPushSubscribeOptions as $, RunOptions as A, Logger$1 as At, SyncHealthStatus as B, SESSION_POLL_MS as Bt, QueryTimeToLive as C, SignOutHook as Ct, RecordVersionArray as D, createAuthEventSystem as Dt, ReconnectConfig as E, AuthEventTypes as Et, StorageHealth as F, IMPERSONATION_ACCESS as Ft, WebPushConfig as G, sameImpersonation as Gt, UpdateOptions as H, decodeTokenClaims as Ht, StorageHealthStatus as I, ImpersonationInfo as It, WebPushErrorCode as J, WebPushDevice as K, StoreType as L, ImpersonationTimerHost as Lt, Sp00kyConfig as M, SealedQuery as Mt, Sp00kyQueryResult as N, EventDefinition as Nt, RecordVersionDiff as O, RemoteDatabaseService as Ot, Sp00kyQueryResultPromise as P, EventSystem as Pt, WebPushModule as Q, SyncHealth as R, ImpersonationTimers as Rt, QueryStatusCallback as S, SignOutContext as St, QueryUpdateCallback as T, AuthEventTypeMap as Tt, PUSH_ENDPOINT_KEY as U, impersonationFromToken as Ut, TimingPhase as V, TokenClaims as Vt, PushDeviceKind as W, renewDelayMs as Wt, WebPushMessage as X, WebPushInfo as Y, WebPushMessageInput as Z, QueryConfig as _, isPushPayload as _t, Level$1 as a, WebPushUpdateOptions as at, QueryState as b, ImpersonationCandidate as bt, MutationCallback as c, BridgeTokenMessage as ct, PersistenceClient as d, PushNotificationAction as dt, WebPushSupport as et, PhaseStat as f, PushOp as ft, QueryAuthorityCallback as g, WebPushClientMessage as gt, PreloadRefresh as h, PushUrgency as ht, InlineRow as i, WebPushUnsupportedReason as it, ServerViewMeta as j, LocalStore as jt, RegistrationTimings as k, LogTap as kt, MutationEvent as l, PUSH_PAYLOAD_VERSION as lt, PreloadOptions as m, PushPayloadKind as mt, DebounceOptions as n, WebPushSyncStatus as nt, MATERIALIZATION_SAMPLE_WINDOW as o, detectWebPushSupport as ot, PinoTransmit as p, PushPayload as pt, WebPushError as q, EventSubscriptionOptions as r, WebPushUnsubscribeOptions as rt, MembershipOutcome as s, BridgeSignOutMessage as st, ConnectionState as t, WebPushSyncOptions as tt, MutationEventType as u, PushNotification as ut, QueryConfigRecord as v, ActiveImpersonation as vt, QueryTimings as w, AuthEventSystem as wt, QueryStatus as x, SIGN_OUT_HOOK_TIMEOUT_MS as xt, QueryHash as y, AuthService as yt, SyncHealthConfig as z, ORIGIN_TOKEN_KEY as zt };
2196
+ export { WebPushModule as $, RegistrationTimings as A, LogTap as At, SyncHealthConfig as B, ORIGIN_TOKEN_KEY as Bt, QueryStatusCallback as C, SignOutContext as Ct, ReconnectConfig as D, AuthEventTypes as Dt, QueryUpdateCallback as E, AuthEventTypeMap as Et, Sp00kyQueryResultPromise as F, EventSystem as Ft, PushDeviceKind as G, renewDelayMs as Gt, TimingPhase as H, TokenClaims as Ht, StorageHealth as I, IMPERSONATION_ACCESS as It, WebPushError as J, WebPushConfig as K, sameImpersonation as Kt, StorageHealthStatus as L, ImpersonationInfo as Lt, ServerViewMeta as M, LocalStore as Mt, Sp00kyConfig as N, SealedQuery as Nt, RecordVersionArray as O, createAuthEventSystem as Ot, Sp00kyQueryResult as P, EventDefinition as Pt, WebPushMessageInput as Q, StoreType as R, ImpersonationTimerHost as Rt, QueryStatus as S, SIGN_OUT_HOOK_TIMEOUT_MS as St, QueryTimings as T, AuthEventSystem as Tt, UpdateOptions as U, decodeTokenClaims as Ut, SyncHealthStatus as V, SESSION_POLL_MS as Vt, PUSH_ENDPOINT_KEY as W, impersonationFromToken as Wt, WebPushInfo as X, WebPushErrorCode as Y, WebPushMessage as Z, QueryAuthorityCallback as _, WebPushClientMessage as _t, InlineRow as a, WebPushUnsupportedReason as at, QueryHash as b, AuthService as bt, MembershipOutcome as c, BridgeSignOutMessage as ct, MutationEventType as d, PushNotification as dt, WebPushSubscribeOptions as et, PersistenceClient as f, PushNotificationAction as ft, PreloadRefresh as g, PushUrgency as gt, PreloadOptions as h, PushPayloadKind as ht, EventSubscriptionOptions as i, WebPushUnsubscribeOptions as it, RunOptions as j, Logger$1 as jt, RecordVersionDiff as k, RemoteDatabaseService as kt, MutationCallback as l, BridgeTokenMessage as lt, PinoTransmit as m, PushPayload as mt, DebounceOptions as n, WebPushSyncOptions as nt, Level$1 as o, WebPushUpdateOptions as ot, PhaseStat as p, PushOp as pt, WebPushDevice as q, EdgeDelta as r, WebPushSyncStatus as rt, MATERIALIZATION_SAMPLE_WINDOW as s, detectWebPushSupport as st, ConnectionState as t, WebPushSupport as tt, MutationEvent as u, PUSH_PAYLOAD_VERSION as ut, QueryConfig as v, isPushPayload as vt, QueryTimeToLive as w, SignOutHook as wt, QueryState as x, ImpersonationCandidate as xt, QueryConfigRecord as y, ActiveImpersonation as yt, SyncHealth as z, ImpersonationTimers as zt };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.330",
3
+ "version": "0.0.1-canary.332",
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.330",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.330",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.332",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.332",
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",
@@ -115,3 +115,81 @@ describe('BucketHandle', () => {
115
115
  expect(calls.some(([s]) => s === 'RETURN f"files:/pre/".list();')).toBe(true);
116
116
  });
117
117
  });
118
+
119
+ describe('r2 buckets (files with the SSP)', () => {
120
+ function fakeSsp(files: Record<string, string> = {}) {
121
+ const requests: Array<{ method: string; url: string; auth: string | null; body: unknown }> = [];
122
+ const fetchMock = vi.fn(async (url: string, init: RequestInit) => {
123
+ const headers = (init.headers ?? {}) as Record<string, string>;
124
+ requests.push({ method: init.method ?? 'GET', url, auth: headers.Authorization ?? null, body: init.body });
125
+ const key = decodeURIComponent(new URL(url).pathname.replace(/^\/bucket\/[^/]+\//, ''));
126
+ const json = (status: number, value: unknown) => new Response(JSON.stringify(value), { status });
127
+ if (headers.Authorization !== 'Bearer tok') return json(401, { error: 'invalid or expired token' });
128
+ switch (init.method) {
129
+ case 'PUT':
130
+ files[key] = typeof init.body === 'string' ? init.body : 'bytes';
131
+ return json(200, { ok: true });
132
+ case 'GET':
133
+ return key in files ? new Response(files[key], { status: 200 }) : json(404, { error: 'no such file' });
134
+ case 'DELETE':
135
+ delete files[key];
136
+ return json(200, { ok: true });
137
+ default: {
138
+ const op = JSON.parse(String(init.body));
139
+ if (op.op === 'exists') return json(200, key in files);
140
+ if (op.op === 'head') return json(200, key in files ? { key: `/${key}`, size: files[key].length } : null);
141
+ if (!(key in files)) return json(404, { error: 'no such file' });
142
+ files[op.target] = files[key];
143
+ if (op.op === 'rename') delete files[key];
144
+ return json(200, { ok: true });
145
+ }
146
+ }
147
+ });
148
+ vi.stubGlobal('fetch', fetchMock);
149
+ return { requests, files };
150
+ }
151
+
152
+ const remote = (token: string | null = 'tok') => ({ getAuthToken: () => token, query: vi.fn() }) as any;
153
+ const r2 = (r = remote()) =>
154
+ new BucketHandle('pics', r, null, { backend: 'r2', files: { endpoint: 'https://app.db.example/' }, blurhash: false });
155
+
156
+ it('reads and writes over HTTP with the session token, never the database socket', async () => {
157
+ const { requests, files } = fakeSsp();
158
+ const db = remote();
159
+ const h = r2(db);
160
+ await h.put('u1/a b.png', 'PNG');
161
+ expect(files['u1/a b.png']).toBe('PNG');
162
+ expect(requests[0]).toMatchObject({ method: 'PUT', url: 'https://app.db.example/bucket/pics/u1/a%20b.png', auth: 'Bearer tok' });
163
+ expect(await ((await h.get('u1/a b.png')) as Blob).text()).toBe('PNG');
164
+ expect(await h.get('missing.png')).toBeNull();
165
+ expect(await h.exists('u1/a b.png')).toBe(true);
166
+ expect(await h.head('u1/a b.png')).toEqual({ key: '/u1/a b.png', size: 3 });
167
+ await h.copy('u1/a b.png', 'u1/c.png');
168
+ await h.rename('u1/c.png', 'u1/d.png');
169
+ expect(Object.keys(files).sort()).toEqual(['u1/a b.png', 'u1/d.png']);
170
+ await expect(h.copy('nope.png', 'x.png')).rejects.toThrow(/no file/);
171
+ await h.delete('u1/a b.png');
172
+ expect(await h.exists('u1/a b.png')).toBe(false);
173
+ await expect(h.list()).rejects.toThrow(/listing/);
174
+ expect(db.query).not.toHaveBeenCalled();
175
+ vi.unstubAllGlobals();
176
+ });
177
+
178
+ it('surfaces refusals and a missing endpoint as errors', async () => {
179
+ fakeSsp({ 'a.png': 'x' });
180
+ await expect(r2(remote(null)).get('a.png')).rejects.toThrow(/401.*invalid or expired token/);
181
+ const noEndpoint = new BucketHandle('pics', remote(), null, { backend: 'r2', files: {} });
182
+ await expect(noEndpoint.get('a.png')).rejects.toThrow(/files\.endpoint/);
183
+ vi.unstubAllGlobals();
184
+ });
185
+
186
+ it('a bucket without backend r2 keeps using the database', async () => {
187
+ const db = { getAuthToken: () => 'tok', query: vi.fn(async () => ['content']) } as any;
188
+ const fetchMock = vi.fn();
189
+ vi.stubGlobal('fetch', fetchMock);
190
+ const h = new BucketHandle('pics', db, null, { files: { endpoint: 'https://app.db.example' } });
191
+ expect(await h.get('a.png')).toBe('content');
192
+ expect(fetchMock).not.toHaveBeenCalled();
193
+ vi.unstubAllGlobals();
194
+ });
195
+ });
@@ -34,6 +34,143 @@ export interface BucketPutResult {
34
34
  interface BucketHandleSettings {
35
35
  blurhash?: BlurhashSetting;
36
36
  logger?: { warn: (obj: unknown, msg?: string) => void };
37
+ /** The bucket's backend as generated (`'r2'`: files live with the SSP). */
38
+ backend?: string;
39
+ /** {@link Sp00kyConfig.files}: where `r2` bucket files are read and written. */
40
+ files?: { endpoint?: string };
41
+ }
42
+
43
+ /** A handle's settings from the client config and the bucket's generated schema. */
44
+ export function bucketSettings(
45
+ config: { blurhash?: BlurhashSetting; files?: { endpoint?: string }; schema: { buckets?: readonly { name: string; backend?: string }[] } },
46
+ name: string,
47
+ logger: { warn: (obj: unknown, msg?: string) => void }
48
+ ): BucketHandleSettings {
49
+ const backend = config.schema.buckets?.find((b) => b.name === name)?.backend;
50
+ return { blurhash: config.blurhash, logger, backend, files: config.files };
51
+ }
52
+
53
+ /** Where a bucket's bytes go: SurrealDB itself, or the SSP for `r2` buckets. */
54
+ interface BucketIo {
55
+ put(path: string, content: string | Uint8Array | Blob): Promise<void>;
56
+ /** The file, or null when it does not exist. */
57
+ get(path: string): Promise<unknown>;
58
+ delete(path: string): Promise<void>;
59
+ exists(path: string): Promise<boolean>;
60
+ head(path: string): Promise<Record<string, unknown>>;
61
+ copy(sourcePath: string, targetPath: string): Promise<void>;
62
+ rename(sourcePath: string, targetPath: string): Promise<void>;
63
+ list(prefix: string): Promise<string[]>;
64
+ }
65
+
66
+ /** `f"bucket:/path"` statements over the database connection. */
67
+ function surrealIo(bucket: string, remote: RemoteDatabaseService): BucketIo {
68
+ const file = (path: string) => `f"${bucket}:/${path}"`;
69
+ return {
70
+ async put(path, content) {
71
+ await remote.query(`RETURN ${file(path)}.put($content);`, { content });
72
+ },
73
+ async get(path) {
74
+ const [result] = await remote.query<[unknown]>(`RETURN ${file(path)}.get();`);
75
+ return result;
76
+ },
77
+ async delete(path) {
78
+ await remote.query(`RETURN ${file(path)}.delete();`);
79
+ },
80
+ async exists(path) {
81
+ const [result] = await remote.query<[boolean]>(`RETURN ${file(path)}.exists();`);
82
+ return result;
83
+ },
84
+ async head(path) {
85
+ const [result] = await remote.query<[Record<string, unknown>]>(`RETURN ${file(path)}.head();`);
86
+ return result;
87
+ },
88
+ async copy(sourcePath, targetPath) {
89
+ await remote.query(`RETURN ${file(sourcePath)}.copy($target);`, { target: targetPath });
90
+ },
91
+ async rename(sourcePath, targetPath) {
92
+ await remote.query(`RETURN ${file(sourcePath)}.rename($target);`, { target: targetPath });
93
+ },
94
+ async list(prefix) {
95
+ const [result] = await remote.query<[string[]]>(`RETURN ${file(prefix)}.list();`);
96
+ return result;
97
+ },
98
+ };
99
+ }
100
+
101
+ /**
102
+ * `BACKEND "r2"` buckets: the SSP keeps the files (R2 on Cloudflare, a
103
+ * directory under `spky dev`) and serves them at `/bucket/<name>/<key>`,
104
+ * after asking SurrealDB, as the signed-in user, whether the bucket's
105
+ * PERMISSIONS clause allows it. The request carries the session's token.
106
+ */
107
+ function sspIo(bucket: string, remote: RemoteDatabaseService, endpoint: string | undefined): BucketIo {
108
+ const base = () => {
109
+ if (!endpoint) {
110
+ throw new Error(
111
+ `Bucket '${bucket}' is stored with the SSP (BACKEND "r2"): set \`files.endpoint\` in the client config to the SSP's URL.`
112
+ );
113
+ }
114
+ return endpoint.replace(/\/+$/, '');
115
+ };
116
+ const url = (path: string) =>
117
+ `${base()}/bucket/${encodeURIComponent(bucket)}/${path
118
+ .split('/')
119
+ .filter(Boolean)
120
+ .map(encodeURIComponent)
121
+ .join('/')}`;
122
+ const send = async (method: string, path: string, body?: BodyInit, contentType?: string): Promise<Response> => {
123
+ const headers: Record<string, string> = {};
124
+ const token = remote.getAuthToken();
125
+ if (token) headers.Authorization = `Bearer ${token}`;
126
+ if (contentType) headers['Content-Type'] = contentType;
127
+ const response = await fetch(url(path), { method, headers, body, credentials: 'omit' });
128
+ if (!response.ok && response.status !== 404) {
129
+ let message = response.statusText;
130
+ try {
131
+ message = ((await response.json()) as { error?: string }).error ?? message;
132
+ } catch {
133
+ // Not JSON: keep the status text.
134
+ }
135
+ throw new Error(`Bucket '${bucket}' ${method} ${path} failed (${response.status}): ${message}`);
136
+ }
137
+ return response;
138
+ };
139
+ const op = async <T>(path: string, body: Record<string, unknown>): Promise<T | null> => {
140
+ const response = await send('POST', path, JSON.stringify(body), 'application/json');
141
+ if (response.status === 404) return null;
142
+ return (await response.json()) as T;
143
+ };
144
+ const missing = (path: string) => new Error(`Bucket '${bucket}': no file at ${path}`);
145
+ return {
146
+ async put(path, content) {
147
+ const type = content instanceof Blob ? content.type : undefined;
148
+ await send('PUT', path, content as BodyInit, type || 'application/octet-stream');
149
+ },
150
+ async get(path) {
151
+ const response = await send('GET', path);
152
+ return response.status === 404 ? null : await response.blob();
153
+ },
154
+ async delete(path) {
155
+ await send('DELETE', path);
156
+ },
157
+ async exists(path) {
158
+ return (await op<boolean>(path, { op: 'exists' })) ?? false;
159
+ },
160
+ async head(path) {
161
+ return (await op<Record<string, unknown>>(path, { op: 'head' })) as Record<string, unknown>;
162
+ },
163
+ async copy(sourcePath, targetPath) {
164
+ if ((await op(sourcePath, { op: 'copy', target: targetPath })) === null) throw missing(sourcePath);
165
+ },
166
+ async rename(sourcePath, targetPath) {
167
+ if ((await op(sourcePath, { op: 'rename', target: targetPath })) === null) throw missing(sourcePath);
168
+ },
169
+ async list() {
170
+ // SurrealDB refuses `list` to record users; the SSP does too.
171
+ throw new Error(`Bucket '${bucket}': listing is not available (BACKEND "r2")`);
172
+ },
173
+ };
37
174
  }
38
175
 
39
176
  /**
@@ -45,13 +182,20 @@ interface BucketHandleSettings {
45
182
  const missingBlurhash = new Set<string>();
46
183
 
47
184
  export class BucketHandle {
185
+ private io: BucketIo;
186
+
48
187
  constructor(
49
188
  private bucketName: string,
50
- private remote: RemoteDatabaseService,
189
+ remote: RemoteDatabaseService,
51
190
  /** Absent on the raw handle the cache itself reads through. */
52
191
  private blobs?: BlobCache | null,
53
192
  private settings?: BucketHandleSettings
54
- ) {}
193
+ ) {
194
+ this.io =
195
+ settings?.backend === 'r2'
196
+ ? sspIo(bucketName, remote, settings.files?.endpoint)
197
+ : surrealIo(bucketName, remote);
198
+ }
55
199
 
56
200
  /** Effective blurhash setting: per-call option > client config > default ON. */
57
201
  private resolveBlurhash(option?: BlurhashSetting): BlurhashEncodeOptions | null {
@@ -73,7 +217,7 @@ export class BucketHandle {
73
217
  ? encodeImageToBlurhash(content, encodeOptions)
74
218
  : Promise.resolve(null);
75
219
 
76
- await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
220
+ await this.io.put(path, content);
77
221
  // A path can be overwritten, so anything cached under it is now wrong.
78
222
  await this.blobs?.invalidate({ bucket: this.bucketName, path });
79
223
 
@@ -84,9 +228,7 @@ export class BucketHandle {
84
228
  hash = await hashPromise;
85
229
  if (hash) {
86
230
  const sidecar = blurhashSidecarPath(path);
87
- await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".put($content);`, {
88
- content: hash,
89
- });
231
+ await this.io.put(sidecar, hash);
90
232
  await this.blobs?.invalidate({ bucket: this.bucketName, path: sidecar });
91
233
  missingBlurhash.delete(`${this.bucketName}:${path}`);
92
234
  }
@@ -128,10 +270,7 @@ export class BucketHandle {
128
270
  }
129
271
 
130
272
  async get(path: string): Promise<unknown> {
131
- const [result] = await this.remote.query<[unknown]>(
132
- `RETURN f"${this.bucketName}:/${path}".get();`
133
- );
134
- return result;
273
+ return this.io.get(path);
135
274
  }
136
275
 
137
276
  /**
@@ -174,14 +313,14 @@ export class BucketHandle {
174
313
  }
175
314
 
176
315
  async delete(path: string): Promise<void> {
177
- await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
316
+ await this.io.delete(path);
178
317
  await this.blobs?.invalidate({ bucket: this.bucketName, path });
179
318
  // Symmetry with put: an image's blurhash sidecar dies with it. Best-effort,
180
319
  // the sidecar may simply not exist.
181
320
  if (isImagePath(path)) {
182
321
  const sidecar = blurhashSidecarPath(path);
183
322
  try {
184
- await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".delete();`);
323
+ await this.io.delete(sidecar);
185
324
  await this.blobs?.invalidate({ bucket: this.bucketName, path: sidecar });
186
325
  } catch {
187
326
  // Nothing to clean up.
@@ -191,37 +330,23 @@ export class BucketHandle {
191
330
  }
192
331
 
193
332
  async exists(path: string): Promise<boolean> {
194
- const [result] = await this.remote.query<[boolean]>(
195
- `RETURN f"${this.bucketName}:/${path}".exists();`
196
- );
197
- return result;
333
+ return this.io.exists(path);
198
334
  }
199
335
 
200
336
  async head(path: string): Promise<Record<string, unknown>> {
201
- const [result] = await this.remote.query<[Record<string, unknown>]>(
202
- `RETURN f"${this.bucketName}:/${path}".head();`
203
- );
204
- return result;
337
+ return this.io.head(path);
205
338
  }
206
339
 
207
340
  async copy(sourcePath: string, targetPath: string): Promise<void> {
208
- await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".copy($target);`, {
209
- target: targetPath,
210
- });
341
+ await this.io.copy(sourcePath, targetPath);
211
342
  }
212
343
 
213
344
  async rename(sourcePath: string, targetPath: string): Promise<void> {
214
- await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".rename($target);`, {
215
- target: targetPath,
216
- });
345
+ await this.io.rename(sourcePath, targetPath);
217
346
  }
218
347
 
219
348
  async list(prefix?: string): Promise<string[]> {
220
- const p = prefix ?? '';
221
- const [result] = await this.remote.query<[string[]]>(
222
- `RETURN f"${this.bucketName}:/${p}".list();`
223
- );
224
- return result;
349
+ return this.io.list(prefix ?? '');
225
350
  }
226
351
  }
227
352
 
@@ -77,7 +77,7 @@ export function route(env: SagaEnv, event: RuntimeEvent): RouteTarget {
77
77
  case 'LiveStart':
78
78
  return { saga: liveStart(env), lane: serial('live') };
79
79
  case 'LiveChange':
80
- return { saga: liveChange(env, event.hashes, event.rows), lane: serial('live-change') };
80
+ return { saga: liveChange(env, event.hashes, event.rows, event.deltas), lane: serial('live-change') };
81
81
  case 'TabRole':
82
82
  return { saga: setRole(env, event.role), lane: serial('tabs') };
83
83
  case 'TabMessage':
@@ -30,9 +30,9 @@ import type { ServiceCalls } from '../kernel/effects';
30
30
  import { encodeRecordId, mintOwnerSecret } from '../utils/index';
31
31
  import { sha256Hex } from '../utils/sha256';
32
32
  import { leaveViewsBeacon, listRefLive } from '../query/sql';
33
- import { hashOfEdge, rowOfEdge } from '../utils/edges';
33
+ import { deltaOfEdge, hashOfEdge, rowOfEdge } from '../utils/edges';
34
34
  import { mintMutationId } from '../mutation/mutation-id';
35
- import { BucketHandle, bucketContentToBlob } from './bucket-handle';
35
+ import { BucketHandle, bucketContentToBlob, bucketSettings } from './bucket-handle';
36
36
 
37
37
  const LAST_BUCKET_KEY = 'sp00ky:last_bucket';
38
38
 
@@ -157,7 +157,7 @@ export function createServices<S extends SchemaStructure>(config: Sp00kyConfig<S
157
157
  const local = createLocalEngine(config.localEngine, config.database, logger, { shared: tabsSupport.supported });
158
158
  const remote = new RemoteDatabaseService(config.database, logger);
159
159
  const connectionSupervisor = new ConnectionSupervisor(remote, logger);
160
- const rawBucket = (name: string) => new BucketHandle(name, remote, null, { blurhash: config.blurhash, logger });
160
+ const rawBucket = (name: string) => new BucketHandle(name, remote, null, bucketSettings(config, name, logger));
161
161
  const blobs = createBlobCache({
162
162
  local,
163
163
  namespace: ANON_USER_ID,
@@ -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 ?? 300);
179
+ const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 50, config.crdtSendIntervalMs ?? 200);
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 {
@@ -395,7 +395,8 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
395
395
  // that was actually deleted the join yields nothing anyway. Landing
396
396
  // a body there would only ever write back what is going away.
397
397
  const row = message.action === 'DELETE' ? null : rowOfEdge(message.value);
398
- onChange([hash], row ? [row] : undefined);
398
+ const delta = deltaOfEdge(message.action, message.value);
399
+ onChange([hash], row ? [row] : undefined, delta ? [delta] : undefined);
399
400
  });
400
401
  return String(uuid);
401
402
  },
@@ -54,7 +54,7 @@ import { preload } from '../boot/preload.saga';
54
54
  import { DEGRADE_AFTER_FAILURES, OUTBOX_BATCH_SIZE, PUSH_TIMEOUT_MS } from '../kernel/constants';
55
55
  import { Runtime } from './runtime';
56
56
  import { createAdapters, createServices, createTabsCoordinator, type ServiceHost, type Services } from './services';
57
- import { BucketHandle } from './bucket-handle';
57
+ import { BucketHandle, bucketSettings } from './bucket-handle';
58
58
  import { ImpersonationBanner } from '../modules/auth/impersonation-banner';
59
59
  import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
60
60
 
@@ -615,7 +615,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
615
615
  }
616
616
 
617
617
  bucket<B extends BucketNames<S>>(name: B): BucketHandle {
618
- return new BucketHandle(name, this.services.remote, this.services.blobs, { blurhash: this.config.blurhash, logger: this.services.logger });
618
+ return new BucketHandle(name, this.services.remote, this.services.blobs, bucketSettings(this.config, name, this.services.logger));
619
619
  }
620
620
 
621
621
  getBlobCacheStats() {
@@ -1,4 +1,4 @@
1
- import type { ConnectionState, InlineRow, QueryHash, QueryStatus, SyncHealth } from '../types';
1
+ import type { ConnectionState, EdgeDelta, InlineRow, QueryHash, QueryStatus, SyncHealth } from '../types';
2
2
  import type { StreamUpdate } from '../services/stream-processor/index';
3
3
 
4
4
  /**
@@ -36,7 +36,7 @@ export type RuntimeEvent =
36
36
  | { type: 'ConnectionChanged'; state: ConnectionState }
37
37
  | { type: 'StreamUpdate'; update: StreamUpdate }
38
38
  | { type: 'LiveStart' }
39
- | { type: 'LiveChange'; hashes: QueryHash[]; rows?: InlineRow[] }
39
+ | { type: 'LiveChange'; hashes: QueryHash[]; rows?: InlineRow[]; deltas?: EdgeDelta[] }
40
40
  | { type: 'TabRole'; role: 'solo' | 'leader' | 'follower' }
41
41
  | { type: 'TabMessage'; message: unknown };
42
42
 
@@ -2,7 +2,7 @@ import { Duration } from 'surrealdb';
2
2
  import type { LocalStore } from '../services/database/cache-engine';
3
3
  import type { IngestRecord, StreamUpdate } from '../services/stream-processor/index';
4
4
  import type { ClientState } from '../state/client-state';
5
- import type { InlineRow } from '../types';
5
+ import type { EdgeDelta, InlineRow } from '../types';
6
6
  import { withTimeout } from '../utils/index';
7
7
  import type { Effect, IdScope, RegisterResult, ServiceCalls, Settled, StatementResult } from './effects';
8
8
  import type { OutEvent, RuntimeEvent } from './events';
@@ -17,7 +17,7 @@ export interface Adapters {
17
17
  * changed query hashes, plus the changed rows themselves when the
18
18
  * subscription was opened with the body joined on (`liveInlineBodies`).
19
19
  */
20
- live(table: string, onChange: (hashes: string[], rows?: InlineRow[]) => void): Promise<string>;
20
+ live(table: string, onChange: (hashes: string[], rows?: InlineRow[], deltas?: EdgeDelta[]) => void): Promise<string>;
21
21
  kill(uuid: string): Promise<void>;
22
22
  };
23
23
  ssp: {
@@ -81,7 +81,7 @@ export function createInterpreter(adapters: Adapters, host: InterpreterHost): In
81
81
  : pending;
82
82
  }
83
83
  case 'remote.live':
84
- return adapters.remote.live(effect.table, (hashes, rows) => host.dispatch({ type: 'LiveChange', hashes, rows }));
84
+ return adapters.remote.live(effect.table, (hashes, rows, deltas) => host.dispatch({ type: 'LiveChange', hashes, rows, deltas }));
85
85
  case 'remote.kill':
86
86
  return adapters.remote.kill(effect.uuid);
87
87
  case 'ssp.register': {