@evumi/berth 0.0.9 → 0.0.10
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/README.md +64 -1
- package/dist/cjs/documents/bun.d.ts +3 -0
- package/dist/cjs/documents/bun.js +54 -0
- package/dist/cjs/documents/json.d.ts +14 -0
- package/dist/cjs/documents/json.js +79 -0
- package/dist/cjs/documents/libsql.d.ts +7 -0
- package/dist/cjs/documents/libsql.js +56 -0
- package/dist/cjs/documents/query.d.ts +17 -0
- package/dist/cjs/documents/query.js +106 -0
- package/dist/cjs/documents.d.ts +66 -0
- package/dist/cjs/documents.js +187 -0
- package/dist/cjs/worker.d.ts +25 -0
- package/dist/cjs/worker.js +2 -0
- package/dist/esm/documents/bun.d.ts +3 -0
- package/dist/esm/documents/bun.js +18 -0
- package/dist/esm/documents/json.d.ts +14 -0
- package/dist/esm/documents/json.js +70 -0
- package/dist/esm/documents/libsql.d.ts +7 -0
- package/dist/esm/documents/libsql.js +20 -0
- package/dist/esm/documents/query.d.ts +17 -0
- package/dist/esm/documents/query.js +98 -0
- package/dist/esm/documents.d.ts +66 -0
- package/dist/esm/documents.js +180 -0
- package/dist/esm/worker.d.ts +25 -0
- package/dist/esm/worker.js +1 -0
- package/package.json +55 -4
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { sha256 } from './sha256.js';
|
|
2
|
+
import { DocumentError, entries, invalid, jsonText, MAX_DOCUMENT_BYTES, validateJSON } from './documents/json.js';
|
|
3
|
+
import { compileFilter, compileSort, path, sortFields, typeSQL, valueSQL } from './documents/query.js';
|
|
4
|
+
export { DocumentError, MAX_DOCUMENT_BYTES };
|
|
5
|
+
const TABLE = '"_berth_documents"';
|
|
6
|
+
const SCHEMA = `CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
7
|
+
collection TEXT NOT NULL, id TEXT NOT NULL,
|
|
8
|
+
data TEXT NOT NULL CHECK (
|
|
9
|
+
json_valid(data) AND json_type(data) = 'object' AND
|
|
10
|
+
json_type(data, '$._id') IS 'text' AND json_extract(data, '$._id') = id AND
|
|
11
|
+
length(CAST(data AS BLOB)) <= ${MAX_DOCUMENT_BYTES}
|
|
12
|
+
), PRIMARY KEY (collection, id)
|
|
13
|
+
)`;
|
|
14
|
+
/** A collection is a query namespace, not an authorization boundary. Give each
|
|
15
|
+
* project/environment its own database credential or local file. No retry occurs. */
|
|
16
|
+
export function createDocumentStore(client) {
|
|
17
|
+
const execute = async (sql, args = []) => {
|
|
18
|
+
try {
|
|
19
|
+
return (await client.execute({ sql, args })).rows;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new DocumentError('database_error', 'Document database operation failed; a write outcome may be unknown. No retry was attempted.');
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
let initialization;
|
|
26
|
+
const ready = () => initialization ??= execute(SCHEMA).then(() => { });
|
|
27
|
+
const run = async (sql, args = []) => { await ready(); return execute(sql, args); };
|
|
28
|
+
return { ready, collection(name) {
|
|
29
|
+
if (typeof name !== 'string' || !name.length || name.length > 128 || name.includes('\0'))
|
|
30
|
+
invalid('Collection name must contain 1 to 128 characters and no NUL');
|
|
31
|
+
return new DocumentCollection(name, run);
|
|
32
|
+
} };
|
|
33
|
+
}
|
|
34
|
+
function filterSQL(filter) { validateJSON(filter); return compileFilter(filter); }
|
|
35
|
+
function decode(row) {
|
|
36
|
+
try {
|
|
37
|
+
if (typeof row.data !== 'string' || typeof row.id !== 'string')
|
|
38
|
+
throw new Error();
|
|
39
|
+
const doc = JSON.parse(row.data);
|
|
40
|
+
validateJSON(doc);
|
|
41
|
+
if (doc === null || Array.isArray(doc) || typeof doc !== 'object' || doc._id !== row.id)
|
|
42
|
+
throw new Error();
|
|
43
|
+
return doc;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new DocumentError('invalid_data', 'Stored document is invalid');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export class DocumentCursor {
|
|
50
|
+
collection;
|
|
51
|
+
execute;
|
|
52
|
+
filter;
|
|
53
|
+
order;
|
|
54
|
+
take;
|
|
55
|
+
offset;
|
|
56
|
+
constructor(collection, execute, filter, order = 'id ASC', take = 100, offset = 0) {
|
|
57
|
+
this.collection = collection;
|
|
58
|
+
this.execute = execute;
|
|
59
|
+
this.filter = filter;
|
|
60
|
+
this.order = order;
|
|
61
|
+
this.take = take;
|
|
62
|
+
this.offset = offset;
|
|
63
|
+
}
|
|
64
|
+
sort(sort) {
|
|
65
|
+
return new DocumentCursor(this.collection, this.execute, this.filter, compileSort(sort), this.take, this.offset);
|
|
66
|
+
}
|
|
67
|
+
limit(n) {
|
|
68
|
+
if (!Number.isInteger(n) || n < 0 || n > 1000)
|
|
69
|
+
invalid('Limit must be an integer from 0 to 1000');
|
|
70
|
+
return new DocumentCursor(this.collection, this.execute, this.filter, this.order, n, this.offset);
|
|
71
|
+
}
|
|
72
|
+
skip(n) {
|
|
73
|
+
if (!Number.isSafeInteger(n) || n < 0 || n > 1_000_000)
|
|
74
|
+
invalid('Skip must be an integer from 0 to 1000000');
|
|
75
|
+
return new DocumentCursor(this.collection, this.execute, this.filter, this.order, this.take, n);
|
|
76
|
+
}
|
|
77
|
+
async toArray() {
|
|
78
|
+
const rows = await this.execute(`SELECT id, data FROM ${TABLE} WHERE collection = ? AND (${this.filter.sql}) ORDER BY ${this.order} LIMIT ? OFFSET ?`, [this.collection, ...this.filter.args, this.take, this.offset]);
|
|
79
|
+
return rows.map(row => decode(row));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export class DocumentCollection {
|
|
83
|
+
name;
|
|
84
|
+
execute;
|
|
85
|
+
constructor(name, execute) {
|
|
86
|
+
this.name = name;
|
|
87
|
+
this.execute = execute;
|
|
88
|
+
}
|
|
89
|
+
async insertOne(document) {
|
|
90
|
+
const input = entries(document, 'document');
|
|
91
|
+
validateJSON(document);
|
|
92
|
+
const supplied = input.find(([key]) => key === '_id');
|
|
93
|
+
const id = supplied ? supplied[1] : globalThis.crypto.randomUUID();
|
|
94
|
+
if (typeof id !== 'string' || !id.length || id.length > 256 || id.includes('\0'))
|
|
95
|
+
invalid('_id must contain 1 to 256 characters and no NUL');
|
|
96
|
+
const data = Object.fromEntries(input);
|
|
97
|
+
data._id = id;
|
|
98
|
+
await this.execute(`INSERT INTO ${TABLE} (collection, id, data) VALUES (?, ?, ?)`, [this.name, id, jsonText(data)]);
|
|
99
|
+
return { insertedId: id };
|
|
100
|
+
}
|
|
101
|
+
find(filter = {}) { return new DocumentCursor(this.name, this.execute, filterSQL(filter)); }
|
|
102
|
+
async findOne(filter = {}) { return (await this.find(filter).limit(1).toArray())[0] ?? null; }
|
|
103
|
+
async countDocuments(filter = {}) {
|
|
104
|
+
const f = filterSQL(filter);
|
|
105
|
+
const rows = await this.execute(`SELECT count(*) AS count FROM ${TABLE} WHERE collection = ? AND (${f.sql})`, [this.name, ...f.args]);
|
|
106
|
+
const n = Number(rows[0]?.count);
|
|
107
|
+
if (!Number.isSafeInteger(n) || n < 0)
|
|
108
|
+
throw new DocumentError('invalid_data', 'Document count exceeds the supported range');
|
|
109
|
+
return n;
|
|
110
|
+
}
|
|
111
|
+
async deleteOne(filter) {
|
|
112
|
+
const f = filterSQL(filter);
|
|
113
|
+
const rows = await this.execute(`DELETE FROM ${TABLE} WHERE collection = ? AND id = (SELECT id FROM ${TABLE} WHERE collection = ? AND (${f.sql}) ORDER BY id LIMIT 1) RETURNING id`, [this.name, this.name, ...f.args]);
|
|
114
|
+
return { deletedCount: rows.length };
|
|
115
|
+
}
|
|
116
|
+
async updateOne(filter, update) {
|
|
117
|
+
const f = filterSQL(filter), u = compileUpdate(update);
|
|
118
|
+
const rows = await this.execute(`UPDATE ${TABLE} SET data = ${u.sql} WHERE collection = ? AND id = (SELECT id FROM ${TABLE} WHERE collection = ? AND (${f.sql}) ORDER BY id LIMIT 1) RETURNING id`, [...u.args, this.name, this.name, ...f.args]);
|
|
119
|
+
return { matchedCount: rows.length };
|
|
120
|
+
}
|
|
121
|
+
/** Indexes cover all collections, with collection as the leading key.
|
|
122
|
+
* Identical specs share one index. Queries still bind the collection. */
|
|
123
|
+
async createIndex(spec) {
|
|
124
|
+
const fields = sortFields(spec);
|
|
125
|
+
if (!fields.some(([field]) => field === '_id'))
|
|
126
|
+
fields.push(['_id', 1]);
|
|
127
|
+
const digest = Array.from(sha256(new TextEncoder().encode(JSON.stringify(fields))), b => b.toString(16).padStart(2, '0')).join('').slice(0, 32);
|
|
128
|
+
const name = `_berth_docs_idx_${digest}`;
|
|
129
|
+
const expressions = fields.map(([field, order]) => `${valueSQL(field)} ${order === 1 ? 'ASC' : 'DESC'}`);
|
|
130
|
+
await this.execute(`CREATE INDEX IF NOT EXISTS "${name}" ON ${TABLE} (collection, ${expressions.join(', ')})`);
|
|
131
|
+
return name;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function compileUpdate(update) {
|
|
135
|
+
validateJSON(update);
|
|
136
|
+
const paths = [], guards = [], guardArgs = [], args = [];
|
|
137
|
+
let expression = 'data';
|
|
138
|
+
for (const [operator, value] of entries(update, 'update')) {
|
|
139
|
+
if (!['$set', '$unset', '$inc'].includes(operator))
|
|
140
|
+
invalid('Unsupported update operator');
|
|
141
|
+
for (const [field, operand] of entries(value, 'update fields')) {
|
|
142
|
+
const jsonPath = path(field);
|
|
143
|
+
if (field === '_id' || field.startsWith('_id.'))
|
|
144
|
+
invalid('_id is immutable');
|
|
145
|
+
if (paths.some(p => p === field || p.startsWith(field + '.') || field.startsWith(p + '.')))
|
|
146
|
+
invalid('Update paths must not overlap');
|
|
147
|
+
paths.push(field);
|
|
148
|
+
if (paths.length > 32)
|
|
149
|
+
invalid('Update exceeds 32 fields');
|
|
150
|
+
const segments = field.split('.');
|
|
151
|
+
for (let i = 1; i < segments.length; i++) {
|
|
152
|
+
const parent = typeSQL(segments.slice(0, i).join('.'));
|
|
153
|
+
guards.push(`(${parent} IS NULL OR ${parent} = 'object')`);
|
|
154
|
+
}
|
|
155
|
+
if (operator === '$set') {
|
|
156
|
+
expression = `json_set(${expression}, '${jsonPath}', json(?))`;
|
|
157
|
+
args.push(jsonText(operand));
|
|
158
|
+
}
|
|
159
|
+
else if (operator === '$unset') {
|
|
160
|
+
if (operand !== true && operand !== 1)
|
|
161
|
+
invalid('$unset values must be true or 1');
|
|
162
|
+
expression = `json_remove(${expression}, '${jsonPath}')`;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
if (typeof operand !== 'number' || !Number.isFinite(operand))
|
|
166
|
+
invalid('$inc requires a finite number');
|
|
167
|
+
const type = typeSQL(field), current = valueSQL(field);
|
|
168
|
+
guards.push(`(${type} IS NULL OR ${type} IN ('integer', 'real'))`);
|
|
169
|
+
guards.push(`(coalesce(${current}, 0) + ? BETWEEN ? AND ?)`);
|
|
170
|
+
guardArgs.push(operand, -Number.MAX_VALUE, Number.MAX_VALUE);
|
|
171
|
+
expression = `json_set(${expression}, '${jsonPath}', coalesce(${current}, 0) + ?)`;
|
|
172
|
+
args.push(operand);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!paths.length)
|
|
177
|
+
invalid('Update must change at least one field');
|
|
178
|
+
// A bad selected target aborts this one statement, including all other fields.
|
|
179
|
+
return { sql: guards.length ? `CASE WHEN ${guards.join(' AND ')} THEN ${expression} ELSE json('invalid document update') END` : expression, args: [...guardArgs, ...args] };
|
|
180
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** State lives only in one running project/environment process and is lost on restart. */
|
|
2
|
+
export interface WorkerRuntime {
|
|
3
|
+
readonly state: Map<string, unknown>;
|
|
4
|
+
readonly startedAt: Date;
|
|
5
|
+
}
|
|
6
|
+
/** A fresh context and state map are created for each HTTP request. */
|
|
7
|
+
export interface FunctionContext {
|
|
8
|
+
readonly request: Request & {
|
|
9
|
+
readonly params: Record<string, string>;
|
|
10
|
+
};
|
|
11
|
+
readonly params: Record<string, string>;
|
|
12
|
+
readonly query: URLSearchParams;
|
|
13
|
+
readonly state: Map<string, unknown>;
|
|
14
|
+
readonly runtime: WorkerRuntime;
|
|
15
|
+
}
|
|
16
|
+
export type FunctionHandler = (context: FunctionContext) => unknown | Promise<unknown>;
|
|
17
|
+
/** May be called once. Direct imports between functions do not re-enter interception. */
|
|
18
|
+
export type Next = (context?: FunctionContext) => Promise<Response>;
|
|
19
|
+
export type Interceptor = (context: FunctionContext, next: Next) => unknown | Promise<unknown>;
|
|
20
|
+
export interface WorkerLifecycle {
|
|
21
|
+
default?: Interceptor;
|
|
22
|
+
onStart?(runtime: WorkerRuntime): unknown | Promise<unknown>;
|
|
23
|
+
onStop?(runtime: WorkerRuntime): unknown | Promise<unknown>;
|
|
24
|
+
onError?(error: unknown, context: FunctionContext): unknown | Promise<unknown>;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evumi/berth",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.10",
|
|
4
|
+
"description": "Berth worker types, document queries on SQLite/libSQL, Queues, Alarms, and signed delivery verification.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -26,6 +26,46 @@
|
|
|
26
26
|
"types": "./dist/cjs/index.d.ts",
|
|
27
27
|
"default": "./dist/cjs/index.js"
|
|
28
28
|
}
|
|
29
|
+
},
|
|
30
|
+
"./documents": {
|
|
31
|
+
"import": {
|
|
32
|
+
"types": "./dist/esm/documents.d.ts",
|
|
33
|
+
"default": "./dist/esm/documents.js"
|
|
34
|
+
},
|
|
35
|
+
"require": {
|
|
36
|
+
"types": "./dist/cjs/documents.d.ts",
|
|
37
|
+
"default": "./dist/cjs/documents.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"./documents/bun": {
|
|
41
|
+
"import": {
|
|
42
|
+
"types": "./dist/esm/documents/bun.d.ts",
|
|
43
|
+
"default": "./dist/esm/documents/bun.js"
|
|
44
|
+
},
|
|
45
|
+
"require": {
|
|
46
|
+
"types": "./dist/cjs/documents/bun.d.ts",
|
|
47
|
+
"default": "./dist/cjs/documents/bun.js"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"./documents/libsql": {
|
|
51
|
+
"import": {
|
|
52
|
+
"types": "./dist/esm/documents/libsql.d.ts",
|
|
53
|
+
"default": "./dist/esm/documents/libsql.js"
|
|
54
|
+
},
|
|
55
|
+
"require": {
|
|
56
|
+
"types": "./dist/cjs/documents/libsql.d.ts",
|
|
57
|
+
"default": "./dist/cjs/documents/libsql.js"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"./worker": {
|
|
61
|
+
"import": {
|
|
62
|
+
"types": "./dist/esm/worker.d.ts",
|
|
63
|
+
"default": "./dist/esm/worker.js"
|
|
64
|
+
},
|
|
65
|
+
"require": {
|
|
66
|
+
"types": "./dist/cjs/worker.d.ts",
|
|
67
|
+
"default": "./dist/cjs/worker.js"
|
|
68
|
+
}
|
|
29
69
|
}
|
|
30
70
|
},
|
|
31
71
|
"files": [
|
|
@@ -35,14 +75,25 @@
|
|
|
35
75
|
"scripts": {
|
|
36
76
|
"build": "rm -rf dist && tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && node scripts/cjs-package.mjs",
|
|
37
77
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
|
-
"test": "pnpm run build && vitest run"
|
|
78
|
+
"test": "pnpm run build && vitest run",
|
|
79
|
+
"bench:documents": "bun bench/documents.ts",
|
|
80
|
+
"test:bun": "bun test/documents.bun.ts && bun test/documents.concurrency.bun.ts"
|
|
39
81
|
},
|
|
40
82
|
"devDependencies": {
|
|
41
83
|
"@types/node": "^26.6.5",
|
|
42
84
|
"typescript": "6.0.3",
|
|
43
|
-
"vitest": "^5.0.5"
|
|
85
|
+
"vitest": "^5.0.5",
|
|
86
|
+
"@libsql/client": "^0.17.0"
|
|
44
87
|
},
|
|
45
88
|
"publishConfig": {
|
|
46
89
|
"access": "public"
|
|
90
|
+
},
|
|
91
|
+
"peerDependencies": {
|
|
92
|
+
"@libsql/client": "^0.17.0"
|
|
93
|
+
},
|
|
94
|
+
"peerDependenciesMeta": {
|
|
95
|
+
"@libsql/client": {
|
|
96
|
+
"optional": true
|
|
97
|
+
}
|
|
47
98
|
}
|
|
48
99
|
}
|