@super-line/collections-cloudflare 0.1.0 → 0.3.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,24 @@
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.3.0] — 2026-10-22
7
+
8
+ ### Features
9
+
10
+ - **collections-cloudflare:** CountBy runs as one GROUP BY in the object's SQLite ([ec7b2b9])
11
+ - **collections-cloudflare:** Count, and text order, like and has run in the object's SQLite ([73dd870])
12
+ - **collections-cloudflare:** A remote rows snapshot may span every table of a store ([f26cc95])
13
+
14
+ ## [collections-cloudflare-v0.2.0] — 2026-10-20
15
+
16
+ ### Features
17
+
18
+ - **collections-cloudflare:** A foreign object's SQLite as a live collection ([a9579af])
19
+
20
+ ### Bug Fixes
21
+
22
+ - **collections-cloudflare:** ReadOnly catches writes in every statement ([e391e8e])
23
+
6
24
  ## [collections-cloudflare-v0.1.0] — 2026-10-15
7
25
 
8
26
  ### 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 | undefined, 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 `storeId` with `eq()` (and may pin `table`; without it the snapshot spans every table of the store), 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,133 @@
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
+ /** Rows of one table, or of every table when `table` is undefined, up to `limit` in all. */
51
+ snapshot(table: string | undefined, limit: number): RemoteRow[];
52
+ get(table: string, id: string): RemoteRow | undefined;
53
+ changesSince(clock: number): RemoteChanges;
54
+ }
55
+ declare const READ_ONLY_MESSAGE = "Read-only: the statement wrote rows or changed the schema and was rolled back";
56
+ /**
57
+ * Change capture over a Durable Object's own SQLite, for an object whose tables are created at runtime by arbitrary
58
+ * SQL. `query` is the object's one write funnel: a batch runs in order inside one `transactionSync`, and the first
59
+ * failure rolls the whole batch back and reports SQLite's message.
60
+ *
61
+ * Every user table gets `AFTER INSERT/UPDATE/DELETE` triggers that log into `_sl_changes` at a clock that bumps once
62
+ * per write batch; a batch that changes the schema re-syncs them, and a table it created is logged whole. A row's id
63
+ * is its single-column primary key, else its rowid; a `WITHOUT ROWID` table with a composite key is skipped, with a
64
+ * warning. Dropping, renaming or altering the columns of a table breaks the history, so `changesSince` from before
65
+ * 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
66
+ * `recursive_triggers` on, so a `REPLACE` that evicts a conflicting row logs its delete.
67
+ *
68
+ * ```ts
69
+ * private sync = syncedSql(this.ctx, { onChange: (push) => this.env.NODE.getByName('node').changed(push) })
70
+ * query(statements: Statement[], opts?: QueryOptions) { return this.sync.query(statements, opts) }
71
+ * ```
72
+ */
73
+ declare function syncedSql(ctx: DurableObjectState, opts?: SyncedSqlOptions): SyncedSql;
74
+
75
+ type Awaitable<T> = T | Promise<T>;
76
+ /** A source as the node reaches it: `syncedSql`'s reads, over RPC. A Durable Object stub satisfies it. */
77
+ interface RemoteSource {
78
+ snapshot(table: string | undefined, limit: number): Awaitable<RemoteRow[]>;
79
+ get(table: string, id: string): Awaitable<RemoteRow | undefined>;
80
+ changesSince(clock: number): Awaitable<RemoteChanges>;
81
+ }
82
+ /** Where the node keeps the last clock it applied per source. `ctx.storage` satisfies it. */
83
+ interface RemoteRowsStorage {
84
+ get<T>(key: string): Promise<T | undefined>;
85
+ put<T>(key: string, value: T): Promise<void>;
86
+ list<T>(options: {
87
+ prefix: string;
88
+ }): Promise<Map<string, T>>;
89
+ }
90
+ interface RemoteRowsOptions {
91
+ /** The contract collection, declared with `remoteRowsCollection(collection)`. */
92
+ collection: string;
93
+ storage: RemoteRowsStorage;
94
+ stub(storeId: string): RemoteSource;
95
+ /**
96
+ * The source's history broke (a dropped, renamed or altered table, pruned deletes, a reset source), so rows its
97
+ * subscribers hold may be stale. Close their connections (`conn.raw.close(4409, 'resync')`): the client reconnects
98
+ * and resubscribes into a fresh snapshot. Default: nothing.
99
+ */
100
+ onWipe?(storeId: string): unknown;
101
+ /** Rows a snapshot reads from the source table, at most. Default 1000. */
102
+ snapshotLimit?: number;
103
+ }
104
+ interface RemoteRowsStore extends SelfCollectionStore {
105
+ /** The forwarder's target: a source's `onChange` push. Applies it inline when it is the next clock, else pulls. */
106
+ ingest(storeId: string, push: RemotePush): Promise<void>;
107
+ /**
108
+ * Pull what these sources changed since their last clock — on wake, for the sources with surviving subscribers.
109
+ * With no ids, every source the node keeps a clock for.
110
+ */
111
+ resume(storeIds?: string[]): Promise<void>;
112
+ }
113
+ /**
114
+ * The node side of a foreign Durable Object's SQLite: a read-only `self` {@link SelfCollectionStore} serving the rows
115
+ * of every source the node fronts as one collection. The source pushes each committed batch to {@link
116
+ * RemoteRowsStore.ingest} (`syncedSql`'s `onChange`); the node keeps the last clock per source in `storage` and pulls
117
+ * `changesSince` when a push skips a clock, carries no changes or comes from a new epoch. A store's feed starts at
118
+ * its first subscribe, and every subscribe pulls first.
119
+ *
120
+ * A snapshot must pin `storeId` by equality (`VALIDATION` otherwise) and may pin `table`; without it the read spans
121
+ * every table of the store. The rest of the query runs on the rows read. Changes carry no prior row, so the server sends a data-less delete to a subscriber the new row doesn't
122
+ * match — unless its filter rules out the row's `storeId` or `table`, which never change for a key. Writes throw
123
+ * `FORBIDDEN`: they go through the source's own path.
124
+ *
125
+ * ```ts
126
+ * const rows = remoteRows({ collection: 'storeRows', storage: ctx.storage, stub: (id) => env.STORE.getByName(id) })
127
+ * createSuperLineServer(contract, { ...base, collections: composeCollections({ default, storeRows: rows }) })
128
+ * ```
129
+ */
130
+ declare function remoteRows(opts: RemoteRowsOptions): RemoteRowsStore;
2
131
 
