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

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/AGENTS.md CHANGED
@@ -35,7 +35,7 @@ re-materialized from state, never patched in place.
35
35
 
36
36
  - `Sp00kyClient<S>` — main class. Methods: `init()`, `create(id, payload)`, `update(table, id, payload, options?)`, `delete(table, id)`, `query(table, opts?)`, `preload(query, { signal? })`, `run(backend, route, payload)`, `bucket(name)`, `remoteQuery(sql, vars?)`, `useRemote(fn)`, `authenticate(token)`, `auth.signOut()`. Admin impersonation (`impersonation.enabled` in sp00ky.yml, admins only, server-enforced): `auth.impersonate(targetId, reason)`, `auth.stopImpersonating()`, `auth.impersonation`, `auth.subscribeImpersonation(cb)`, `auth.searchImpersonationTargets(search)`; while active the client injects a warning bar with a Stop button and shifts the page down onto a hazard-striped layer. `impersonationBanner` in the client config restyles it (`theme`), replaces it (`mode: 'custom'` + `client.acknowledgeImpersonationBanner()`, else it falls back after ~2.5s) or turns it off (`mode: 'none'`). See `/docs/client/impersonation`. Traffic indicators: `pendingMutationCount` / `subscribeToPendingMutations(cb)` (outbox depth), `fetchingQueryCount` / `subscribeToFetchActivity(cb)` (queries a user waits on; a query that already shows rows and is only re-registering, as after a reload, is not counted). Failed-writes tray: `failedMutationCount`, `subscribeToFailedMutations(cb)`, `listFailedMutations()`, `retryFailedMutation(id)`, `discardFailedMutation(id)`.
37
37
  - `FailedMutation` — a row of the `_00_failed_mutations` tray (`mutationType`, `recordId`, `data`, `beforeRecord`, `error`, `revert`).
38
- - `BucketHandle` — file storage handle (`put`, `get`, `delete`, `exists`), plus the cache-aware `read`, `url`, `pin`/`unpin`, `evict`, `prefetch`. `get` is always remote; `read`/`url` go through the blob cache.
38
+ - `BucketHandle` — file storage handle (`put`, `get`, `delete`, `exists`), plus the cache-aware `read`, `url`, `pin`/`unpin`, `evict`, `prefetch`. `get` is always remote; `read`/`url` go through the blob cache. A bucket generated with `backend: 'r2'` (`BACKEND "r2"`) sends its bytes to the SSP over HTTP (`<files.endpoint>/bucket/<name>/<path>`, bearer = the session token from `RemoteDatabaseService.getAuthToken()`) instead of `f"bucket:/path"` statements; the SSP checks `fn::_00_bucket::<name>` as the user first.
39
39
  - `services/blobs/` — durable cache for bucket bytes: OPFS holds the files, `_00_blob` holds a manifest that reconcile rebuilds from disk (so a wiped local store costs metadata, not the offline cache). Nothing expires on a timer; eviction is LRU under a byte budget only, skipping pinned and on-screen entries. Config: `blobCache` in `types.ts`.
40
40
  - `AuthService` — token management, sign-in/sign-out events. `subscribe(cb, { immediate? })` calls `cb` at once with the current user unless `immediate: false`, then on every change.
41
41
  - `CrdtManager`, `CrdtField`, `cursorColorFromName`, `CURSOR_COLORS`: collaborative `@crdt` fields on Loro. Client methods: `openCrdtField(table, recordId, field, draft?)` -> `Promise<CrdtField>` (every open of one field in a tab shares one `LoroDoc`; pair each with a close), `closeCrdtField(table, recordId, field)` -> `Promise<void>` (resolves once unsent edits are in the outbox; offline, once they are saved on the device, never waiting on the network), `createCrdtDraft(table, field)` -> a detached `CrdtField` for a record not created yet (`db.create` first, then pass it to `openCrdtField` as `draft`). `CrdtField`: `getDoc()`, `hasContent()`, `pushCursorState(bytes)`, `onCursorUpdate = cb` (a setter). Config: `crdt: true` preloads Loro, `crdtDebounceMs` (default 300, max wait 2 s). Design: `docs/crdt-v2.md` at the repo root.
