@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/AGENTS.md +2 -2
- package/dist/index.d.ts +28 -5
- package/dist/index.js +285 -64
- package/dist/otel/index.d.ts +1 -1
- package/dist/pure.d.ts +16 -0
- package/dist/types.d.ts +42 -11
- 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/router.ts +1 -1
- package/src/client/services.ts +6 -5
- package/src/client/sp00ky-client.ts +2 -2
- package/src/kernel/events.ts +2 -2
- package/src/kernel/interpreter.ts +3 -3
- package/src/modules/crdt/crdt.test.ts +52 -6
- package/src/modules/crdt/index.ts +59 -23
- package/src/query/membership.saga.test.ts +73 -1
- package/src/query/membership.saga.ts +39 -5
- package/src/query/membership.test.ts +33 -0
- package/src/query/membership.ts +33 -0
- package/src/services/database/remote.ts +6 -0
- package/src/sync/live.saga.test.ts +51 -0
- package/src/sync/live.saga.ts +17 -8
- package/src/types.ts +37 -10
- package/src/utils/edges.ts +19 -1
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
|
-
*
|
|
1595
|
-
*
|
|
1596
|
-
*
|
|
1597
|
-
*
|
|
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
|
|
1636
|
-
*
|
|
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.
|
|
1643
|
-
*
|
|
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 {
|
|
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.
|
|
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.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
-
|
|
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/router.ts
CHANGED
|
@@ -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':
|
package/src/client/services.ts
CHANGED
|
@@ -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,
|
|
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 ??
|
|
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
|
-
|
|
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,
|
|
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() {
|
package/src/kernel/events.ts
CHANGED
|
@@ -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': {
|