3
132
  /**
4
133
  * The Durable Object SQLite RelayCollectionStore: every LWW collection on the contract gets its own typed table
@@ -16,4 +145,4 @@ import { CollectionDef, RelayCollectionStore } from '@super-line/core';
16
145
  */
17
146
  declare function cloudflareCollections(storage: DurableObjectStorage, collections: Record<string, CollectionDef>): RelayCollectionStore;
18
147
 
19
- export { cloudflareCollections };
148
+ 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, countValues, 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";
@@ -48,8 +48,26 @@ function resolveField(t, field) {
48
48
  if (col.kind === "json") return { at: "json", col: col.name, path: dot === -1 ? "$" : "$." + field.slice(dot + 1) };
49
49
  return dot === -1 ? { at: "scalar", col } : { at: "absent" };
50
50
  }
51
+ var twoValued = (col, sql) => col.nullable || col.optional ? `COALESCE(${sql}, 0)` : sql;
51
52
  var TRUE = { sql: "1=1", params: [], exact: true };
52
53
  var FALSE = { sql: "1=0", params: [], exact: true };
54
+ var elementSql = (value) => value === null ? { sql: `e.type = 'null'`, params: [] } : typeof value === "boolean" ? { sql: `e.type = '${value}'`, params: [] } : typeof value === "number" ? { sql: `e.type IN ('integer', 'real') AND e.value = ?`, params: [value] } : { sql: `e.type = 'text' AND e.value = ?`, params: [value] };
55
+ function likeSql(pattern, ci) {
56
+ let out = "";
57
+ let exact = true;
58
+ for (const ch of pattern) {
59
+ if (ch === "%") out += ci ? "%" : "*";
60
+ else if (ch === "_") {
61
+ out += ci ? "%" : "*";
62
+ exact = false;
63
+ } else if (ci && ch.length === 1 && ch.charCodeAt(0) > 127) {
64
+ out += "_";
65
+ exact = false;
66
+ } else if (!ci && (ch === "*" || ch === "?" || ch === "[")) out += `[${ch}]`;
67
+ else out += ch;
68
+ }
69
+ return { pattern: out, exact };
70
+ }
53
71
  function compileExpr(t, expr) {
54
72
  switch (expr.op) {
55
73
  case "and":
@@ -106,10 +124,9 @@ function compileExpr(t, expr) {
106
124
  const ref = resolveField(t, expr.field);
107
125
  if (ref.at === "absent") return FALSE;
108
126
  if (ref.at === "scalar") {
109
- if (ref.col.kind === "real" && typeof expr.value === "number") {
110
- return { sql: `COALESCE("${ref.col.name}" ${op} ?, 0)`, params: [expr.value], exact: true };
127
+ if (ref.col.kind === "real" && typeof expr.value === "number" || ref.col.kind === "text" && typeof expr.value === "string") {
128
+ return { sql: twoValued(ref.col, `"${ref.col.name}" ${op} ?`), params: [expr.value], exact: true };
111
129
  }
112
- if (ref.col.kind === "text" && typeof expr.value === "string") return null;
113
130
  return FALSE;
114
131
  }
115
132
  if (typeof expr.value !== "number") return null;
@@ -124,7 +141,7 @@ function compileExpr(t, expr) {
124
141
  const parts = [];
125
142
  const params = [];
126
143
  if (usable.length > 0) {
127
- parts.push(`COALESCE("${ref.col.name}" IN (${usable.map(() => "?").join(",")}), 0)`);
144
+ parts.push(twoValued(ref.col, `"${ref.col.name}" IN (${usable.map(() => "?").join(",")})`));
128
145
  params.push(...usable.map(norm));
129
146
  }
130
147
  if (expr.values.includes(null) && ref.col.nullable) parts.push(`"${ref.col.name}" IS NULL`);
@@ -137,15 +154,34 @@ function compileExpr(t, expr) {
137
154
  exact: false
138
155
  };
139
156
  }
140
- default:
141
- return null;
157
+ case "like":
158
+ case "ilike": {
159
+ const ref = resolveField(t, expr.field);
160
+ if (ref.at === "absent") return FALSE;
161
+ if (ref.at !== "scalar") return null;
162
+ if (ref.col.kind !== "text") return FALSE;
163
+ const p = likeSql(expr.pattern, expr.op === "ilike");
164
+ const sql = twoValued(ref.col, `"${ref.col.name}" ${expr.op === "ilike" ? "LIKE" : "GLOB"} ?`);
165
+ return { sql, params: [p.pattern], exact: p.exact };
166
+ }
167
+ case "has": {
168
+ if (expr.field.includes(".")) return null;
169
+ const ref = resolveField(t, expr.field);
170
+ if (ref.at !== "json") return FALSE;
171
+ const el = elementSql(expr.value);
172
+ return {
173
+ sql: `json_type("${ref.col}", ?) IS 'array' AND EXISTS (SELECT 1 FROM json_each("${t.name}"."${ref.col}", ?) AS e WHERE ${el.sql})`,
174
+ params: [ref.path, ref.path, ...el.params],
175
+ exact: true
176
+ };
177
+ }
142
178
  }
143
179
  }
144
180
  function compileOrder(t, orderBy) {
145
181
  const parts = [];
146
182
  for (const { field, dir } of orderBy) {
147
183
  const ref = resolveField(t, field);
148
- if (ref.at !== "scalar" || ref.col.kind !== "real") return null;
184
+ if (ref.at !== "scalar" || ref.col.kind === "integer-bool") return null;
149
185
  parts.push(dir === "desc" ? `"${ref.col.name}" DESC NULLS FIRST` : `"${ref.col.name}" ASC NULLS LAST`);
150
186
  }
151
187
  return parts.join(", ");
@@ -168,6 +204,33 @@ function snapshotSql(t, query, maxParams = Infinity) {
168
204
  }
169
205
  return { sql, params, exact: true };
170
206
  }
207
+ function countSql(t, filter, maxParams = Infinity) {
208
+ if (!filter) return { sql: `SELECT COUNT(*) AS n FROM "${t.name}"`, params: [] };
209
+ const where = compileExpr(t, filter);
210
+ if (!where?.exact || where.params.length > maxParams) return null;
211
+ return { sql: `SELECT COUNT(*) AS n FROM "${t.name}" WHERE ${where.sql}`, params: where.params };
212
+ }
213
+ function countBySql(t, field, filter, maxParams = Infinity) {
214
+ const where = filter ? compileExpr(t, filter) : TRUE;
215
+ if (field.includes(".") || !where?.exact || where.params.length + 2 > maxParams) return null;
216
+ const ref = resolveField(t, field);
217
+ if (ref.at === "absent") return { sql: `SELECT 0 AS n WHERE 0`, params: [], decode: () => ({ value: null, count: 0 }) };
218
+ if (ref.at === "scalar") {
219
+ const c = ref.col;
220
+ const missing = c.optional ? ` AND "${c.name}" IS NOT NULL` : "";
221
+ return {
222
+ sql: `SELECT "${c.name}" AS v, COUNT(*) AS n FROM "${t.name}" WHERE (${where.sql})${missing} GROUP BY "${c.name}"`,
223
+ params: where.params,
224
+ decode: (r) => ({ value: c.kind === "integer-bool" && r.v !== null ? r.v === 1 : r.v, count: r.n })
225
+ };
226
+ }
227
+ const kind = `CASE WHEN e.type IN ('integer', 'real') THEN 'number' ELSE e.type END`;
228
+ return {
229
+ sql: `SELECT k, v, COUNT(*) AS n FROM (SELECT DISTINCT f.rid, ${kind} AS k, e.value AS v FROM (SELECT "${t.plan.key}" AS rid, "${ref.col}" AS c FROM "${t.name}" WHERE ${where.sql}) AS f, json_each(f.c, ?) AS e WHERE json_type(f.c, ?) IS NOT 'object' AND e.type NOT IN ('object', 'array')) GROUP BY k, v`,
230
+ params: [...where.params, ref.path, ref.path],
231
+ decode: (r) => ({ value: r.k === "true" ? true : r.k === "false" ? false : r.v, count: r.n })
232
+ };
233
+ }
171
234
  var rowMetaSql = (t, count) => `SELECT "${t.plan.key}" AS id, "_sl_created_at" AS c, "_sl_updated_at" AS u FROM "${t.name}" WHERE "${t.plan.key}" IN (${Array.from({ length: count }, () => "?").join(",")})`;
172
235
  var encode = (col, v) => {
173
236
  if (col.kind === "json") return v === void 0 ? null : JSON.stringify(v);
@@ -228,6 +291,316 @@ function driftOf(t, stored) {
228
291
  return { alter };
229
292
  }
230
293
 
294
+ // src/synced-sql.ts
295
+ import { getLogger } from "@logtape/logtape";
296
+ var log = getLogger(["super-line", "collections-cloudflare", "synced-sql"]);
297
+ var READ_ONLY_MESSAGE = "Read-only: the statement wrote rows or changed the schema and was rolled back";
298
+ var MAX_BYTES = 16 * 1024 * 1024;
299
+ var INSTALL = [
300
+ "CREATE TABLE IF NOT EXISTS _sl_meta (epoch TEXT NOT NULL, clock INTEGER NOT NULL, historyStartsAt INTEGER NOT NULL)",
301
+ "CREATE TABLE IF NOT EXISTS _sl_changes (tbl TEXT NOT NULL, id, clock INTEGER NOT NULL, op TEXT NOT NULL, PRIMARY KEY (tbl, id))",
302
+ "CREATE INDEX IF NOT EXISTS _sl_changes_clock ON _sl_changes (op, clock)"
303
+ ];
304
+ var SIGNATURE_SQL = `SELECT group_concat(type || ' ' || name || ' ' || ifnull(sql, ''), char(10)) AS s FROM sqlite_master
305
+ WHERE tbl_name NOT GLOB '_sl_*'`;
306
+ var TRIGGERS_SQL = `SELECT name, tbl_name AS tbl FROM sqlite_master WHERE type = 'trigger' AND name GLOB '_sl_*'`;
307
+ var TRACKED_SQL = `SELECT r.name AS name, r.tbl_name AS tbl, t.sql AS sql FROM sqlite_master AS r
308
+ 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_*'`;
309
+ var TABLES_SQL = `SELECT l.name AS name, l.wr AS wr,
310
+ (SELECT count(*) FROM pragma_table_info(l.name) WHERE pk > 0) AS pks,
311
+ (SELECT name FROM pragma_table_info(l.name) WHERE pk = 1) AS pk
312
+ FROM pragma_table_list() AS l
313
+ 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_*'
314
+ AND l.name NOT GLOB '__miniflare_*'`;
315
+ var OPS = ["i", "u", "d"];
316
+ var triggerName = (op, table) => `_sl_${op}_${table}`;
317
+ var ident = (name) => `"${name.replaceAll('"', '""')}"`;
318
+ var literal = (text) => `'${text.replaceAll("'", "''")}'`;
319
+ var toBase64 = (buffer) => {
320
+ let s = "";
321
+ for (const byte of new Uint8Array(buffer)) s += String.fromCharCode(byte);
322
+ return btoa(s);
323
+ };
324
+ var fromBase64 = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0)).buffer;
325
+ var decode = (value) => value !== null && typeof value === "object" ? fromBase64(value.blob) : value;
326
+ var encode2 = (value) => value instanceof ArrayBuffer ? { blob: toBase64(value) } : value;
327
+ var idOf = (value) => value instanceof ArrayBuffer ? toBase64(value) : String(value);
328
+ var utf8 = new TextEncoder();
329
+ var ResultTooLarge = class extends Error {
330
+ constructor(bytes) {
331
+ super("The result is over the cap");
332
+ this.bytes = bytes;
333
+ }
334
+ bytes;
335
+ };
336
+ 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};`;
337
+ var triggers = (table, key) => {
338
+ const tbl = literal(table);
339
+ const on = ident(table);
340
+ const [i, u, d] = OPS.map((op) => ident(triggerName(op, table)));
341
+ return [
342
+ `CREATE TRIGGER IF NOT EXISTS ${i} AFTER INSERT ON ${on} BEGIN ${record(tbl, `NEW.${key}`, "put")} END`,
343
+ `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`,
344
+ `CREATE TRIGGER IF NOT EXISTS ${d} AFTER DELETE ON ${on} BEGIN ${record(tbl, `OLD.${key}`, "delete")} END`
345
+ ];
346
+ };
347
+ function syncedSql(ctx, opts = {}) {
348
+ const { maxTombstones = 1e3, maxInlineChanges = 500, maxPullChanges = 5e3, onChange } = opts;
349
+ const storage = ctx.storage;
350
+ const sql = storage.sql;
351
+ const rows = (query, ...params) => sql.exec(query, ...params).toArray();
352
+ const count = (query, ...params) => sql.exec(query, ...params).one().n;
353
+ const meta = () => sql.exec("SELECT * FROM _sl_meta").one();
354
+ const signature = () => sql.exec(SIGNATURE_SQL).one().s;
355
+ const totalChanges = () => count("SELECT total_changes() AS n");
356
+ const userTables = () => {
357
+ const tables = /* @__PURE__ */ new Map();
358
+ for (const t of rows(TABLES_SQL)) {
359
+ tables.set(t.name, t.pks === 1 && t.pk !== null ? ident(t.pk) : t.wr === 0 ? "rowid" : void 0);
360
+ }
361
+ return tables;
362
+ };
363
+ const tracked = () => {
364
+ const tables = /* @__PURE__ */ new Map();
365
+ for (const { name, tbl, sql: create } of rows(TRACKED_SQL))
366
+ if (name === triggerName("i", tbl)) tables.set(tbl, create);
367
+ return tables;
368
+ };
369
+ const sync = (before, backfillAt) => {
370
+ for (const { name, tbl } of rows(TRIGGERS_SQL)) {
371
+ if (/^_sl_[iud]_/.test(name) && !OPS.some((op) => triggerName(op, tbl) === name)) sql.exec(`DROP TRIGGER ${ident(name)}`);
372
+ }
373
+ const now = tracked();
374
+ const lost = [...before].filter(([t, create]) => now.get(t) !== create).map(([t]) => t);
375
+ for (const t of lost) sql.exec("DELETE FROM _sl_changes WHERE tbl = ?", t);
376
+ for (const [table, key] of userTables()) {
377
+ if (now.has(table)) continue;
378
+ if (key === void 0) {
379
+ log.warning("Table {table} has a composite primary key and no rowid; its changes are not synced", { table });
380
+ continue;
381
+ }
382
+ for (const trigger of triggers(table, key)) sql.exec(trigger);
383
+ if (backfillAt !== void 0 && lost.length === 0)
384
+ sql.exec(`INSERT INTO _sl_changes (tbl, id, clock, op) SELECT ?, ${key}, ?, 'put' FROM ${ident(table)}`, table, backfillAt);
385
+ }
386
+ return lost.length > 0;
387
+ };
388
+ const ensure = () => {
389
+ sql.exec("PRAGMA recursive_triggers = ON");
390
+ if (rows(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_sl_meta'`).length > 0) return;
391
+ storage.transactionSync(() => {
392
+ for (const ddl of INSTALL) sql.exec(ddl);
393
+ sql.exec("INSERT INTO _sl_meta (epoch, clock, historyStartsAt) VALUES (?, 0, 0)", crypto.randomUUID());
394
+ sync(/* @__PURE__ */ new Map());
395
+ });
396
+ };
397
+ const read = (table, query, ...params) => {
398
+ const cursor = sql.exec(query, ...params);
399
+ const columns = cursor.columnNames.slice(1);
400
+ return Array.from(cursor.raw(), ([id, ...values]) => ({
401
+ table,
402
+ id: idOf(id),
403
+ data: Object.fromEntries(columns.map((c, i) => [c, encode2(values[i])]))
404
+ }));
405
+ };
406
+ const changes = (since) => {
407
+ const out = [];
408
+ for (const { tbl, id } of rows(`SELECT tbl, id FROM _sl_changes WHERE op = 'delete' AND clock > ?`, since))
409
+ out.push({ table: tbl, id: idOf(id), op: "delete" });
410
+ const tables = userTables();
411
+ for (const { tbl: table } of rows(`SELECT DISTINCT tbl FROM _sl_changes WHERE op = 'put' AND clock > ?`, since)) {
412
+ const key = tables.get(table);
413
+ if (key === void 0) continue;
414
+ const query = `SELECT t.${key}, t.* FROM ${ident(table)} AS t JOIN _sl_changes AS c ON c.id = t.${key}
415
+ WHERE c.tbl = ? AND c.op = 'put' AND c.clock > ?`;
416
+ for (const row of read(table, query, table, since)) out.push({ ...row, op: "put" });
417
+ }
418
+ return out;
419
+ };
420
+ const prune = () => {
421
+ const over = count(`SELECT count(*) AS n FROM _sl_changes WHERE op = 'delete'`) - maxTombstones;
422
+ if (over <= 0) return;
423
+ const cutoff = count(`SELECT clock AS n FROM _sl_changes WHERE op = 'delete' ORDER BY clock LIMIT 1 OFFSET ?`, over - 1);
424
+ sql.exec(`DELETE FROM _sl_changes WHERE op = 'delete' AND clock <= ?`, cutoff);
425
+ sql.exec("UPDATE _sl_meta SET historyStartsAt = max(historyStartsAt, ?)", cutoff);
426
+ };
427
+ const commit = (before) => {
428
+ const { epoch, clock } = meta();
429
+ const next = clock + 1;
430
+ const broken = signature() !== before.signature && sync(before.tracked, next);
431
+ const logged = count(`SELECT count(*) AS n FROM _sl_changes WHERE op IN ('put', 'delete') AND clock = ?`, next);
432
+ if (logged === 0 && !broken) return void 0;
433
+ sql.exec("UPDATE _sl_meta SET clock = ?", next);
434
+ if (broken) sql.exec("UPDATE _sl_meta SET historyStartsAt = ?", next);
435
+ prune();
436
+ if (!onChange) return void 0;
437
+ const inline = meta().historyStartsAt < next && logged <= maxInlineChanges;
438
+ return { epoch, clock: next, changes: inline ? changes(clock) : void 0 };
439
+ };
440
+ const notify = (push) => ctx.waitUntil(
441
+ Promise.resolve().then(() => onChange?.(push)).catch((error) => log.error("onChange failed for clock {clock}: {error}", { clock: push.clock, error }))
442
+ );
443
+ const run = ({ sql: query, params = [] }, budget) => {
444
+ const cursor = sql.exec(query, ...params.map(decode));
445
+ const out = [];
446
+ for (const raw of cursor.raw()) {
447
+ const row = raw.map(encode2);
448
+ budget.bytes += utf8.encode(JSON.stringify(row)).byteLength + 1;
449
+ if (budget.bytes > budget.maxBytes) throw new ResultTooLarge(budget.bytes);
450
+ out.push(row);
451
+ }
452
+ return { columns: cursor.columnNames, rows: out, meta: { rowsRead: cursor.rowsRead, rowsWritten: cursor.rowsWritten } };
453
+ };
454
+ return {
455
+ query(statements, { readOnly = false, maxBytes = MAX_BYTES } = {}) {
456
+ let index = 0;
457
+ const budget = { bytes: 0, maxBytes };
458
+ try {
459
+ ensure();
460
+ const { results, push } = storage.transactionSync(() => {
461
+ const before = readOnly ? void 0 : { signature: signature(), tracked: tracked() };
462
+ const results2 = statements.map((statement, i) => {
463
+ index = i;
464
+ if (!readOnly) return run(statement, budget);
465
+ const schema = signature();
466
+ const changed = totalChanges();
467
+ const result = run(statement, budget);
468
+ if (result.meta.rowsWritten > 0 || totalChanges() !== changed || signature() !== schema) throw new Error(READ_ONLY_MESSAGE);
469
+ return result;
470
+ });
471
+ return { results: results2, push: before && commit(before) };
472
+ });
473
+ if (push) notify(push);
474
+ return { results };
475
+ } catch (error) {
476
+ const message = error instanceof Error ? error.message : String(error);
477
+ if (error instanceof ResultTooLarge) return { error: message, statement: index, bytes: error.bytes };
478
+ return { error: message, statement: index };
479
+ }
480
+ },
481
+ snapshot(table, limit) {
482
+ ensure();
483
+ const tables = userTables();
484
+ const out = [];
485
+ for (const [name, key] of table === void 0 ? tables : [[table, tables.get(table)]]) {
486
+ if (key === void 0 || out.length >= limit) continue;
487
+ out.push(...read(name, `SELECT ${key}, * FROM ${ident(name)} LIMIT ?`, limit - out.length));
488
+ }
489
+ return out;
490
+ },
491
+ get(table, id) {
492
+ ensure();
493
+ const key = userTables().get(table);
494
+ if (key === void 0) return void 0;
495
+ return read(table, `SELECT ${key}, * FROM ${ident(table)} WHERE ${key} = ?`, id).find((row) => row.id === id);
496
+ },
497
+ changesSince(since) {
498
+ ensure();
499
+ const { epoch, clock, historyStartsAt } = meta();
500
+ const wipeAll = since < historyStartsAt || since > clock || count(`SELECT count(*) AS n FROM _sl_changes WHERE op IN ('put', 'delete') AND clock > ?`, since) > maxPullChanges;
501
+ return { epoch, clock, changes: wipeAll ? [] : changes(since), wipeAll };
502
+ }
503
+ };
504
+ }
505
+
506
+ // src/remote-rows.ts
507
+ import { SuperLineError, applyQuery } from "@super-line/core";
508
+ var CURRENT = Number.MAX_SAFE_INTEGER;
509
+ var conjuncts = (expr) => expr === void 0 ? [] : expr.op === "and" ? expr.args.flatMap(conjuncts) : [expr];
510
+ var pinOf = (parts, field) => {
511
+ for (const e of parts) if (e.op === "eq" && e.field === field) return e.value;
512
+ return void 0;
513
+ };
514
+ var FIXED = ["storeId", "table"];
515
+ var isPin = (e) => e.op === "eq" && (e.field === "storeId" || e.field === "table");
516
+ var splitKey = (key) => {
517
+ const a = key.indexOf(":");
518
+ const b = key.indexOf(":", a + 1);
519
+ return a < 0 || b < 0 ? void 0 : [key.slice(0, a), key.slice(a + 1, b), key.slice(b + 1)];
520
+ };
521
+ function remoteRows(opts) {
522
+ const { collection, storage, stub, onWipe, snapshotLimit = 1e3 } = opts;
523
+ const listeners = /* @__PURE__ */ new Set();
524
+ const queues = /* @__PURE__ */ new Map();
525
+ const prefix = `remote-rows:${collection}:`;
526
+ const stateKey = (storeId) => prefix + storeId;
527
+ const keyOf = (storeId, table, id) => `${storeId}:${table}:${id}`;
528
+ const rowOf = (storeId, { table, id, data }) => ({ key: keyOf(storeId, table, id), storeId, table, id, data });
529
+ const changeOf = (storeId, c) => {
530
+ const id = keyOf(storeId, c.table, c.id);
531
+ if (c.op === "delete") return { n: collection, k: "delete", id, origin: storeId };
532
+ return { n: collection, k: "update", id, next: rowOf(storeId, c), origin: storeId };
533
+ };
534
+ const emit = (storeId, changes) => {
535
+ for (const c of changes) {
536
+ const change = changeOf(storeId, c);
537
+ for (const cb of listeners) cb(change);
538
+ }
539
+ };
540
+ const serial = (storeId, step) => {
541
+ const run = (queues.get(storeId) ?? Promise.resolve()).then(step);
542
+ queues.set(storeId, run.catch(() => {
543
+ }));
544
+ return run;
545
+ };
546
+ const save = (storeId, { epoch, clock }) => storage.put(stateKey(storeId), { epoch, clock });
547
+ async function pull(storeId, seen) {
548
+ const pulled = await stub(storeId).changesSince(seen?.clock ?? CURRENT);
549
+ await save(storeId, pulled);
550
+ if (!seen) return;
551
+ if (pulled.wipeAll || pulled.epoch !== seen.epoch) await onWipe?.(storeId);
552
+ else emit(storeId, pulled.changes);
553
+ }
554
+ async function receive(storeId, push) {
555
+ const seen = await storage.get(stateKey(storeId));
556
+ if (!seen) return;
557
+ const sameEpoch = push.epoch === seen.epoch;
558
+ if (sameEpoch && push.clock <= seen.clock) return;
559
+ if (!sameEpoch || push.clock !== seen.clock + 1 || !push.changes) return pull(storeId, seen);
560
+ await save(storeId, push);
561
+ emit(storeId, push.changes);
562
+ }
563
+ const catchUp = (storeId) => serial(storeId, async () => pull(storeId, await storage.get(stateKey(storeId))));
564
+ const readOnly = () => {
565
+ throw new SuperLineError("FORBIDDEN", `Collection ${collection} is read-only: write through its source`);
566
+ };
567
+ return {
568
+ clustering: "self",
569
+ coordination: "cluster",
570
+ ingest: (storeId, push) => serial(storeId, () => receive(storeId, push)),
571
+ async resume(storeIds) {
572
+ const ids = storeIds ?? [...(await storage.list({ prefix })).keys()].map((key) => key.slice(prefix.length));
573
+ await Promise.all(ids.map(catchUp));
574
+ },
575
+ async snapshot(_n, query) {
576
+ const parts = conjuncts(query.filter);
577
+ const storeId = pinOf(parts, "storeId");
578
+ const table = pinOf(parts, "table");
579
+ if (typeof storeId !== "string" || table !== void 0 && typeof table !== "string")
580
+ throw new SuperLineError("VALIDATION", `A ${collection} subscription must pin storeId with eq()`);
581
+ await catchUp(storeId);
582
+ const exact = !query.orderBy?.length && parts.every(isPin) && query.limit !== void 0;
583
+ const limit = exact ? Math.min(snapshotLimit, (query.offset ?? 0) + (query.limit ?? 0)) : snapshotLimit;
584
+ const rows = await stub(storeId).snapshot(table, limit);
585
+ return applyQuery(rows.map((row) => rowOf(storeId, row)), query);
586
+ },
587
+ async read(_n, key) {
588
+ const parts = splitKey(key);
589
+ if (!parts) return void 0;
590
+ const [storeId, table, id] = parts;
591
+ const row = await stub(storeId).get(table, id);
592
+ return row && rowOf(storeId, row);
593
+ },
594
+ fixedFields: () => FIXED,
595
+ onChange(cb) {
596
+ listeners.add(cb);
597
+ return () => listeners.delete(cb);
598
+ },
599
+ apply: readOnly,
600
+ conditionalApply: readOnly
601
+ };
602
+ }
603
+
231
604
  // src/index.ts
