@super-line/collections-cloudflare 0.1.0 → 0.2.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  Generated from conventional commits by [git-cliff](https://git-cliff.org) —
4
4
  run `pnpm changelog` rather than editing by hand.
5
5
 
6
+ ## [collections-cloudflare-v0.2.0] — 2026-10-20
7
+
8
+ ### Features
9
+
10
+ - **collections-cloudflare:** A foreign object's SQLite as a live collection ([a9579af])
11
+
12
+ ### Bug Fixes
13
+
14
+ - **collections-cloudflare:** ReadOnly catches writes in every statement ([e391e8e])
15
+
6
16
  ## [collections-cloudflare-v0.1.0] — 2026-10-15
7
17
 
8
18
  ### Features
package/README.md CHANGED
@@ -38,7 +38,67 @@ A new optional field is altered in on the first operation. Any other change —
38
38
 
39
39
  The backend owns the `col_<name>` and `col_meta` tables in the object's storage.
40
40
 
41
+ ## Serve another object's tables
42
+
43
+ `syncedSql` and `remoteRows` serve the tables of *other* Durable Objects — sources whose tables are created at runtime by arbitrary SQL — as one live, read-only collection on the node. Declare it on the contract with `remoteRowsCollection` from the `/contract` subpath, which imports only `zod` and `@super-line/core`:
44
+
45
+ ```ts
46
+ import { remoteRowsCollection } from '@super-line/collections-cloudflare/contract'
47
+
48
+ export const contract = defineContract({ roles, plugins: [remoteRowsCollection('storeRows')] })
49
+ // rows: { key: `${storeId}:${table}:${id}`, storeId, table, id, data } — each row's columns opaque under `data`
50
+ ```
51
+
52
+ In the source, `syncedSql(ctx, { onChange })` captures every row change with SQLite triggers, and pushes each committed batch to the node. Route every write through its `query`, and expose its reads for the node:
53
+
54
+ ```ts
55
+ import { DurableObject } from 'cloudflare:workers'
56
+ import { syncedSql, type QueryOptions, type Statement } from '@super-line/collections-cloudflare'
57
+
58
+ export class Store extends DurableObject<Env> {
59
+ private sync = syncedSql(this.ctx, {
60
+ onChange: (push) => {
61
+ const [node, storeId] = this.ctx.id.name!.split(':') as [string, string]
62
+ return this.env.NODE.getByName(node).storeChanged(storeId, push)
63
+ },
64
+ })
65
+ query(statements: Statement[], opts?: QueryOptions) { return this.sync.query(statements, opts) }
66
+ snapshot(table: string, limit: number) { return this.sync.snapshot(table, limit) }
67
+ get(table: string, id: string) { return this.sync.get(table, id) }
68
+ changesSince(clock: number) { return this.sync.changesSince(clock) }
69
+ }
70
+ ```
71
+
72
+ On the node, `remoteRows` is a `self` backend fed by those pushes, served beside this package's own backend through `composeCollections` from `@super-line/server`:
73
+
74
+ ```ts
75
+ export class Node extends createDurableNode((ctx, env, base) => {
76
+ const storeRows = remoteRows({
77
+ collection: 'storeRows',
78
+ storage: ctx.storage,
79
+ stub: (storeId) => env.STORE.getByName(`${ctx.id.name}:${storeId}`),
80
+ onWipe: (storeId) => closeReadersOf(storeId), // conn.raw.close(4409, 'resync') → they resubscribe into a fresh snapshot
81
+ })
82
+ const { storeRows: _, ...owned } = contract.collections
83
+ const server = createSuperLineServer(contract, {
84
+ ...base,
85
+ authenticate,
86
+ policies,
87
+ collections: composeCollections({ default: cloudflareCollections(ctx.storage, owned), storeRows }),
88
+ })
89
+ void server.ready.then(() => storeRows.resume()) // a wake restores subscriptions without reading the source: catch every source up
90
+ return Object.assign(server, { storeRows })
91
+ }) {
92
+ storeChanged(storeId: string, push: RemotePush) {
93
+ return this.server.storeRows.ingest(storeId, push)
94
+ }
95
+ }
96
+ ```
97
+
98
+ A subscription must pin both `storeId` and `table` with `eq()`, and writes fail with `FORBIDDEN` — they go through the source's `query`. Dropping, renaming or altering a table, pruned deletes, a backlog over `maxPullChanges` or a reset source breaks that source's history: `onWipe` runs, and its subscribers resync. Store ids and table names must be colon-free. The source owns every `_sl_`-prefixed object in its SQLite.
99
+
41
100
  - 📖 Guide: [Host a node on Cloudflare Durable Objects](https://super-line.dogar.biz/how-to/cloudflare-durable-objects)
101
+ - 📖 Guide: [Serve another Durable Object's tables](https://super-line.dogar.biz/how-to/cloudflare-remote-rows)
42
102
  - 📚 Reference: [Backends & clustering](https://super-line.dogar.biz/collections/backends)
43
103
  - 🧩 Source: <https://github.com/mertdogar/super-line>
44
104
 
@@ -0,0 +1,66 @@
1
+ import * as _super_line_core from '@super-line/core';
2
+ import * as z from 'zod';
3
+
4
+ /** A cell on the wire: SQLite's own types, with a blob as base64 under `blob`. */
5
+ type Value = string | number | null | {
6
+ blob: string;
7
+ };
8
+ type Statement = {
9
+ sql: string;
10
+ params?: Value[];
11
+ };
12
+ type Result = {
13
+ columns: string[];
14
+ rows: Value[][];
15
+ meta: {
16
+ rowsRead: number;
17
+ rowsWritten: number;
18
+ };
19
+ };
20
+ /** `bytes` is set when the batch's rows passed `maxBytes`; the message then names the cap. */
21
+ type Outcome = {
22
+ results: Result[];
23
+ } | {
24
+ error: string;
25
+ statement: number;
26
+ bytes?: number;
27
+ };
28
+ /**
29
+ * `readOnly` rolls the batch back as soon as a statement writes a row or changes the schema; `maxBytes` rolls it
30
+ * back as soon as the serialized rows of the batch pass the cap.
31
+ */
32
+ type QueryOptions = {
33
+ readOnly?: boolean;
34
+ maxBytes?: number;
35
+ };
36
+ /** One row of a source's table, as the node serves it. `key` is `${storeId}:${table}:${id}`. */
37
+ declare const remoteRowSchema: z.ZodObject<{
38
+ key: z.ZodString;
39
+ storeId: z.ZodString;
40
+ table: z.ZodString;
41
+ id: z.ZodString;
42
+ data: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodNull, z.ZodObject<{
43
+ blob: z.ZodString;
44
+ }, z.core.$strip>]>>;
45
+ }, z.core.$strip>;
46
+ type RemoteRowsRow = z.infer<typeof remoteRowSchema>;
47
+ type RemoteRowsFragment<N extends string> = {
48
+ collections: {
49
+ [K in N]: {
50
+ schema: typeof remoteRowSchema;
51
+ key: 'key';
52
+ };
53
+ };
54
+ };
55
+ /**
56
+ * The contract half of `remoteRows`: one read-only collection `name` holding the rows of every source the node
57
+ * fronts, keyed `${storeId}:${table}:${id}`, with each row's columns opaque under `data`. Its own subpath, so a
58
+ * contract package doesn't pull in the Durable Object side.
59
+ *
60
+ * ```ts
61
+ * defineContract({ roles, plugins: [remoteRowsCollection('storeRows')] })
62
+ * ```
63
+ */
64
+ declare function remoteRowsCollection<const N extends string>(name: N): _super_line_core.ContractPlugin<RemoteRowsFragment<N>>;
65
+
66
+ export { type Outcome, type QueryOptions, type RemoteRowsRow, type Result, type Statement, type Value, remoteRowSchema, remoteRowsCollection };
@@ -0,0 +1,19 @@
1
+ // src/contract.ts
2
+ import * as z from "zod";
3
+ import { defineContractPlugin } from "@super-line/core";
4
+ var value = z.union([z.string(), z.number(), z.null(), z.object({ blob: z.string() })]);
5
+ var remoteRowSchema = z.object({
6
+ key: z.string(),
7
+ storeId: z.string(),
8
+ table: z.string(),
9
+ id: z.string(),
10
+ data: z.record(z.string(), value)
11
+ });
12
+ function remoteRowsCollection(name) {
13
+ const fragment = { collections: { [name]: { schema: remoteRowSchema, key: "key" } } };
14
+ return defineContractPlugin(`remote-rows:${name}`, fragment);
15
+ }
16
+ export {
17
+ remoteRowSchema,
18
+ remoteRowsCollection
19
+ };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,132 @@
1
- import { CollectionDef, RelayCollectionStore } from '@super-line/core';
1
+ import { SelfCollectionStore, CollectionDef, RelayCollectionStore } from '@super-line/core';
2
+ import { Value, Statement, QueryOptions, Outcome } from './contract.js';
3
+ export { Result } from './contract.js';
4
+ import 'zod';
5
+
6
+ /** One row of a user table: `id` is its single-column primary key, else its rowid, as a string. */
7
+ type RemoteRow = {
8
+ table: string;
9
+ id: string;
10
+ data: Record<string, Value>;
11
+ };
12
+ type RemoteChange = {
13
+ table: string;
14
+ id: string;
15
+ op: 'put';
16
+ data: Record<string, Value>;
17
+ } | {
18
+ table: string;
19
+ id: string;
20
+ op: 'delete';
21
+ };
22
+ /**
23
+ * `wipeAll`: the history since that clock is gone (pruned, a dropped, renamed or altered table, a clock from the
24
+ * future) or is longer than `maxPullChanges`; `changes` is then empty.
25
+ */
26
+ type RemoteChanges = {
27
+ epoch: string;
28
+ clock: number;
29
+ changes: RemoteChange[];
30
+ wipeAll: boolean;
31
+ };
32
+ /** `changes` is `undefined` when the batch was too large to inline or broke the history: pull `changesSince`. */
33
+ type RemotePush = {
34
+ epoch: string;
35
+ clock: number;
36
+ changes: RemoteChange[] | undefined;
37
+ };
38
+ interface SyncedSqlOptions {
39
+ /** Delete markers kept for `changesSince`; older ones are pruned and the history starts later. Default 1000. */
40
+ maxTombstones?: number;
41
+ /** Changes a push carries inline; a larger batch pushes the clock only. Default 500. */
42
+ maxInlineChanges?: number;
43
+ /** Changes `changesSince` answers with; a longer backlog answers `wipeAll` instead. Default 5000. */
44
+ maxPullChanges?: number;
45
+ /** Called after every batch that changed rows, once it committed. A throw or rejection is logged and dropped. */
46
+ onChange?: (push: RemotePush) => unknown;
47
+ }
48
+ interface SyncedSql {
49
+ query(statements: Statement[], opts?: QueryOptions): Outcome;
50
+ snapshot(table: string, limit: number): RemoteRow[];
51
+ get(table: string, id: string): RemoteRow | undefined;
52
+ changesSince(clock: number): RemoteChanges;
53
+ }
54
+ declare const READ_ONLY_MESSAGE = "Read-only: the statement wrote rows or changed the schema and was rolled back";
55
+ /**
56
+ * Change capture over a Durable Object's own SQLite, for an object whose tables are created at runtime by arbitrary
57
+ * SQL. `query` is the object's one write funnel: a batch runs in order inside one `transactionSync`, and the first
58
+ * failure rolls the whole batch back and reports SQLite's message.
59
+ *
60
+ * Every user table gets `AFTER INSERT/UPDATE/DELETE` triggers that log into `_sl_changes` at a clock that bumps once
61
+ * per write batch; a batch that changes the schema re-syncs them, and a table it created is logged whole. A row's id
62
+ * is its single-column primary key, else its rowid; a `WITHOUT ROWID` table with a composite key is skipped, with a
63
+ * warning. Dropping, renaming or altering the columns of a table breaks the history, so `changesSince` from before
64
+ * it says `wipeAll`, as it does past `maxTombstones` pruned deletes and for a backlog over `maxPullChanges`. The `_sl_` prefix is reserved, and the connection runs with
65
+ * `recursive_triggers` on, so a `REPLACE` that evicts a conflicting row logs its delete.
66
+ *
67
+ * ```ts
68
+ * private sync = syncedSql(this.ctx, { onChange: (push) => this.env.NODE.getByName('node').changed(push) })
69
+ * query(statements: Statement[], opts?: QueryOptions) { return this.sync.query(statements, opts) }
70
+ * ```
71
+ */
72
+ declare function syncedSql(ctx: DurableObjectState, opts?: SyncedSqlOptions): SyncedSql;
73
+
74
+ type Awaitable<T> = T | Promise<T>;
75
+ /** A source as the node reaches it: `syncedSql`'s reads, over RPC. A Durable Object stub satisfies it. */
76
+ interface RemoteSource {
77
+ snapshot(table: string, limit: number): Awaitable<RemoteRow[]>;
78
+ get(table: string, id: string): Awaitable<RemoteRow | undefined>;
79
+ changesSince(clock: number): Awaitable<RemoteChanges>;
80
+ }
81
+ /** Where the node keeps the last clock it applied per source. `ctx.storage` satisfies it. */
82
+ interface RemoteRowsStorage {
83
+ get<T>(key: string): Promise<T | undefined>;
84
+ put<T>(key: string, value: T): Promise<void>;
85
+ list<T>(options: {
86
+ prefix: string;
87
+ }): Promise<Map<string, T>>;
88
+ }
89
+ interface RemoteRowsOptions {
90
+ /** The contract collection, declared with `remoteRowsCollection(collection)`. */
91
+ collection: string;
92
+ storage: RemoteRowsStorage;
93
+ stub(storeId: string): RemoteSource;
94
+ /**
95
+ * The source's history broke (a dropped, renamed or altered table, pruned deletes, a reset source), so rows its
96
+ * subscribers hold may be stale. Close their connections (`conn.raw.close(4409, 'resync')`): the client reconnects
97
+ * and resubscribes into a fresh snapshot. Default: nothing.
98
+ */
99
+ onWipe?(storeId: string): unknown;
100
+ /** Rows a snapshot reads from the source table, at most. Default 1000. */
101
+ snapshotLimit?: number;
102
+ }
103
+ interface RemoteRowsStore extends SelfCollectionStore {
104
+ /** The forwarder's target: a source's `onChange` push. Applies it inline when it is the next clock, else pulls. */
105
+ ingest(storeId: string, push: RemotePush): Promise<void>;
106
+ /**
107
+ * Pull what these sources changed since their last clock — on wake, for the sources with surviving subscribers.
108
+ * With no ids, every source the node keeps a clock for.
109
+ */
110
+ resume(storeIds?: string[]): Promise<void>;
111
+ }
112
+ /**
113
+ * The node side of a foreign Durable Object's SQLite: a read-only `self` {@link SelfCollectionStore} serving the rows
114
+ * of every source the node fronts as one collection. The source pushes each committed batch to {@link
115
+ * RemoteRowsStore.ingest} (`syncedSql`'s `onChange`); the node keeps the last clock per source in `storage` and pulls
116
+ * `changesSince` when a push skips a clock, carries no changes or comes from a new epoch. A store's feed starts at
117
+ * its first subscribe, and every subscribe pulls first.
118
+ *
119
+ * A snapshot must pin `storeId` and `table` by equality (`VALIDATION` otherwise); the rest of the query runs on the
120
+ * rows read. Changes carry no prior row, so the server sends a data-less delete to a subscriber the new row doesn't
121
+ * match — unless its filter rules out the row's `storeId` or `table`, which never change for a key. Writes throw
122
+ * `FORBIDDEN`: they go through the source's own path.
123
+ *
124
+ * ```ts
125
+ * const rows = remoteRows({ collection: 'storeRows', storage: ctx.storage, stub: (id) => env.STORE.getByName(id) })
126
+ * createSuperLineServer(contract, { ...base, collections: composeCollections({ default, storeRows: rows }) })
127
+ * ```
128
+ */
129
+ declare function remoteRows(opts: RemoteRowsOptions): RemoteRowsStore;
2
130
 
3
131
  /**
4
132
  * The Durable Object SQLite RelayCollectionStore: every LWW collection on the contract gets its own typed table
@@ -16,4 +144,4 @@ import { CollectionDef, RelayCollectionStore } from '@super-line/core';
16
144
  */
17
145
  declare function cloudflareCollections(storage: DurableObjectStorage, collections: Record<string, CollectionDef>): RelayCollectionStore;
18
146
 
19
- export { cloudflareCollections };
147
+ export { Outcome, QueryOptions, READ_ONLY_MESSAGE, type RemoteChange, type RemoteChanges, type RemotePush, type RemoteRow, type RemoteRowsOptions, type RemoteRowsStorage, type RemoteRowsStore, type RemoteSource, Statement, type SyncedSql, type SyncedSqlOptions, Value, cloudflareCollections, remoteRows, syncedSql };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import { SuperLineError, applyQuery, isCrdtCollection, matchesFilter } from "@super-line/core";
2
+ import { SuperLineError as SuperLineError2, applyQuery as applyQuery2, isCrdtCollection, matchesFilter } from "@super-line/core";
3
3
 
4
4
  // ../collections-sqlite/src/sql.ts
5
5
  import { DEGENERATE_DATA_COLUMN, planColumns } from "@super-line/core";
@@ -228,6 +228,312 @@ function driftOf(t, stored) {
228
228
  return { alter };
229
229
  }
230
230
 
231
+ // src/synced-sql.ts
232
+ import { getLogger } from "@logtape/logtape";
233
+ var log = getLogger(["super-line", "collections-cloudflare", "synced-sql"]);
234
+ var READ_ONLY_MESSAGE = "Read-only: the statement wrote rows or changed the schema and was rolled back";
235
+ var MAX_BYTES = 16 * 1024 * 1024;
236
+ var INSTALL = [
237
+ "CREATE TABLE IF NOT EXISTS _sl_meta (epoch TEXT NOT NULL, clock INTEGER NOT NULL, historyStartsAt INTEGER NOT NULL)",
238
+ "CREATE TABLE IF NOT EXISTS _sl_changes (tbl TEXT NOT NULL, id, clock INTEGER NOT NULL, op TEXT NOT NULL, PRIMARY KEY (tbl, id))",
239
+ "CREATE INDEX IF NOT EXISTS _sl_changes_clock ON _sl_changes (op, clock)"
240
+ ];
241
+ var SIGNATURE_SQL = `SELECT group_concat(type || ' ' || name || ' ' || ifnull(sql, ''), char(10)) AS s FROM sqlite_master
242
+ WHERE tbl_name NOT GLOB '_sl_*'`;
243
+ var TRIGGERS_SQL = `SELECT name, tbl_name AS tbl FROM sqlite_master WHERE type = 'trigger' AND name GLOB '_sl_*'`;
244
+ var TRACKED_SQL = `SELECT r.name AS name, r.tbl_name AS tbl, t.sql AS sql FROM sqlite_master AS r
245
+ JOIN sqlite_master AS t ON t.type = 'table' AND t.name = r.tbl_name WHERE r.type = 'trigger' AND r.name GLOB '_sl_i_*'`;
246
+ var TABLES_SQL = `SELECT l.name AS name, l.wr AS wr,
247
+ (SELECT count(*) FROM pragma_table_info(l.name) WHERE pk > 0) AS pks,
248
+ (SELECT name FROM pragma_table_info(l.name) WHERE pk = 1) AS pk
249
+ FROM pragma_table_list() AS l
250
+ WHERE l.schema = 'main' AND l.type = 'table' AND l.name NOT GLOB 'sqlite_*' AND l.name NOT GLOB '_cf_*' AND l.name NOT GLOB '_sl_*'
251
+ AND l.name NOT GLOB '__miniflare_*'`;
252
+ var OPS = ["i", "u", "d"];
253
+ var triggerName = (op, table) => `_sl_${op}_${table}`;
254
+ var ident = (name) => `"${name.replaceAll('"', '""')}"`;
255
+ var literal = (text) => `'${text.replaceAll("'", "''")}'`;
256
+ var toBase64 = (buffer) => {
257
+ let s = "";
258
+ for (const byte of new Uint8Array(buffer)) s += String.fromCharCode(byte);
259
+ return btoa(s);
260
+ };
261
+ var fromBase64 = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0)).buffer;
262
+ var decode = (value) => value !== null && typeof value === "object" ? fromBase64(value.blob) : value;
263
+ var encode2 = (value) => value instanceof ArrayBuffer ? { blob: toBase64(value) } : value;
264
+ var idOf = (value) => value instanceof ArrayBuffer ? toBase64(value) : String(value);
265
+ var utf8 = new TextEncoder();
266
+ var ResultTooLarge = class extends Error {
267
+ constructor(bytes) {
268
+ super("The result is over the cap");
269
+ this.bytes = bytes;
270
+ }
271
+ bytes;
272
+ };
273
+ var record = (tbl, id, op, when = "") => `DELETE FROM _sl_changes WHERE tbl = ${tbl} AND id = ${id}; INSERT INTO _sl_changes (tbl, id, clock, op) SELECT ${tbl}, ${id}, clock + 1, '${op}' FROM _sl_meta${when};`;
274
+ var triggers = (table, key) => {
275
+ const tbl = literal(table);
276
+ const on = ident(table);
277
+ const [i, u, d] = OPS.map((op) => ident(triggerName(op, table)));
278
+ return [
279
+ `CREATE TRIGGER IF NOT EXISTS ${i} AFTER INSERT ON ${on} BEGIN ${record(tbl, `NEW.${key}`, "put")} END`,
280
+ `CREATE TRIGGER IF NOT EXISTS ${u} AFTER UPDATE ON ${on} BEGIN ${record(tbl, `OLD.${key}`, "delete", ` WHERE OLD.${key} IS NOT NEW.${key}`)} ${record(tbl, `NEW.${key}`, "put")} END`,
281
+ `CREATE TRIGGER IF NOT EXISTS ${d} AFTER DELETE ON ${on} BEGIN ${record(tbl, `OLD.${key}`, "delete")} END`
282
+ ];
283
+ };
284
+ function syncedSql(ctx, opts = {}) {
285
+ const { maxTombstones = 1e3, maxInlineChanges = 500, maxPullChanges = 5e3, onChange } = opts;
286
+ const storage = ctx.storage;
287
+ const sql = storage.sql;
288
+ const rows = (query, ...params) => sql.exec(query, ...params).toArray();
289
+ const count = (query, ...params) => sql.exec(query, ...params).one().n;
290
+ const meta = () => sql.exec("SELECT * FROM _sl_meta").one();
291
+ const signature = () => sql.exec(SIGNATURE_SQL).one().s;
292
+ const totalChanges = () => count("SELECT total_changes() AS n");
293
+ const userTables = () => {
294
+ const tables = /* @__PURE__ */ new Map();
295
+ for (const t of rows(TABLES_SQL)) {
296
+ tables.set(t.name, t.pks === 1 && t.pk !== null ? ident(t.pk) : t.wr === 0 ? "rowid" : void 0);
297
+ }
298
+ return tables;
299
+ };
300
+ const tracked = () => {
301
+ const tables = /* @__PURE__ */ new Map();
302
+ for (const { name, tbl, sql: create } of rows(TRACKED_SQL))
303
+ if (name === triggerName("i", tbl)) tables.set(tbl, create);
304
+ return tables;
305
+ };
306
+ const sync = (before, backfillAt) => {
307
+ for (const { name, tbl } of rows(TRIGGERS_SQL)) {
308
+ if (/^_sl_[iud]_/.test(name) && !OPS.some((op) => triggerName(op, tbl) === name)) sql.exec(`DROP TRIGGER ${ident(name)}`);
309
+ }
310
+ const now = tracked();
311
+ const lost = [...before].filter(([t, create]) => now.get(t) !== create).map(([t]) => t);
312
+ for (const t of lost) sql.exec("DELETE FROM _sl_changes WHERE tbl = ?", t);
313
+ for (const [table, key] of userTables()) {
314
+ if (now.has(table)) continue;
315
+ if (key === void 0) {
316
+ log.warning("Table {table} has a composite primary key and no rowid; its changes are not synced", { table });
317
+ continue;
318
+ }
319
+ for (const trigger of triggers(table, key)) sql.exec(trigger);
320
+ if (backfillAt !== void 0 && lost.length === 0)
321
+ sql.exec(`INSERT INTO _sl_changes (tbl, id, clock, op) SELECT ?, ${key}, ?, 'put' FROM ${ident(table)}`, table, backfillAt);
322
+ }
323
+ return lost.length > 0;
324
+ };
325
+ const ensure = () => {
326
+ sql.exec("PRAGMA recursive_triggers = ON");
327
+ if (rows(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sl_meta'`).length > 0) return;
328
+ storage.transactionSync(() => {
329
+ for (const ddl of INSTALL) sql.exec(ddl);
330
+ sql.exec("INSERT INTO _sl_meta (epoch, clock, historyStartsAt) VALUES (?, 0, 0)", crypto.randomUUID());
331
+ sync(/* @__PURE__ */ new Map());
332
+ });
333
+ };
334
+ const read = (table, query, ...params) => {
335
+ const cursor = sql.exec(query, ...params);
336
+ const columns = cursor.columnNames.slice(1);
337
+ return Array.from(cursor.raw(), ([id, ...values]) => ({
338
+ table,
339
+ id: idOf(id),
340
+ data: Object.fromEntries(columns.map((c, i) => [c, encode2(values[i])]))
341
+ }));
342
+ };
343
+ const changes = (since) => {
344
+ const out = [];
345
+ for (const { tbl, id } of rows(`SELECT tbl, id FROM _sl_changes WHERE op = 'delete' AND clock > ?`, since))
346
+ out.push({ table: tbl, id: idOf(id), op: "delete" });
347
+ const tables = userTables();
348
+ for (const { tbl: table } of rows(`SELECT DISTINCT tbl FROM _sl_changes WHERE op = 'put' AND clock > ?`, since)) {
349
+ const key = tables.get(table);
350
+ if (key === void 0) continue;
351
+ const query = `SELECT t.${key}, t.* FROM ${ident(table)} AS t JOIN _sl_changes AS c ON c.id = t.${key}
352
+ WHERE c.tbl = ? AND c.op = 'put' AND c.clock > ?`;
353
+ for (const row of read(table, query, table, since)) out.push({ ...row, op: "put" });
354
+ }
355
+ return out;
356
+ };
357
+ const prune = () => {
358
+ const over = count(`SELECT count(*) AS n FROM _sl_changes WHERE op = 'delete'`) - maxTombstones;
359
+ if (over <= 0) return;
360
+ const cutoff = count(`SELECT clock AS n FROM _sl_changes WHERE op = 'delete' ORDER BY clock LIMIT 1 OFFSET ?`, over - 1);
361
+ sql.exec(`DELETE FROM _sl_changes WHERE op = 'delete' AND clock <= ?`, cutoff);
362
+ sql.exec("UPDATE _sl_meta SET historyStartsAt = max(historyStartsAt, ?)", cutoff);
363
+ };
364
+ const commit = (before) => {
365
+ const { epoch, clock } = meta();
366
+ const next = clock + 1;
367
+ const broken = signature() !== before.signature && sync(before.tracked, next);
368
+ const logged = count(`SELECT count(*) AS n FROM _sl_changes WHERE op IN ('put', 'delete') AND clock = ?`, next);
369
+ if (logged === 0 && !broken) return void 0;
370
+ sql.exec("UPDATE _sl_meta SET clock = ?", next);
371
+ if (broken) sql.exec("UPDATE _sl_meta SET historyStartsAt = ?", next);
372
+ prune();
373
+ if (!onChange) return void 0;
374
+ const inline = meta().historyStartsAt < next && logged <= maxInlineChanges;
375
+ return { epoch, clock: next, changes: inline ? changes(clock) : void 0 };
376
+ };
377
+ const notify = (push) => ctx.waitUntil(
378
+ Promise.resolve().then(() => onChange?.(push)).catch((error) => log.error("onChange failed for clock {clock}: {error}", { clock: push.clock, error }))
379
+ );
380
+ const run = ({ sql: query, params = [] }, budget) => {
381
+ const cursor = sql.exec(query, ...params.map(decode));
382
+ const out = [];
383
+ for (const raw of cursor.raw()) {
384
+ const row = raw.map(encode2);
385
+ budget.bytes += utf8.encode(JSON.stringify(row)).byteLength + 1;
386
+ if (budget.bytes > budget.maxBytes) throw new ResultTooLarge(budget.bytes);
387
+ out.push(row);
388
+ }
389
+ return { columns: cursor.columnNames, rows: out, meta: { rowsRead: cursor.rowsRead, rowsWritten: cursor.rowsWritten } };
390
+ };
391
+ return {
392
+ query(statements, { readOnly = false, maxBytes = MAX_BYTES } = {}) {
393
+ let index = 0;
394
+ const budget = { bytes: 0, maxBytes };
395
+ try {
396
+ ensure();
397
+ const { results, push } = storage.transactionSync(() => {
398
+ const before = readOnly ? void 0 : { signature: signature(), tracked: tracked() };
399
+ const results2 = statements.map((statement, i) => {
400
+ index = i;
401
+ if (!readOnly) return run(statement, budget);
402
+ const schema = signature();
403
+ const changed = totalChanges();
404
+ const result = run(statement, budget);
405
+ if (result.meta.rowsWritten > 0 || totalChanges() !== changed || signature() !== schema) throw new Error(READ_ONLY_MESSAGE);
406
+ return result;
407
+ });
408
+ return { results: results2, push: before && commit(before) };
409
+ });
410
+ if (push) notify(push);
411
+ return { results };
412
+ } catch (error) {
413
+ const message = error instanceof Error ? error.message : String(error);
414
+ if (error instanceof ResultTooLarge) return { error: message, statement: index, bytes: error.bytes };
415
+ return { error: message, statement: index };
416
+ }
417
+ },
418
+ snapshot(table, limit) {
419
+ ensure();
420
+ const key = userTables().get(table);
421
+ if (key === void 0) return [];
422
+ return read(table, `SELECT ${key}, * FROM ${ident(table)} LIMIT ?`, limit);
423
+ },
424
+ get(table, id) {
425
+ ensure();
426
+ const key = userTables().get(table);
427
+ if (key === void 0) return void 0;
428
+ return read(table, `SELECT ${key}, * FROM ${ident(table)} WHERE ${key} = ?`, id).find((row) => row.id === id);
429
+ },
430
+ changesSince(since) {
431
+ ensure();
432
+ const { epoch, clock, historyStartsAt } = meta();
433
+ const wipeAll = since < historyStartsAt || since > clock || count(`SELECT count(*) AS n FROM _sl_changes WHERE op IN ('put', 'delete') AND clock > ?`, since) > maxPullChanges;
434
+ return { epoch, clock, changes: wipeAll ? [] : changes(since), wipeAll };
435
+ }
436
+ };
437
+ }
438
+
439
+ // src/remote-rows.ts
440
+ import { SuperLineError, applyQuery } from "@super-line/core";
441
+ var CURRENT = Number.MAX_SAFE_INTEGER;
442
+ var conjuncts = (expr) => expr === void 0 ? [] : expr.op === "and" ? expr.args.flatMap(conjuncts) : [expr];
443
+ var pinOf = (parts, field) => {
444
+ for (const e of parts) if (e.op === "eq" && e.field === field) return e.value;
445
+ return void 0;
446
+ };
447
+ var FIXED = ["storeId", "table"];
448
+ var isPin = (e) => e.op === "eq" && (e.field === "storeId" || e.field === "table");
449
+ var splitKey = (key) => {
450
+ const a = key.indexOf(":");
451
+ const b = key.indexOf(":", a + 1);
452
+ return a < 0 || b < 0 ? void 0 : [key.slice(0, a), key.slice(a + 1, b), key.slice(b + 1)];
453
+ };
454
+ function remoteRows(opts) {
455
+ const { collection, storage, stub, onWipe, snapshotLimit = 1e3 } = opts;
456
+ const listeners = /* @__PURE__ */ new Set();
457
+ const queues = /* @__PURE__ */ new Map();
458
+ const prefix = `remote-rows:${collection}:`;
459
+ const stateKey = (storeId) => prefix + storeId;
460
+ const keyOf = (storeId, table, id) => `${storeId}:${table}:${id}`;
461
+ const rowOf = (storeId, { table, id, data }) => ({ key: keyOf(storeId, table, id), storeId, table, id, data });
462
+ const changeOf = (storeId, c) => {
463
+ const id = keyOf(storeId, c.table, c.id);
464
+ if (c.op === "delete") return { n: collection, k: "delete", id, origin: storeId };
465
+ return { n: collection, k: "update", id, next: rowOf(storeId, c), origin: storeId };
466
+ };
467
+ const emit = (storeId, changes) => {
468
+ for (const c of changes) {
469
+ const change = changeOf(storeId, c);
470
+ for (const cb of listeners) cb(change);
471
+ }
472
+ };
473
+ const serial = (storeId, step) => {
474
+ const run = (queues.get(storeId) ?? Promise.resolve()).then(step);
475
+ queues.set(storeId, run.catch(() => {
476
+ }));
477
+ return run;
478
+ };
479
+ const save = (storeId, { epoch, clock }) => storage.put(stateKey(storeId), { epoch, clock });
480
+ async function pull(storeId, seen) {
481
+ const pulled = await stub(storeId).changesSince(seen?.clock ?? CURRENT);
482
+ await save(storeId, pulled);
483
+ if (!seen) return;
484
+ if (pulled.wipeAll || pulled.epoch !== seen.epoch) await onWipe?.(storeId);
485
+ else emit(storeId, pulled.changes);
486
+ }
487
+ async function receive(storeId, push) {
488
+ const seen = await storage.get(stateKey(storeId));
489
+ if (!seen) return;
490
+ const sameEpoch = push.epoch === seen.epoch;
491
+ if (sameEpoch && push.clock <= seen.clock) return;
492
+ if (!sameEpoch || push.clock !== seen.clock + 1 || !push.changes) return pull(storeId, seen);
493
+ await save(storeId, push);
494
+ emit(storeId, push.changes);
495
+ }
496
+ const catchUp = (storeId) => serial(storeId, async () => pull(storeId, await storage.get(stateKey(storeId))));
497
+ const readOnly = () => {
498
+ throw new SuperLineError("FORBIDDEN", `Collection ${collection} is read-only: write through its source`);
499
+ };
500
+ return {
501
+ clustering: "self",
502
+ coordination: "cluster",
503
+ ingest: (storeId, push) => serial(storeId, () => receive(storeId, push)),
504
+ async resume(storeIds) {
505
+ const ids = storeIds ?? [...(await storage.list({ prefix })).keys()].map((key) => key.slice(prefix.length));
506
+ await Promise.all(ids.map(catchUp));
507
+ },
508
+ async snapshot(_n, query) {
509
+ const parts = conjuncts(query.filter);
510
+ const storeId = pinOf(parts, "storeId");
511
+ const table = pinOf(parts, "table");
512
+ if (typeof storeId !== "string" || typeof table !== "string")
513
+ throw new SuperLineError("VALIDATION", `A ${collection} subscription must pin storeId and table with eq()`);
514
+ await catchUp(storeId);
515
+ const exact = !query.orderBy?.length && parts.every(isPin) && query.limit !== void 0;
516
+ const limit = exact ? Math.min(snapshotLimit, (query.offset ?? 0) + (query.limit ?? 0)) : snapshotLimit;
517
+ const rows = await stub(storeId).snapshot(table, limit);
518
+ return applyQuery(rows.map((row) => rowOf(storeId, row)), query);
519
+ },
520
+ async read(_n, key) {
521
+ const parts = splitKey(key);
522
+ if (!parts) return void 0;
523
+ const [storeId, table, id] = parts;
524
+ const row = await stub(storeId).get(table, id);
525
+ return row && rowOf(storeId, row);
526
+ },
527
+ fixedFields: () => FIXED,
528
+ onChange(cb) {
529
+ listeners.add(cb);
530
+ return () => listeners.delete(cb);
531
+ },
532
+ apply: readOnly,
533
+ conditionalApply: readOnly
534
+ };
535
+ }
536
+
231
537
  // src/index.ts
232
538
  var MAX_PARAMS = 100;
233
539
  var MAX_COLUMNS = 98;
@@ -261,14 +567,14 @@ function cloudflareCollections(storage, collections) {
261
567
  try {
262
568
  tables.set(n, openTable(n, def));
263
569
  } catch (err) {
264
- tables.set(n, new SuperLineError("INTERNAL", `cloudflareCollections: collection '${n}' is unusable: ${err.message}`));
570
+ tables.set(n, new SuperLineError2("INTERNAL", `cloudflareCollections: collection '${n}' is unusable: ${err.message}`));
265
571
  }
266
572
  }
267
573
  return tables;
268
574
  };
269
575
  const table = (n) => {
270
576
  const t = open().get(n);
271
- if (t instanceof SuperLineError) throw t;
577
+ if (t instanceof SuperLineError2) throw t;
272
578
  return t;
273
579
  };
274
580
  const readRow = (t, id) => {
@@ -284,14 +590,14 @@ function cloudflareCollections(storage, collections) {
284
590
  const ts = Date.now();
285
591
  for (const op of ops) {
286
592
  const t = table(op.n);
287
- if (!t) throw new SuperLineError("NOT_FOUND", `Unknown collection: ${op.n}`);
593
+ if (!t) throw new SuperLineError2("NOT_FOUND", `Unknown collection: ${op.n}`);
288
594
  if (op.op === "insert") {
289
- if (rows(t.sql.has, op.id).length > 0) throw new SuperLineError("CONFLICT", `Row already exists: ${op.n}/${op.id}`);
595
+ if (rows(t.sql.has, op.id).length > 0) throw new SuperLineError2("CONFLICT", `Row already exists: ${op.n}/${op.id}`);
290
596
  sql.exec(t.sql.insert, ...rowParams(t, op.id, op.row), ts, ts);
291
597
  changes.push({ n: op.n, k: "insert", id: op.id, next: op.row, origin });
292
598
  } else if (op.op === "update") {
293
599
  const prev = readRow(t, op.id);
294
- if (prev === void 0) throw new SuperLineError("NOT_FOUND", `No row: ${op.n}/${op.id}`);
600
+ if (prev === void 0) throw new SuperLineError2("NOT_FOUND", `No row: ${op.n}/${op.id}`);
295
601
  sql.exec(t.sql.update, ...updateParams(t, op.id, op.row, ts));
296
602
  changes.push({ n: op.n, k: "update", id: op.id, prev, next: op.row, origin });
297
603
  } else {
@@ -329,7 +635,7 @@ function cloudflareCollections(storage, collections) {
329
635
  if (!t) return [];
330
636
  const q = snapshotSql(t, query, MAX_PARAMS);
331
637
  const found = rows(q.sql, ...q.params).map((r) => fromRow(t, r));
332
- return q.exact ? found : applyQuery(found, query);
638
+ return q.exact ? found : applyQuery2(found, query);
333
639
  },
334
640
  read(n, id) {
335
641
  const t = table(n);
@@ -354,5 +660,8 @@ function cloudflareCollections(storage, collections) {
354
660
  };
355
661
  }
356
662
  export {
357
- cloudflareCollections
663
+ READ_ONLY_MESSAGE,
664
+ cloudflareCollections,
665
+ remoteRows,
666
+ syncedSql
358
667
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@super-line/collections-cloudflare",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Durable Object SQLite CollectionStore backend for super-line — typed row collections stored in the object that is the node, with IR→SQL snapshot pushdown.",
6
6
  "license": "MIT",
@@ -26,6 +26,10 @@
26
26
  ".": {
27
27
  "types": "./dist/index.d.ts",
28
28
  "default": "./dist/index.js"
29
+ },
30
+ "./contract": {
31
+ "types": "./dist/contract.d.ts",
32
+ "default": "./dist/contract.js"
29
33
  }
30
34
  },
31
35
  "files": [
@@ -36,12 +40,16 @@
36
40
  "publishConfig": {
37
41
  "access": "public"
38
42
  },
43
+ "dependencies": {
44
+ "@logtape/logtape": "^2.2.4",
45
+ "zod": "^4.2.0"
46
+ },
39
47
  "peerDependencies": {
40
- "@super-line/core": ">=0.17.0 <1.0.0"
48
+ "@super-line/core": ">=0.23.0 <1.0.0"
41
49
  },
42
50
  "devDependencies": {
43
51
  "@cloudflare/workers-types": "^5.20261013.1",
44
- "@super-line/core": "^0.21.0"
52
+ "@super-line/core": "^0.23.0"
45
53
  },
46
54
  "scripts": {
47
55
  "build": "tsup"