@super-line/collections-cloudflare 0.2.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,14 @@
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
+
6
14
  ## [collections-cloudflare-v0.2.0] — 2026-10-20
7
15
 
8
16
  ### Features
package/README.md CHANGED
@@ -63,7 +63,7 @@ export class Store extends DurableObject<Env> {
63
63
  },
64
64
  })
65
65
  query(statements: Statement[], opts?: QueryOptions) { return this.sync.query(statements, opts) }
66
- snapshot(table: string, limit: number) { return this.sync.snapshot(table, limit) }
66
+ snapshot(table: string | undefined, limit: number) { return this.sync.snapshot(table, limit) }
67
67
  get(table: string, id: string) { return this.sync.get(table, id) }
68
68
  changesSince(clock: number) { return this.sync.changesSince(clock) }
69
69
  }
@@ -95,7 +95,7 @@ export class Node extends createDurableNode((ctx, env, base) => {
95
95
  }
96
96
  ```
97
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.
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
99
 
100
100
  - 📖 Guide: [Host a node on Cloudflare Durable Objects](https://super-line.dogar.biz/how-to/cloudflare-durable-objects)
101
101
  - 📖 Guide: [Serve another Durable Object's tables](https://super-line.dogar.biz/how-to/cloudflare-remote-rows)
package/dist/index.d.ts CHANGED
@@ -47,7 +47,8 @@ interface SyncedSqlOptions {
47
47
  }
48
48
  interface SyncedSql {
49
49
  query(statements: Statement[], opts?: QueryOptions): Outcome;
50
- snapshot(table: string, limit: number): RemoteRow[];
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[];
51
52
  get(table: string, id: string): RemoteRow | undefined;
52
53
  changesSince(clock: number): RemoteChanges;
53
54
  }
@@ -74,7 +75,7 @@ declare function syncedSql(ctx: DurableObjectState, opts?: SyncedSqlOptions): Sy
74
75
  type Awaitable<T> = T | Promise<T>;
75
76
  /** A source as the node reaches it: `syncedSql`'s reads, over RPC. A Durable Object stub satisfies it. */
76
77
  interface RemoteSource {
77
- snapshot(table: string, limit: number): Awaitable<RemoteRow[]>;
78
+ snapshot(table: string | undefined, limit: number): Awaitable<RemoteRow[]>;
78
79
  get(table: string, id: string): Awaitable<RemoteRow | undefined>;
79
80
  changesSince(clock: number): Awaitable<RemoteChanges>;
80
81
  }
@@ -116,8 +117,8 @@ interface RemoteRowsStore extends SelfCollectionStore {
116
117
  * `changesSince` when a push skips a clock, carries no changes or comes from a new epoch. A store's feed starts at
117
118
  * its first subscribe, and every subscribe pulls first.
118
119
  *
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
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
121
122
  * match — unless its filter rules out the row's `storeId` or `table`, which never change for a key. Writes throw
122
123
  * `FORBIDDEN`: they go through the source's own path.
123
124
  *
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import { SuperLineError as SuperLineError2, applyQuery as applyQuery2, 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);
@@ -417,9 +480,13 @@ function syncedSql(ctx, opts = {}) {
417
480
  },
418
481
  snapshot(table, limit) {
419
482
  ensure();
420
- const key = userTables().get(table);
421
- if (key === void 0) return [];
422
- return read(table, `SELECT ${key}, * FROM ${ident(table)} LIMIT ?`, limit);
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;
423
490
  },
424
491
  get(table, id) {
425
492
  ensure();
@@ -509,8 +576,8 @@ function remoteRows(opts) {
509
576
  const parts = conjuncts(query.filter);
510
577
  const storeId = pinOf(parts, "storeId");
511
578
  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()`);
579
+ if (typeof storeId !== "string" || table !== void 0 && typeof table !== "string")
580
+ throw new SuperLineError("VALIDATION", `A ${collection} subscription must pin storeId with eq()`);
514
581
  await catchUp(storeId);
515
582
  const exact = !query.orderBy?.length && parts.every(isPin) && query.limit !== void 0;
516
583
  const limit = exact ? Math.min(snapshotLimit, (query.offset ?? 0) + (query.limit ?? 0)) : snapshotLimit;
@@ -581,6 +648,13 @@ function cloudflareCollections(storage, collections) {
581
648
  const r = rows(t.sql.get, id)[0];
582
649
  return r ? fromRow(t, r) : void 0;
583
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
+ };
584
658
  const listeners = /* @__PURE__ */ new Set();
585
659
  const emit = (changes) => {
586
660
  for (const c of changes) for (const cb of listeners) cb(c);
@@ -630,12 +704,18 @@ function cloudflareCollections(storage, collections) {
630
704
  emit(changes);
631
705
  return true;
632
706
  },
633
- 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) {
634
715
  const t = table(n);
635
716
  if (!t) return [];
636
- const q = snapshotSql(t, query, MAX_PARAMS);
637
- const found = rows(q.sql, ...q.params).map((r) => fromRow(t, r));
638
- return q.exact ? found : applyQuery2(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);
639
719
  },
640
720
  read(n, id) {
641
721
  const t = table(n);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@super-line/collections-cloudflare",
3
- "version": "0.2.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",
@@ -45,11 +45,11 @@
45
45
  "zod": "^4.2.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@super-line/core": ">=0.23.0 <1.0.0"
48
+ "@super-line/core": ">=0.24.0 <1.0.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@cloudflare/workers-types": "^5.20261013.1",
52
- "@super-line/core": "^0.23.0"
52
+ "@super-line/core": "^0.24.0"
53
53
  },
54
54
  "scripts": {
55
55
  "build": "tsup"