@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 +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +138 -27
- package/dist/types.d.ts +13 -0
- package/package.json +3 -3
- package/src/client/bucket-handle.test.ts +78 -0
- package/src/client/bucket-handle.ts +156 -31
- package/src/client/services.ts +2 -2
- package/src/client/sp00ky-client.ts +2 -2
- package/src/services/database/remote.ts +6 -0
- package/src/types.ts +8 -0
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.
|
|
2192
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
15308
|
-
return result;
|
|
15428
|
+
return this.io.exists(path);
|
|
15309
15429
|
}
|
|
15310
15430
|
async head(path) {
|
|
15311
|
-
|
|
15312
|
-
return result;
|
|
15431
|
+
return this.io.head(path);
|
|
15313
15432
|
}
|
|
15314
15433
|
async copy(sourcePath, targetPath) {
|
|
15315
|
-
await this.
|
|
15434
|
+
await this.io.copy(sourcePath, targetPath);
|
|
15316
15435
|
}
|
|
15317
15436
|
async rename(sourcePath, targetPath) {
|
|
15318
|
-
await this.
|
|
15437
|
+
await this.io.rename(sourcePath, targetPath);
|
|
15319
15438
|
}
|
|
15320
15439
|
async list(prefix) {
|
|
15321
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
215
|
-
target: targetPath,
|
|
216
|
-
});
|
|
345
|
+
await this.io.rename(sourcePath, targetPath);
|
|
217
346
|
}
|
|
218
347
|
|
|
219
348
|
async list(prefix?: string): Promise<string[]> {
|
|
220
|
-
|
|
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
|
|
package/src/client/services.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|