package/dist/index.d.ts CHANGED
@@ -1985,13 +1985,21 @@ interface BucketHandleSettings {
1985
1985
  logger?: {
1986
1986
  warn: (obj: unknown, msg?: string) => void;
1987
1987
  };
1988
+ /** The bucket's backend as generated (`'r2'`: files live with the SSP). */
1989
+ backend?: string;
1990
+ /** {@link Sp00kyConfig.files}: where `r2` bucket files are read and written. */
1991
+ files?: {
1992
+ endpoint?: string;
1993
+ };
1988
1994
  }
1995
+ /** A handle's settings from the client config and the bucket's generated schema. */
1996
+
1989
1997
  declare class BucketHandle {
1990
1998
  private bucketName;
1991
- private remote;
1992
1999
  /** Absent on the raw handle the cache itself reads through. */
1993
2000
  private blobs?;
1994
2001
  private settings?;
2002
+ private io;
1995
2003
  constructor(bucketName: string, remote: RemoteDatabaseService, /** Absent on the raw handle the cache itself reads through. */
1996
2004
  blobs?: (BlobCache | null) | undefined, settings?: BucketHandleSettings | undefined);
1997
2005
  /** Effective blurhash setting: per-call option > client config > default ON. */
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.330";
2192
- const WASM_VERSION = "0.0.1-canary.330";
2191
+ const CORE_VERSION = "0.0.1-canary.331";
2192
+ const WASM_VERSION = "0.0.1-canary.331";
2193
2193
  const SURREAL_VERSION = "3.1.0";
2194
2194
  var DevToolsService = class DevToolsService {
2195
2195
  version = CORE_VERSION;
@@ -8544,6 +8544,11 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
8544
8544
  setAuthToken(token) {
8545
8545
  this.authToken = token;
8546
8546
  }
8547
+ /** The token the session is signed in with (or the configured one), for
8548
+ * calls that leave the socket: `r2` bucket files go to the SSP over HTTP. */
8549
+ getAuthToken() {
8550
+ return this.authToken ?? this.getConfig().token ?? null;
8551
+ }
8547
8552
  /** Resolved reconnect tunables; the supervisor reads its own knobs here. */
8548
8553
  getReconnectConfig() {
8549
8554
  return this.reconnectConfig;
@@ -15150,6 +15155,122 @@ function bucketContentToBlob(content) {
15150
15155
  if (ArrayBuffer.isView(content)) return new Blob([content]);
15151
15156
  return null;
15152
15157
  }
15158
+ /** A handle's settings from the client config and the bucket's generated schema. */
15159
+ function bucketSettings(config, name, logger) {
15160
+ const backend = config.schema.buckets?.find((b) => b.name === name)?.backend;
15161
+ return {
15162
+ blurhash: config.blurhash,
15163
+ logger,
15164
+ backend,
15165
+ files: config.files
15166
+ };
15167
+ }
15168
+ /** `f"bucket:/path"` statements over the database connection. */
15169
+ function surrealIo(bucket, remote) {
15170
+ const file = (path) => `f"${bucket}:/${path}"`;
15171
+ return {
15172
+ async put(path, content) {
15173
+ await remote.query(`RETURN ${file(path)}.put($content);`, { content });
15174
+ },
15175
+ async get(path) {
15176
+ const [result] = await remote.query(`RETURN ${file(path)}.get();`);
15177
+ return result;
15178
+ },
15179
+ async delete(path) {
15180
+ await remote.query(`RETURN ${file(path)}.delete();`);
15181
+ },
15182
+ async exists(path) {
15183
+ const [result] = await remote.query(`RETURN ${file(path)}.exists();`);
15184
+ return result;
15185
+ },
15186
+ async head(path) {
15187
+ const [result] = await remote.query(`RETURN ${file(path)}.head();`);
15188
+ return result;
15189
+ },
15190
+ async copy(sourcePath, targetPath) {
15191
+ await remote.query(`RETURN ${file(sourcePath)}.copy($target);`, { target: targetPath });
15192
+ },
15193
+ async rename(sourcePath, targetPath) {
15194
+ await remote.query(`RETURN ${file(sourcePath)}.rename($target);`, { target: targetPath });
15195
+ },
15196
+ async list(prefix) {
15197
+ const [result] = await remote.query(`RETURN ${file(prefix)}.list();`);
15198
+ return result;
15199
+ }
15200
+ };
15201
+ }
15202
+ /**
15203
+ * `BACKEND "r2"` buckets: the SSP keeps the files (R2 on Cloudflare, a
15204
+ * directory under `spky dev`) and serves them at `/bucket/<name>/<key>`,
15205
+ * after asking SurrealDB, as the signed-in user, whether the bucket's
15206
+ * PERMISSIONS clause allows it. The request carries the session's token.
15207
+ */
15208
+ function sspIo(bucket, remote, endpoint) {
15209
+ const base = () => {
15210
+ if (!endpoint) throw new Error(`Bucket '${bucket}' is stored with the SSP (BACKEND "r2"): set \`files.endpoint\` in the client config to the SSP's URL.`);
15211
+ return endpoint.replace(/\/+$/, "");
15212
+ };
15213
+ const url = (path) => `${base()}/bucket/${encodeURIComponent(bucket)}/${path.split("/").filter(Boolean).map(encodeURIComponent).join("/")}`;
15214
+ const send = async (method, path, body, contentType) => {
15215
+ const headers = {};
15216
+ const token = remote.getAuthToken();
15217
+ if (token) headers.Authorization = `Bearer ${token}`;
15218
+ if (contentType) headers["Content-Type"] = contentType;
15219
+ const response = await fetch(url(path), {
15220
+ method,
15221
+ headers,
15222
+ body,
15223
+ credentials: "omit"
15224
+ });
15225
+ if (!response.ok && response.status !== 404) {
15226
+ let message = response.statusText;
15227
+ try {
15228
+ message = (await response.json()).error ?? message;
15229
+ } catch {}
15230
+ throw new Error(`Bucket '${bucket}' ${method} ${path} failed (${response.status}): ${message}`);
15231
+ }
15232
+ return response;
15233
+ };
15234
+ const op = async (path, body) => {
15235
+ const response = await send("POST", path, JSON.stringify(body), "application/json");
15236
+ if (response.status === 404) return null;
15237
+ return await response.json();
15238
+ };
15239
+ const missing = (path) => /* @__PURE__ */ new Error(`Bucket '${bucket}': no file at ${path}`);
15240
+ return {
15241
+ async put(path, content) {
15242
+ await send("PUT", path, content, (content instanceof Blob ? content.type : void 0) || "application/octet-stream");
15243
+ },
15244
+ async get(path) {
15245
+ const response = await send("GET", path);
15246
+ return response.status === 404 ? null : await response.blob();
15247
+ },
15248
+ async delete(path) {
15249
+ await send("DELETE", path);
15250
+ },
15251
+ async exists(path) {
15252
+ return await op(path, { op: "exists" }) ?? false;
15253
+ },
15254
+ async head(path) {
15255
+ return await op(path, { op: "head" });
15256
+ },
15257
+ async copy(sourcePath, targetPath) {
15258
+ if (await op(sourcePath, {
15259
+ op: "copy",
15260
+ target: targetPath
15261
+ }) === null) throw missing(sourcePath);
15262
+ },
15263
+ async rename(sourcePath, targetPath) {
15264
+ if (await op(sourcePath, {
15265
+ op: "rename",
15266
+ target: targetPath
15267
+ }) === null) throw missing(sourcePath);
15268
+ },
15269
+ async list() {
15270
+ throw new Error(`Bucket '${bucket}': listing is not available (BACKEND "r2")`);
15271
+ }
15272
+ };
15273
+ }
15153
15274
  /**
15154
15275
  * Paths known to have no blurhash sidecar, per tab. The blob cache has no
15155
15276
  * negative caching, so without this every mount of a hashless image would pay
@@ -15158,11 +15279,12 @@ function bucketContentToBlob(content) {
15158
15279
  */
15159
15280
  const missingBlurhash = /* @__PURE__ */ new Set();
15160
15281
  var BucketHandle = class {
15282
+ io;
15161
15283
  constructor(bucketName, remote, blobs, settings) {
15162
15284
  this.bucketName = bucketName;
15163
- this.remote = remote;
15164
15285
  this.blobs = blobs;
15165
15286
  this.settings = settings;
15287
+ this.io = settings?.backend === "r2" ? sspIo(bucketName, remote, settings.files?.endpoint) : surrealIo(bucketName, remote);
15166
15288
  }
15167
15289
  /** Effective blurhash setting: per-call option > client config > default ON. */
15168
15290
  resolveBlurhash(option) {
@@ -15173,7 +15295,7 @@ var BucketHandle = class {
15173
15295
  async put(path, content, options) {
15174
15296
  const encodeOptions = this.resolveBlurhash(options?.blurhash);
15175
15297
  const hashPromise = encodeOptions && isImagePath(path) ? encodeImageToBlurhash(content, encodeOptions) : Promise.resolve(null);
15176
- await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
15298
+ await this.io.put(path, content);
15177
15299
  await this.blobs?.invalidate({
15178
15300
  bucket: this.bucketName,
15179
15301
  path
@@ -15183,7 +15305,7 @@ var BucketHandle = class {
15183
15305
  hash = await hashPromise;
15184
15306
  if (hash) {
15185
15307
  const sidecar = blurhashSidecarPath(path);
15186
- await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".put($content);`, { content: hash });
15308
+ await this.io.put(sidecar, hash);
15187
15309
  await this.blobs?.invalidate({
15188
15310
  bucket: this.bucketName,
15189
15311
  path: sidecar
@@ -15231,8 +15353,7 @@ var BucketHandle = class {
15231
15353
  }
15232
15354
  }
15233
15355
  async get(path) {
15234
- const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".get();`);
15235
- return result;
15356
+ return this.io.get(path);
15236
15357
  }
15237
15358
  /**
15238
15359
  * Read through the local blob cache: OPFS first, the bucket second. Unlike
@@ -15286,7 +15407,7 @@ var BucketHandle = class {
15286
15407
  })));
15287
15408
  }
15288
15409
  async delete(path) {
15289
- await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
15410
+ await this.io.delete(path);
15290
15411
  await this.blobs?.invalidate({
15291
15412
  bucket: this.bucketName,
15292
15413
  path
@@ -15294,7 +15415,7 @@ var BucketHandle = class {
15294
15415
  if (isImagePath(path)) {
15295
15416
  const sidecar = blurhashSidecarPath(path);
15296
15417
  try {
15297
- await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".delete();`);
15418
+ await this.io.delete(sidecar);
15298
15419
  await this.blobs?.invalidate({
15299
15420
  bucket: this.bucketName,
15300
15421
  path: sidecar
@@ -15304,23 +15425,19 @@ var BucketHandle = class {
15304
15425
  }
15305
15426
  }
15306
15427
  async exists(path) {
15307
- const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".exists();`);
15308
- return result;
15428
+ return this.io.exists(path);
15309
15429
  }
15310
15430
  async head(path) {
15311
- const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".head();`);
15312
- return result;
15431
+ return this.io.head(path);
15313
15432
  }
15314
15433
  async copy(sourcePath, targetPath) {
15315
- await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".copy($target);`, { target: targetPath });
15434
+ await this.io.copy(sourcePath, targetPath);
15316
15435
  }
15317
15436
  async rename(sourcePath, targetPath) {
15318
- await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".rename($target);`, { target: targetPath });
15437
+ await this.io.rename(sourcePath, targetPath);
15319
15438
  }
15320
15439
  async list(prefix) {
15321
- const p = prefix ?? "";
15322
- const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${p}".list();`);
15323
- return result;
15440
+ return this.io.list(prefix ?? "");
15324
15441
  }
15325
15442
  };
15326
15443
 
@@ -15405,10 +15522,7 @@ function createServices(config) {
15405
15522
  const local = createLocalEngine(config.localEngine, config.database, logger, { shared: tabsSupport.supported });
15406
15523
  const remote = new RemoteDatabaseService(config.database, logger);
15407
15524
  const connectionSupervisor = new ConnectionSupervisor(remote, logger);
15408
- const rawBucket = (name) => new BucketHandle(name, remote, null, {
15409
- blurhash: config.blurhash,
15410
- logger
15411
- });
15525
+ const rawBucket = (name) => new BucketHandle(name, remote, null, bucketSettings(config, name, logger));
15412
15526
  const blobs = createBlobCache({
15413
15527
  local,
15414
15528
  namespace: ANON_USER_ID,
@@ -15453,7 +15567,7 @@ function createTabsCoordinator(config, s, host) {
15453
15567
  return new TabsCoordinator({
15454
15568
  tabId: s.tabId,
15455
15569
  fingerprint: computeTabsFingerprint({
15456
- coreVersion: "0.0.1-canary.330",
15570
+ coreVersion: "0.0.1-canary.331",
15457
15571
  schemaHash: hash53(config.schemaSurql),
15458
15572
  endpoint: config.database.endpoint ?? "",
15459
15573
  namespace: config.database.namespace,
@@ -16564,10 +16678,7 @@ var Sp00kyClient = class {
16564
16678
  return this.services.crdt.close(table, recordId, field);
16565
16679
  }
16566
16680
  bucket(name) {
16567
- return new BucketHandle(name, this.services.remote, this.services.blobs, {
16568
- blurhash: this.config.blurhash,
16569
- logger: this.services.logger
16570
- });
16681
+ return new BucketHandle(name, this.services.remote, this.services.blobs, bucketSettings(this.config, name, this.services.logger));
16571
16682
  }
16572
16683
  getBlobCacheStats() {
16573
16684
  return this.services.blobs.stats();
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. */
@@ -1707,6 +1710,16 @@ interface Sp00kyConfig<S extends SchemaStructure> {
1707
1710
  componentX?: number;
1708
1711
  componentY?: number;
1709
1712
  };
1713
+ /**
1714
+ * Buckets declared `BACKEND "r2"` keep their files with the SSP (R2 on
1715
+ * Cloudflare, a directory under `spky dev`) instead of in SurrealDB, which
1716
+ * on Surreal Cloud can only hold memory buckets. `endpoint` is the SSP's
1717
+ * URL: `spky dev` and `spky deploy` hand it to Vite as
1718
+ * `VITE_SPKY_FILES_ENDPOINT`.
1719
+ */
1720
+ files?: {
1721
+ endpoint?: string;
1722
+ };
1710
1723
  /**
1711
1724
  * Deadline (ms) for a single outgoing mutation push. Tighter than
1712
1725
  * {@link Sp00kyConfig.database.queryTimeoutMs} because the up-queue drains
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.331",
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.331",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.331",
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
 
@@ -32,7 +32,7 @@ import { sha256Hex } from '../utils/sha256';
32
32
  import { leaveViewsBeacon, listRefLive } from '../query/sql';
33
33
  import { 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,
@@ -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() {
@@ -222,6 +222,12 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
222
222
  this.authToken = token;
223
223
  }
224
224
 
225
+ /** The token the session is signed in with (or the configured one), for
226
+ * calls that leave the socket: `r2` bucket files go to the SSP over HTTP. */
227
+ getAuthToken(): string | null {
228
+ return this.authToken ?? this.getConfig().token ?? null;
229
+ }
230
+
225
231
  /** Resolved reconnect tunables; the supervisor reads its own knobs here. */
226
232
  getReconnectConfig(): Required<ReconnectConfig> {
227
233
  return this.reconnectConfig;
package/src/types.ts CHANGED
@@ -371,6 +371,14 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
371
371
  * A per-call `put(path, content, { blurhash })` option overrides this.
372
372
  */
373
373
  blurhash?: boolean | { componentX?: number; componentY?: number };
374
+ /**
375
+ * Buckets declared `BACKEND "r2"` keep their files with the SSP (R2 on
376
+ * Cloudflare, a directory under `spky dev`) instead of in SurrealDB, which
377
+ * on Surreal Cloud can only hold memory buckets. `endpoint` is the SSP's
378
+ * URL: `spky dev` and `spky deploy` hand it to Vite as
379
+ * `VITE_SPKY_FILES_ENDPOINT`.
380
+ */
381
+ files?: { endpoint?: string };
374
382
  /**
375
383
  * Deadline (ms) for a single outgoing mutation push. Tighter than
376
384
  * {@link Sp00kyConfig.database.queryTimeoutMs} because the up-queue drains