232
605
  var MAX_PARAMS = 100;
233
606
  var MAX_COLUMNS = 98;
@@ -261,20 +634,27 @@ function cloudflareCollections(storage, collections) {
261
634
  try {
262
635
  tables.set(n, openTable(n, def));
263
636
  } catch (err) {
264
- tables.set(n, new SuperLineError("INTERNAL", `cloudflareCollections: collection '${n}' is unusable: ${err.message}`));
637
+ tables.set(n, new SuperLineError2("INTERNAL", `cloudflareCollections: collection '${n}' is unusable: ${err.message}`));
265
638
  }
266
639
  }
267
640
  return tables;
268
641
  };
269
642
  const table = (n) => {
270
643
  const t = open().get(n);
271
- if (t instanceof SuperLineError) throw t;
644
+ if (t instanceof SuperLineError2) throw t;
272
645
  return t;
273
646
  };
274
647
  const readRow = (t, id) => {
275
648
  const r = rows(t.sql.get, id)[0];
276
649
  return r ? fromRow(t, r) : void 0;
277
650
  };
651
+ const snapshot = (n, query) => {
652
+ const t = table(n);
653
+ if (!t) return [];
654
+ const q = snapshotSql(t, query, MAX_PARAMS);
655
+ const found = rows(q.sql, ...q.params).map((r) => fromRow(t, r));
656
+ return q.exact ? found : applyQuery2(found, query);
657
+ };
278
658
  const listeners = /* @__PURE__ */ new Set();
