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.
- package/dist/server/d1/index.d.ts +1 -1
- package/dist/server/d1/index.js +1 -1
- package/dist/server/d1/limits.d.ts +31 -0
- package/dist/server/d1/limits.js +37 -0
- package/package.json +1 -1
- package/src/server/d1/index.ts +1 -1
- package/src/server/d1/limits.spec.ts +66 -1
- package/src/server/d1/limits.ts +36 -0
|
@@ -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';
|
package/dist/server/d1/index.js
CHANGED
|
@@ -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
|
+
};
|
package/dist/server/d1/limits.js
CHANGED
|
@@ -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.
|
|
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.",
|
package/src/server/d1/index.ts
CHANGED
|
@@ -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
|
+
|
package/src/server/d1/limits.ts
CHANGED
|
@@ -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
|
+
}
|