cursedbelt-server 4.14.0 → 4.15.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.
@@ -16,7 +16,7 @@
16
16
  */
17
17
  export { type BackupPoint, createTimeTravelBackup, type DatabaseBackup, type TimeTravelOpts, } from './backup.js';
18
18
  export { createPendingWrites, type InvocationD1, type PendingWrites, perInvocation, type WriteCollector } from './invocation.js';
19
- export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits.js';
19
+ export { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
20
20
  export { createLocalD1, refuseInteractiveTransaction } from './local.js';
21
21
  export { createRemoteD1, type D1BindingLike, type D1BindingMeta, type D1BindingResult, type D1BindingStatement, } from './remote.js';
22
22
  export { classifySchedule, cronFieldCount, jobsForCron, type PortableJob, type PortableJobContext, type ScheduleVerdict, toCronTriggers, toFiveField, type TriggerShape, type WorkersPrimitive, } from './scheduling.js';
@@ -34,7 +34,7 @@ export { createTimeTravelBackup, } from './backup.js';
34
34
  // of them, since business tables stay on raw statements. Measured 2026-09-18 against the
35
35
  // published 4.3.0 tarball. `barrelsReachNoOptionalPeer.spec.ts` is what keeps it out.
36
36
  export { createPendingWrites, perInvocation } from './invocation.js';
37
- export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits.js';
37
+ export { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
38
38
  export { createLocalD1, refuseInteractiveTransaction } from './local.js';
39
39
  export { createRemoteD1, } from './remote.js';
40
40
  export { classifySchedule, cronFieldCount, jobsForCron, toCronTriggers, toFiveField, } from './scheduling.js';
@@ -54,3 +54,34 @@ export declare function assertBatchSize(count: number): void;
54
54
  * ```
55
55
  */
56
56
  export declare function chunkForBind<T>(items: readonly T[], paramsPerItem: number): T[][];
57
+ /**
58
+ * `column IN (…)` over a list of ANY length, as ONE bound parameter.
59
+ *
60
+ * ```ts
61
+ * const ids = inArray(itemIds);
62
+ * await db.prepare(`UPDATE items SET deleted_at = ? WHERE id ${ids.sql}`).bind(now, ids.param).run();
63
+ * ```
64
+ *
65
+ * 🔴 **Why this and not {@link chunkForBind} for a set of ids.** An `IN (?, ?, …)` built by
66
+ * `ids.map(() => '?')` binds one parameter per id, so it is green on every fixture and a 500 on
67
+ * the 101st id — the cap is asserted on both drivers, and real D1 enforces the same one.
68
+ * Chunking fixes a bulk INSERT, but it cannot fix a READ that must be one statement (a
69
+ * `WHERE … ORDER BY … LIMIT ? OFFSET ?` page and the `COUNT(*)` beside it), and on a write
70
+ * it trades one atomic statement for a batch that is atomic only per chunk. `json_each`
71
+ * over a JSON array is one parameter however long the list is, keeps the statement whole,
72
+ * and SQLite still drives the column's index through the materialised set.
73
+ *
74
+ * The array travels as one TEXT value, so the ceiling moves from 100 ids to
75
+ * `LIMITS.valueBytes` (2 MB) of JSON — about 70,000 of this fleet's 25-character ids — and
76
+ * past that `assertWithinLimits` refuses it at `bind()` with the byte count, on both drivers.
77
+ * An empty list is valid SQL and matches nothing, so no caller needs an `IN ()` special case.
78
+ *
79
+ * Only strings and finite numbers: `json_each` yields TEXT for a string and INTEGER/REAL for
80
+ * a number, which is what makes `id IN …` compare like `id = ?` would. Anything else would
81
+ * compare as something the caller did not write, so it is refused here rather than matched
82
+ * silently against nothing.
83
+ */
84
+ export declare function inArray(values: readonly (string | number)[]): {
85
+ sql: string;
86
+ param: string;
87
+ };
@@ -94,3 +94,40 @@ export function chunkForBind(items, paramsPerItem) {
94
94
  out.push(items.slice(i, i + size));
95
95
  return out;
96
96
  }
97
+ /**
98
+ * `column IN (…)` over a list of ANY length, as ONE bound parameter.
99
+ *
100
+ * ```ts
101
+ * const ids = inArray(itemIds);
102
+ * await db.prepare(`UPDATE items SET deleted_at = ? WHERE id ${ids.sql}`).bind(now, ids.param).run();
103
+ * ```
104
+ *
105
+ * 🔴 **Why this and not {@link chunkForBind} for a set of ids.** An `IN (?, ?, …)` built by
106
+ * `ids.map(() => '?')` binds one parameter per id, so it is green on every fixture and a 500 on
107
+ * the 101st id — the cap is asserted on both drivers, and real D1 enforces the same one.
108
+ * Chunking fixes a bulk INSERT, but it cannot fix a READ that must be one statement (a
109
+ * `WHERE … ORDER BY … LIMIT ? OFFSET ?` page and the `COUNT(*)` beside it), and on a write
110
+ * it trades one atomic statement for a batch that is atomic only per chunk. `json_each`
111
+ * over a JSON array is one parameter however long the list is, keeps the statement whole,
112
+ * and SQLite still drives the column's index through the materialised set.
113
+ *
114
+ * The array travels as one TEXT value, so the ceiling moves from 100 ids to
115
+ * `LIMITS.valueBytes` (2 MB) of JSON — about 70,000 of this fleet's 25-character ids — and
116
+ * past that `assertWithinLimits` refuses it at `bind()` with the byte count, on both drivers.
117
+ * An empty list is valid SQL and matches nothing, so no caller needs an `IN ()` special case.
118
+ *
119
+ * Only strings and finite numbers: `json_each` yields TEXT for a string and INTEGER/REAL for
120
+ * a number, which is what makes `id IN …` compare like `id = ?` would. Anything else would
121
+ * compare as something the caller did not write, so it is refused here rather than matched
122
+ * silently against nothing.
123
+ */
124
+ export function inArray(values) {
125
+ for (const v of values) {
126
+ if (typeof v === 'string')
127
+ continue;
128
+ if (typeof v === 'number' && Number.isFinite(v))
129
+ continue;
130
+ throw new TypeError(`inArray takes strings and finite numbers, got ${typeof v}: ${String(v)}`);
131
+ }
132
+ return { sql: 'IN (SELECT value FROM json_each(?))', param: JSON.stringify(values) };
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.14.0",
3
+ "version": "4.15.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -40,7 +40,7 @@ export {
40
40
  // of them, since business tables stay on raw statements. Measured 2026-09-18 against the
41
41
  // published 4.3.0 tarball. `barrelsReachNoOptionalPeer.spec.ts` is what keeps it out.
42
42
  export { createPendingWrites, type InvocationD1, type PendingWrites, perInvocation, type WriteCollector } from './invocation.js';
43
- export { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits.js';
43
+ export { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
44
44
  export { createLocalD1, refuseInteractiveTransaction } from './local.js';
45
45
  export {
46
46
  createRemoteD1,
@@ -1,6 +1,6 @@
1
1
  import { Database } from 'bun:sqlite';
2
2
  import { describe, expect, test } from 'bun:test';
3
- import { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits.js';
3
+ import { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
4
4
  import { createLocalD1 } from './local.js';
5
5
  import { D1LimitError } from './types.js';
6
6
 
@@ -87,4 +87,69 @@ describe('D1 limits are enforced on the LOCAL driver too', () => {
87
87
  expect(() => chunkForBind([1], 1.5)).toThrow(RangeError);
88
88
  });
89
89
  });
90
+
91
+ describe('inArray binds a list of any length as ONE parameter', () => {
92
+ const seeded = (n: number) => {
93
+ const db = new Database(':memory:');
94
+ db.run('CREATE TABLE items (id TEXT PRIMARY KEY, n INTEGER, deleted_at INTEGER)');
95
+ const insert = db.prepare('INSERT INTO items (id, n) VALUES (?, ?)');
96
+ for (let i = 0; i < n; i++) insert.run(`itm_${String(i).padStart(4, '0')}`, i);
97
+ return { raw: db, seam: createLocalD1(db) };
98
+ };
99
+ const ids = (n: number) => Array.from({ length: n }, (_, i) => `itm_${String(i).padStart(4, '0')}`);
100
+
101
+ test('the shape it replaces is refused at 150 ids — the red this exists for', () => {
102
+ const { seam } = seeded(150);
103
+ const holes = ids(150).map(() => '?').join(', ');
104
+ expect(() => seam.prepare(`SELECT id FROM items WHERE id IN (${holes})`).bind(...ids(150))).toThrow(
105
+ D1LimitError,
106
+ );
107
+ });
108
+
109
+ test('a 150-id read under ORDER BY / LIMIT / OFFSET selects exactly those rows', async () => {
110
+ const { seam } = seeded(400);
111
+ const set = inArray(ids(150));
112
+ const page = await seam
113
+ .prepare(`SELECT id FROM items WHERE id ${set.sql} ORDER BY id LIMIT ? OFFSET ?`)
114
+ .bind(set.param, 100, 20)
115
+ .all<{ id: string }>();
116
+ expect(page.results.map((r) => r.id)).toEqual(ids(150).slice(20, 120));
117
+ const count = await seam
118
+ .prepare(`SELECT COUNT(*) AS n FROM items WHERE id ${set.sql}`)
119
+ .bind(set.param)
120
+ .first<{ n: number }>();
121
+ expect(count?.n).toBe(150);
122
+ });
123
+
124
+ test('a 1,000-id UPDATE is one statement and touches only those rows', async () => {
125
+ const { raw, seam } = seeded(1_200);
126
+ const set = inArray(ids(1_000));
127
+ await seam.prepare(`UPDATE items SET deleted_at = ? WHERE id ${set.sql}`).bind(7, set.param).run();
128
+ expect(raw.query('SELECT COUNT(*) AS n FROM items WHERE deleted_at = 7').get()).toEqual({ n: 1_000 });
129
+ expect(raw.query('SELECT COUNT(*) AS n FROM items WHERE deleted_at IS NULL').get()).toEqual({ n: 200 });
130
+ });
131
+
132
+ test('numbers compare as numbers, and an empty list is valid SQL that matches nothing', async () => {
133
+ const { seam } = seeded(10);
134
+ const nums = inArray([2, 4, 6]);
135
+ const hit = await seam.prepare(`SELECT id FROM items WHERE n ${nums.sql} ORDER BY n`).bind(nums.param).all<{ id: string }>();
136
+ expect(hit.results.map((r) => r.id)).toEqual(['itm_0002', 'itm_0004', 'itm_0006']);
137
+ const none = inArray([]);
138
+ const empty = await seam.prepare(`SELECT id FROM items WHERE id ${none.sql}`).bind(none.param).all();
139
+ expect(empty.results).toEqual([]);
140
+ });
141
+
142
+ test('a value it cannot compare honestly is refused, not matched against nothing', () => {
143
+ expect(() => inArray([null as unknown as string])).toThrow(TypeError);
144
+ expect(() => inArray([Number.NaN])).toThrow(TypeError);
145
+ expect(() => inArray([{} as unknown as string])).toThrow(TypeError);
146
+ });
147
+
148
+ test('past 2 MB of JSON the seam refuses it at bind(), naming the byte count', () => {
149
+ const { seam } = seeded(1);
150
+ const huge = inArray(Array.from({ length: 90_000 }, (_, i) => `itm_${String(i).padStart(20, '0')}`));
151
+ expect(() => seam.prepare(`SELECT id FROM items WHERE id ${huge.sql}`).bind(huge.param)).toThrow(/value size/);
152
+ });
153
+ });
90
154
  });
155
+
@@ -121,3 +121,39 @@ export function chunkForBind<T>(items: readonly T[], paramsPerItem: number): T[]
121
121
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size) as T[]);
122
122
  return out;
123
123
  }
124
+
125
+ /**
126
+ * `column IN (…)` over a list of ANY length, as ONE bound parameter.
127
+ *
128
+ * ```ts
129
+ * const ids = inArray(itemIds);
130
+ * await db.prepare(`UPDATE items SET deleted_at = ? WHERE id ${ids.sql}`).bind(now, ids.param).run();
131
+ * ```
132
+ *
133
+ * 🔴 **Why this and not {@link chunkForBind} for a set of ids.** An `IN (?, ?, …)` built by
134
+ * `ids.map(() => '?')` binds one parameter per id, so it is green on every fixture and a 500 on
135
+ * the 101st id — the cap is asserted on both drivers, and real D1 enforces the same one.
136
+ * Chunking fixes a bulk INSERT, but it cannot fix a READ that must be one statement (a
137
+ * `WHERE … ORDER BY … LIMIT ? OFFSET ?` page and the `COUNT(*)` beside it), and on a write
138
+ * it trades one atomic statement for a batch that is atomic only per chunk. `json_each`
139
+ * over a JSON array is one parameter however long the list is, keeps the statement whole,
140
+ * and SQLite still drives the column's index through the materialised set.
141
+ *
142
+ * The array travels as one TEXT value, so the ceiling moves from 100 ids to
143
+ * `LIMITS.valueBytes` (2 MB) of JSON — about 70,000 of this fleet's 25-character ids — and
144
+ * past that `assertWithinLimits` refuses it at `bind()` with the byte count, on both drivers.
145
+ * An empty list is valid SQL and matches nothing, so no caller needs an `IN ()` special case.
146
+ *
147
+ * Only strings and finite numbers: `json_each` yields TEXT for a string and INTEGER/REAL for
148
+ * a number, which is what makes `id IN …` compare like `id = ?` would. Anything else would
149
+ * compare as something the caller did not write, so it is refused here rather than matched
150
+ * silently against nothing.
151
+ */
152
+ export function inArray(values: readonly (string | number)[]): { sql: string; param: string } {
153
+ for (const v of values) {
154
+ if (typeof v === 'string') continue;
155
+ if (typeof v === 'number' && Number.isFinite(v)) continue;
156
+ throw new TypeError(`inArray takes strings and finite numbers, got ${typeof v}: ${String(v)}`);
157
+ }
158
+ return { sql: 'IN (SELECT value FROM json_each(?))', param: JSON.stringify(values) };
159
+ }