@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 +8 -0
- package/README.md +2 -2
- package/dist/index.d.ts +5 -4
- package/dist/index.js +97 -17
- package/package.json +3 -3
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
|
|
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
|
-
|
|
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`
|
|
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: `
|
|
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(`
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
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
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
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
|
|
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 =
|
|
637
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
52
|
+
"@super-line/core": "^0.24.0"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
55
|
"build": "tsup"
|