279
659
  const emit = (changes) => {
280
660
  for (const c of changes) for (const cb of listeners) cb(c);
@@ -284,14 +664,14 @@ function cloudflareCollections(storage, collections) {
284
664
  const ts = Date.now();
285
665
  for (const op of ops) {
286
666
  const t = table(op.n);
287
- if (!t) throw new SuperLineError("NOT_FOUND", `Unknown collection: ${op.n}`);
667
+ if (!t) throw new SuperLineError2("NOT_FOUND", `Unknown collection: ${op.n}`);
288
668
  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}`);
669
+ if (rows(t.sql.has, op.id).length > 0) throw new SuperLineError2("CONFLICT", `Row already exists: ${op.n}/${op.id}`);
290
670
  sql.exec(t.sql.insert, ...rowParams(t, op.id, op.row), ts, ts);
291
671
  changes.push({ n: op.n, k: "insert", id: op.id, next: op.row, origin });
292
672
  } else if (op.op === "update") {
293
673
  const prev = readRow(t, op.id);
294
- if (prev === void 0) throw new SuperLineError("NOT_FOUND", `No row: ${op.n}/${op.id}`);
674
+ if (prev === void 0) throw new SuperLineError2("NOT_FOUND", `No row: ${op.n}/${op.id}`);
295
675
  sql.exec(t.sql.update, ...updateParams(t, op.id, op.row, ts));
296
676
  changes.push({ n: op.n, k: "update", id: op.id, prev, next: op.row, origin });
297
677
  } else {
@@ -324,12 +704,18 @@ function cloudflareCollections(storage, collections) {
324
704
  emit(changes);
325
705
  return true;
326
706
  },
327
- snapshot(n, query) {
707
+ snapshot,
708
+ count(n, filter) {
709
+ const t = table(n);
710
+ if (!t) return 0;
711
+ const q = countSql(t, filter, MAX_PARAMS);
712
+ return q ? rows(q.sql, ...q.params)[0].n : snapshot(n, { filter }).length;
713
+ },
714
+ countBy(n, field, filter) {
328
715
  const t = table(n);
329
716
  if (!t) return [];
330
- const q = snapshotSql(t, query, MAX_PARAMS);
331
- const found = rows(q.sql, ...q.params).map((r) => fromRow(t, r));
332
- return q.exact ? found : applyQuery(found, query);
717
+ const q = countBySql(t, field, filter, MAX_PARAMS);
718
+ return q ? rows(q.sql, ...q.params).map(q.decode) : countValues(snapshot(n, { filter }), field);
333
719
  },
334
720
  read(n, id) {
335
721
  const t = table(n);
@@ -354,5 +740,8 @@ function cloudflareCollections(storage, collections) {
354
740
  };
355
741
  }
356
742
  export {
357
- cloudflareCollections
743
+ READ_ONLY_MESSAGE,
744
+ cloudflareCollections,
745
+ remoteRows,
746
+ syncedSql
358
747
  };
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.3.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.24.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.24.0"
45
53
  },
46
54
  "scripts": {
47
55
  "build": "tsup"