@evumi/berth 0.0.8 → 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,187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DocumentCollection = exports.DocumentCursor = exports.MAX_DOCUMENT_BYTES = exports.DocumentError = void 0;
|
|
4
|
+
exports.createDocumentStore = createDocumentStore;
|
|
5
|
+
const sha256_js_1 = require("./sha256.js");
|
|
6
|
+
const json_js_1 = require("./documents/json.js");
|
|
7
|
+
Object.defineProperty(exports, "DocumentError", { enumerable: true, get: function () { return json_js_1.DocumentError; } });
|
|
8
|
+
Object.defineProperty(exports, "MAX_DOCUMENT_BYTES", { enumerable: true, get: function () { return json_js_1.MAX_DOCUMENT_BYTES; } });
|
|
9
|
+
const query_js_1 = require("./documents/query.js");
|
|
10
|
+
const TABLE = '"_berth_documents"';
|
|
11
|
+
const SCHEMA = `CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
12
|
+
collection TEXT NOT NULL, id TEXT NOT NULL,
|
|
13
|
+
data TEXT NOT NULL CHECK (
|
|
14
|
+
json_valid(data) AND json_type(data) = 'object' AND
|
|
15
|
+
json_type(data, '$._id') IS 'text' AND json_extract(data, '$._id') = id AND
|
|
16
|
+
length(CAST(data AS BLOB)) <= ${json_js_1.MAX_DOCUMENT_BYTES}
|
|
17
|
+
), PRIMARY KEY (collection, id)
|
|
18
|
+
)`;
|
|
19
|
+
/** A collection is a query namespace, not an authorization boundary. Give each
|
|
20
|
+
* project/environment its own database credential or local file. No retry occurs. */
|
|
21
|
+
function createDocumentStore(client) {
|
|
22
|
+
const execute = async (sql, args = []) => {
|
|
23
|
+
try {
|
|
24
|
+
return (await client.execute({ sql, args })).rows;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new json_js_1.DocumentError('database_error', 'Document database operation failed; a write outcome may be unknown. No retry was attempted.');
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
let initialization;
|
|
31
|
+
const ready = () => initialization ??= execute(SCHEMA).then(() => { });
|
|
32
|
+
const run = async (sql, args = []) => { await ready(); return execute(sql, args); };
|
|
33
|
+
return { ready, collection(name) {
|
|
34
|
+
if (typeof name !== 'string' || !name.length || name.length > 128 || name.includes('\0'))
|
|
35
|
+
(0, json_js_1.invalid)('Collection name must contain 1 to 128 characters and no NUL');
|
|
36
|
+
return new DocumentCollection(name, run);
|
|
37
|
+
} };
|
|
38
|
+
}
|
|
39
|
+
function filterSQL(filter) { (0, json_js_1.validateJSON)(filter); return (0, query_js_1.compileFilter)(filter); }
|
|
40
|
+
function decode(row) {
|
|
41
|
+
try {
|
|
42
|
+
if (typeof row.data !== 'string' || typeof row.id !== 'string')
|
|
43
|
+
throw new Error();
|
|
44
|
+
const doc = JSON.parse(row.data);
|
|
45
|
+
(0, json_js_1.validateJSON)(doc);
|
|
46
|
+
if (doc === null || Array.isArray(doc) || typeof doc !== 'object' || doc._id !== row.id)
|
|
47
|
+
throw new Error();
|
|
48
|
+
return doc;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new json_js_1.DocumentError('invalid_data', 'Stored document is invalid');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
class DocumentCursor {
|
|
55
|
+
collection;
|
|
56
|
+
execute;
|
|
57
|
+
filter;
|
|
58
|
+
order;
|
|
59
|
+
take;
|
|
60
|
+
offset;
|
|
61
|
+
constructor(collection, execute, filter, order = 'id ASC', take = 100, offset = 0) {
|
|
62
|
+
this.collection = collection;
|
|
63
|
+
this.execute = execute;
|
|
64
|
+
this.filter = filter;
|
|
65
|
+
this.order = order;
|
|
66
|
+
this.take = take;
|
|
67
|
+
this.offset = offset;
|
|
68
|
+
}
|
|
69
|
+
sort(sort) {
|
|
70
|
+
return new DocumentCursor(this.collection, this.execute, this.filter, (0, query_js_1.compileSort)(sort), this.take, this.offset);
|
|
71
|
+
}
|
|
72
|
+
limit(n) {
|
|
73
|
+
if (!Number.isInteger(n) || n < 0 || n > 1000)
|
|
74
|
+
(0, json_js_1.invalid)('Limit must be an integer from 0 to 1000');
|
|
75
|
+
return new DocumentCursor(this.collection, this.execute, this.filter, this.order, n, this.offset);
|
|
76
|
+
}
|
|
77
|
+
skip(n) {
|
|
78
|
+
if (!Number.isSafeInteger(n) || n < 0 || n > 1_000_000)
|
|
79
|
+
(0, json_js_1.invalid)('Skip must be an integer from 0 to 1000000');
|
|
80
|
+
return new DocumentCursor(this.collection, this.execute, this.filter, this.order, this.take, n);
|
|
81
|
+
}
|
|
82
|
+
async toArray() {
|
|
83
|
+
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]);
|
|
84
|
+
return rows.map(row => decode(row));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
exports.DocumentCursor = DocumentCursor;
|
|
88
|
+
class DocumentCollection {
|
|
89
|
+
name;
|
|
90
|
+
execute;
|
|
91
|
+
constructor(name, execute) {
|
|
92
|
+
this.name = name;
|
|
93
|
+
this.execute = execute;
|
|
94
|
+
}
|
|
95
|
+
async insertOne(document) {
|
|
96
|
+
const input = (0, json_js_1.entries)(document, 'document');
|
|
97
|
+
(0, json_js_1.validateJSON)(document);
|
|
98
|
+
const supplied = input.find(([key]) => key === '_id');
|
|
99
|
+
const id = supplied ? supplied[1] : globalThis.crypto.randomUUID();
|
|
100
|
+
if (typeof id !== 'string' || !id.length || id.length > 256 || id.includes('\0'))
|
|
101
|
+
(0, json_js_1.invalid)('_id must contain 1 to 256 characters and no NUL');
|
|
102
|
+
const data = Object.fromEntries(input);
|
|
103
|
+
data._id = id;
|
|
104
|
+
await this.execute(`INSERT INTO ${TABLE} (collection, id, data) VALUES (?, ?, ?)`, [this.name, id, (0, json_js_1.jsonText)(data)]);
|
|
105
|
+
return { insertedId: id };
|
|
106
|
+
}
|
|
107
|
+
find(filter = {}) { return new DocumentCursor(this.name, this.execute, filterSQL(filter)); }
|
|
108
|
+
async findOne(filter = {}) { return (await this.find(filter).limit(1).toArray())[0] ?? null; }
|
|
109
|
+
async countDocuments(filter = {}) {
|
|
110
|
+
const f = filterSQL(filter);
|
|
111
|
+
const rows = await this.execute(`SELECT count(*) AS count FROM ${TABLE} WHERE collection = ? AND (${f.sql})`, [this.name, ...f.args]);
|
|
112
|
+
const n = Number(rows[0]?.count);
|
|
113
|
+
if (!Number.isSafeInteger(n) || n < 0)
|
|
114
|
+
throw new json_js_1.DocumentError('invalid_data', 'Document count exceeds the supported range');
|
|
115
|
+
return n;
|
|
116
|
+
}
|
|
117
|
+
async deleteOne(filter) {
|
|
118
|
+
const f = filterSQL(filter);
|
|
119
|
+
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]);
|
|
120
|
+
return { deletedCount: rows.length };
|
|
121
|
+
}
|
|
122
|
+
async updateOne(filter, update) {
|
|
123
|
+
const f = filterSQL(filter), u = compileUpdate(update);
|
|
124
|
+
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]);
|
|
125
|
+
return { matchedCount: rows.length };
|
|
126
|
+
}
|
|
127
|
+
/** Indexes cover all collections, with collection as the leading key.
|
|
128
|
+
* Identical specs share one index. Queries still bind the collection. */
|
|
129
|
+
async createIndex(spec) {
|
|
130
|
+
const fields = (0, query_js_1.sortFields)(spec);
|
|
131
|
+
if (!fields.some(([field]) => field === '_id'))
|
|
132
|
+
fields.push(['_id', 1]);
|
|
133
|
+
const digest = Array.from((0, sha256_js_1.sha256)(new TextEncoder().encode(JSON.stringify(fields))), b => b.toString(16).padStart(2, '0')).join('').slice(0, 32);
|
|
134
|
+
const name = `_berth_docs_idx_${digest}`;
|
|
135
|
+
const expressions = fields.map(([field, order]) => `${(0, query_js_1.valueSQL)(field)} ${order === 1 ? 'ASC' : 'DESC'}`);
|
|
136
|
+
await this.execute(`CREATE INDEX IF NOT EXISTS "${name}" ON ${TABLE} (collection, ${expressions.join(', ')})`);
|
|
137
|
+
return name;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
exports.DocumentCollection = DocumentCollection;
|
|
141
|
+
function compileUpdate(update) {
|
|
142
|
+
(0, json_js_1.validateJSON)(update);
|
|
143
|
+
const paths = [], guards = [], guardArgs = [], args = [];
|
|
144
|
+
let expression = 'data';
|
|
145
|
+
for (const [operator, value] of (0, json_js_1.entries)(update, 'update')) {
|
|
146
|
+
if (!['$set', '$unset', '$inc'].includes(operator))
|
|
147
|
+
(0, json_js_1.invalid)('Unsupported update operator');
|
|
148
|
+
for (const [field, operand] of (0, json_js_1.entries)(value, 'update fields')) {
|
|
149
|
+
const jsonPath = (0, query_js_1.path)(field);
|
|
150
|
+
if (field === '_id' || field.startsWith('_id.'))
|
|
151
|
+
(0, json_js_1.invalid)('_id is immutable');
|
|
152
|
+
if (paths.some(p => p === field || p.startsWith(field + '.') || field.startsWith(p + '.')))
|
|
153
|
+
(0, json_js_1.invalid)('Update paths must not overlap');
|
|
154
|
+
paths.push(field);
|
|
155
|
+
if (paths.length > 32)
|
|
156
|
+
(0, json_js_1.invalid)('Update exceeds 32 fields');
|
|
157
|
+
const segments = field.split('.');
|
|
158
|
+
for (let i = 1; i < segments.length; i++) {
|
|
159
|
+
const parent = (0, query_js_1.typeSQL)(segments.slice(0, i).join('.'));
|
|
160
|
+
guards.push(`(${parent} IS NULL OR ${parent} = 'object')`);
|
|
161
|
+
}
|
|
162
|
+
if (operator === '$set') {
|
|
163
|
+
expression = `json_set(${expression}, '${jsonPath}', json(?))`;
|
|
164
|
+
args.push((0, json_js_1.jsonText)(operand));
|
|
165
|
+
}
|
|
166
|
+
else if (operator === '$unset') {
|
|
167
|
+
if (operand !== true && operand !== 1)
|
|
168
|
+
(0, json_js_1.invalid)('$unset values must be true or 1');
|
|
169
|
+
expression = `json_remove(${expression}, '${jsonPath}')`;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
if (typeof operand !== 'number' || !Number.isFinite(operand))
|
|
173
|
+
(0, json_js_1.invalid)('$inc requires a finite number');
|
|
174
|
+
const type = (0, query_js_1.typeSQL)(field), current = (0, query_js_1.valueSQL)(field);
|
|
175
|
+
guards.push(`(${type} IS NULL OR ${type} IN ('integer', 'real'))`);
|
|
176
|
+
guards.push(`(coalesce(${current}, 0) + ? BETWEEN ? AND ?)`);
|
|
177
|
+
guardArgs.push(operand, -Number.MAX_VALUE, Number.MAX_VALUE);
|
|
178
|
+
expression = `json_set(${expression}, '${jsonPath}', coalesce(${current}, 0) + ?)`;
|
|
179
|
+
args.push(operand);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!paths.length)
|
|
184
|
+
(0, json_js_1.invalid)('Update must change at least one field');
|
|
185
|
+
// A bad selected target aborts this one statement, including all other fields.
|
|
186
|
+
return { sql: guards.length ? `CASE WHEN ${guards.join(' AND ')} THEN ${expression} ELSE json('invalid document update') END` : expression, args: [...guardArgs, ...args] };
|
|
187
|
+
}
|
|
@@ -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,18 @@
|
|
|
1
|
+
import { createDocumentStore } from '../documents.js';
|
|
2
|
+
/** Local durable SQLite. Native Bun code is loaded only from this optional entry. */
|
|
3
|
+
export async function openBunDocumentStore(filename) {
|
|
4
|
+
const moduleName = 'bun:sqlite';
|
|
5
|
+
const { Database } = await import(moduleName);
|
|
6
|
+
const db = new Database(filename);
|
|
7
|
+
try {
|
|
8
|
+
db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000;');
|
|
9
|
+
const client = { async execute({ sql, args }) { return { rows: db.query(sql).all(...args) }; } };
|
|
10
|
+
const store = createDocumentStore(client);
|
|
11
|
+
await store.ready();
|
|
12
|
+
return { ...store, close: () => db.close() };
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
db.close();
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type JSONScalar = string | number | boolean | null;
|
|
2
|
+
export type JSONValue = JSONScalar | JSONValue[] | {
|
|
3
|
+
[key: string]: JSONValue;
|
|
4
|
+
};
|
|
5
|
+
export declare const MAX_DOCUMENT_BYTES: number;
|
|
6
|
+
export declare class DocumentError extends Error {
|
|
7
|
+
readonly code: 'invalid_input' | 'database_error' | 'invalid_data';
|
|
8
|
+
constructor(code: 'invalid_input' | 'database_error' | 'invalid_data', message: string);
|
|
9
|
+
}
|
|
10
|
+
export declare function invalid(message: string): never;
|
|
11
|
+
export declare function entries(value: unknown, label?: string): [string, unknown][];
|
|
12
|
+
export declare function validateJSON(value: unknown, ancestors?: Set<object>, depth?: number): asserts value is JSONValue;
|
|
13
|
+
export declare function jsonText(value: unknown): string;
|
|
14
|
+
export declare function scalar(value: unknown): asserts value is JSONScalar;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const MAX_DOCUMENT_BYTES = 1024 * 1024;
|
|
2
|
+
export class DocumentError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = 'DocumentError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function invalid(message) {
|
|
11
|
+
throw new DocumentError('invalid_input', message);
|
|
12
|
+
}
|
|
13
|
+
// Read descriptors rather than properties: validation must not execute getters/toJSON.
|
|
14
|
+
export function entries(value, label = 'object') {
|
|
15
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) ||
|
|
16
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(value)))
|
|
17
|
+
invalid(`${label} must be a plain object`);
|
|
18
|
+
return Reflect.ownKeys(value).map(key => {
|
|
19
|
+
const desc = Object.getOwnPropertyDescriptor(value, key);
|
|
20
|
+
if (typeof key !== 'string' || !desc.enumerable || !('value' in desc))
|
|
21
|
+
invalid(`${label} must contain enumerable data properties only`);
|
|
22
|
+
return [key, desc.value];
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function validateJSON(value, ancestors = new Set(), depth = 0) {
|
|
26
|
+
if (depth > 32)
|
|
27
|
+
invalid('JSON nesting exceeds 32 levels');
|
|
28
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
29
|
+
return;
|
|
30
|
+
if (typeof value === 'number') {
|
|
31
|
+
if (!Number.isFinite(value))
|
|
32
|
+
invalid('JSON numbers must be finite');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (typeof value !== 'object')
|
|
36
|
+
invalid('Value is not JSON');
|
|
37
|
+
if (ancestors.has(value))
|
|
38
|
+
invalid('JSON must not contain cycles');
|
|
39
|
+
ancestors.add(value);
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
if (Object.getPrototypeOf(value) !== Array.prototype || Reflect.ownKeys(value).length !== value.length + 1)
|
|
42
|
+
invalid('JSON arrays must not contain holes or extra properties');
|
|
43
|
+
for (let i = 0; i < value.length; i++) {
|
|
44
|
+
const desc = Object.getOwnPropertyDescriptor(value, String(i));
|
|
45
|
+
if (!desc || !('value' in desc) || !desc.enumerable)
|
|
46
|
+
invalid('JSON arrays must contain data values');
|
|
47
|
+
validateJSON(desc.value, ancestors, depth + 1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
for (const [, child] of entries(value, 'JSON object'))
|
|
52
|
+
validateJSON(child, ancestors, depth + 1);
|
|
53
|
+
}
|
|
54
|
+
ancestors.delete(value);
|
|
55
|
+
}
|
|
56
|
+
export function jsonText(value) {
|
|
57
|
+
validateJSON(value);
|
|
58
|
+
const text = JSON.stringify(value);
|
|
59
|
+
if (new TextEncoder().encode(text).byteLength > MAX_DOCUMENT_BYTES)
|
|
60
|
+
invalid('Document exceeds 1 MiB');
|
|
61
|
+
return text;
|
|
62
|
+
}
|
|
63
|
+
export function scalar(value) {
|
|
64
|
+
if (value !== null && typeof value !== 'string' && typeof value !== 'boolean' && typeof value !== 'number')
|
|
65
|
+
invalid('Query operands must be JSON scalars');
|
|
66
|
+
if (typeof value === 'number' && !Number.isFinite(value))
|
|
67
|
+
invalid('Query numbers must be finite');
|
|
68
|
+
if (typeof value === 'string' && value.length > MAX_DOCUMENT_BYTES)
|
|
69
|
+
invalid('Query string is too large');
|
|
70
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type OwnedDocumentStore } from '../documents.js';
|
|
2
|
+
export interface LibSQLDocumentConfig {
|
|
3
|
+
url: string;
|
|
4
|
+
authToken?: string;
|
|
5
|
+
}
|
|
6
|
+
/** Install @libsql/client separately. Remote durability is a server setting. */
|
|
7
|
+
export declare function openLibSQLDocumentStore(config: LibSQLDocumentConfig): Promise<OwnedDocumentStore>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createDocumentStore } from '../documents.js';
|
|
2
|
+
/** Install @libsql/client separately. Remote durability is a server setting. */
|
|
3
|
+
export async function openLibSQLDocumentStore(config) {
|
|
4
|
+
const { createClient } = await import('@libsql/client');
|
|
5
|
+
const client = createClient(config);
|
|
6
|
+
try {
|
|
7
|
+
if (config.url.startsWith('file:')) {
|
|
8
|
+
await client.execute('PRAGMA journal_mode=WAL');
|
|
9
|
+
await client.execute('PRAGMA synchronous=FULL');
|
|
10
|
+
await client.execute('PRAGMA busy_timeout=5000');
|
|
11
|
+
}
|
|
12
|
+
const store = createDocumentStore(client);
|
|
13
|
+
await store.ready();
|
|
14
|
+
return { ...store, close: () => client.close() };
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
client.close();
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type SQLValue = string | number | null;
|
|
2
|
+
export type Filter = {
|
|
3
|
+
[field: string]: unknown;
|
|
4
|
+
};
|
|
5
|
+
export type Sort = {
|
|
6
|
+
[field: string]: 1 | -1;
|
|
7
|
+
};
|
|
8
|
+
export type Fragment = {
|
|
9
|
+
sql: string;
|
|
10
|
+
args: SQLValue[];
|
|
11
|
+
};
|
|
12
|
+
export declare function path(field: string): string;
|
|
13
|
+
export declare function valueSQL(field: string): string;
|
|
14
|
+
export declare function typeSQL(field: string): string;
|
|
15
|
+
export declare function compileFilter(filter?: Filter): Fragment;
|
|
16
|
+
export declare function sortFields(sort: Sort): [string, 1 | -1][];
|
|
17
|
+
export declare function compileSort(sort: Sort): string;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { entries, invalid, scalar } from './json.js';
|
|
2
|
+
export function path(field) {
|
|
3
|
+
if (field.length > 256 || field.split('.').length > 16 || !/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(field))
|
|
4
|
+
invalid('Invalid object field path');
|
|
5
|
+
return '$.' + field.split('.').map(p => `"${p}"`).join('.');
|
|
6
|
+
}
|
|
7
|
+
// Paths are validated identifiers, never caller-supplied SQL. Keeping their literal
|
|
8
|
+
// representation identical lets SQLite use expression indexes for these predicates.
|
|
9
|
+
export function valueSQL(field) {
|
|
10
|
+
return field === '_id' ? 'id' : `json_extract(data, '${path(field)}')`;
|
|
11
|
+
}
|
|
12
|
+
export function typeSQL(field) {
|
|
13
|
+
return field === '_id' ? "'text'" : `json_type(data, '${path(field)}')`;
|
|
14
|
+
}
|
|
15
|
+
function comparison(field, operator, value) {
|
|
16
|
+
scalar(value);
|
|
17
|
+
const type = typeSQL(field), expr = valueSQL(field);
|
|
18
|
+
if (operator !== '=' && (value === null || typeof value === 'boolean'))
|
|
19
|
+
invalid('Range comparisons require a number or string');
|
|
20
|
+
if (value === null)
|
|
21
|
+
return { sql: `${type} = 'null'`, args: [] };
|
|
22
|
+
if (typeof value === 'boolean')
|
|
23
|
+
return { sql: `${type} = '${value ? 'true' : 'false'}'`, args: [] };
|
|
24
|
+
const guard = typeof value === 'number' ? `${type} IN ('integer', 'real')` : `${type} = 'text'`;
|
|
25
|
+
return { sql: `(${guard} AND ${expr} ${operator} ?)`, args: [value] };
|
|
26
|
+
}
|
|
27
|
+
export function compileFilter(filter = {}) {
|
|
28
|
+
let conditions = 0;
|
|
29
|
+
const walk = (input, depth) => {
|
|
30
|
+
if (depth > 10)
|
|
31
|
+
invalid('Filter nesting exceeds 10 levels');
|
|
32
|
+
const parts = [], args = [];
|
|
33
|
+
const append = (f) => { parts.push(`(${f.sql})`); args.push(...f.args); };
|
|
34
|
+
for (const [field, condition] of entries(input, 'filter')) {
|
|
35
|
+
if (++conditions > 64)
|
|
36
|
+
invalid('Filter exceeds 64 conditions');
|
|
37
|
+
if (field === '$and' || field === '$or') {
|
|
38
|
+
if (!Array.isArray(condition) || !condition.length || condition.length > 64)
|
|
39
|
+
invalid('Logical filters require 1 to 64 child filters');
|
|
40
|
+
const children = condition.map(child => walk(child, depth + 1));
|
|
41
|
+
append({ sql: children.map(c => `(${c.sql})`).join(field === '$and' ? ' AND ' : ' OR '), args: children.flatMap(c => c.args) });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
path(field);
|
|
45
|
+
if (condition === null || typeof condition !== 'object') {
|
|
46
|
+
append(comparison(field, '=', condition));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const operators = entries(condition, 'field operators');
|
|
50
|
+
if (!operators.length)
|
|
51
|
+
invalid('Field operators must not be empty');
|
|
52
|
+
for (const [operator, operand] of operators) {
|
|
53
|
+
if (++conditions > 64)
|
|
54
|
+
invalid('Filter exceeds 64 conditions');
|
|
55
|
+
if (operator === '$exists') {
|
|
56
|
+
if (typeof operand !== 'boolean')
|
|
57
|
+
invalid('$exists requires a boolean');
|
|
58
|
+
append({ sql: `${typeSQL(field)} IS ${operand ? 'NOT ' : ''}NULL`, args: [] });
|
|
59
|
+
}
|
|
60
|
+
else if (operator === '$in') {
|
|
61
|
+
if (!Array.isArray(operand) || operand.length > 100)
|
|
62
|
+
invalid('$in requires at most 100 scalar values');
|
|
63
|
+
const choices = operand.map(v => comparison(field, '=', v));
|
|
64
|
+
append({ sql: choices.length ? choices.map(c => `(${c.sql})`).join(' OR ') : '0', args: choices.flatMap(c => c.args) });
|
|
65
|
+
}
|
|
66
|
+
else if (operator === '$ne') {
|
|
67
|
+
const equal = comparison(field, '=', operand);
|
|
68
|
+
append({ sql: `NOT coalesce((${equal.sql}), 0)`, args: equal.args });
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const sqlOperator = { $eq: '=', $gt: '>', $gte: '>=', $lt: '<', $lte: '<=' }[operator];
|
|
72
|
+
if (!sqlOperator)
|
|
73
|
+
invalid('Unsupported query operator');
|
|
74
|
+
append(comparison(field, sqlOperator, operand));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { sql: parts.length ? parts.join(' AND ') : '1', args };
|
|
79
|
+
};
|
|
80
|
+
return walk(filter, 0);
|
|
81
|
+
}
|
|
82
|
+
export function sortFields(sort) {
|
|
83
|
+
const fields = entries(sort, 'sort');
|
|
84
|
+
if (!fields.length || fields.length > 4)
|
|
85
|
+
invalid('Sort/index requires 1 to 4 fields');
|
|
86
|
+
return fields.map(([field, order]) => {
|
|
87
|
+
path(field);
|
|
88
|
+
if (order !== 1 && order !== -1)
|
|
89
|
+
invalid('Sort direction must be 1 or -1');
|
|
90
|
+
return [field, order];
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export function compileSort(sort) {
|
|
94
|
+
const fields = sortFields(sort);
|
|
95
|
+
if (!fields.some(([field]) => field === '_id'))
|
|
96
|
+
fields.push(['_id', 1]);
|
|
97
|
+
return fields.map(([field, order]) => `${valueSQL(field)} ${order === 1 ? 'ASC' : 'DESC'}`).join(', ');
|
|
98
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { DocumentError, MAX_DOCUMENT_BYTES, type JSONValue } from './documents/json.js';
|
|
2
|
+
import { type Filter, type Sort, type SQLValue, type Fragment } from './documents/query.js';
|
|
3
|
+
export { DocumentError, MAX_DOCUMENT_BYTES };
|
|
4
|
+
export type { JSONValue, Filter, Sort, SQLValue };
|
|
5
|
+
export type Document<T extends object = Record<string, JSONValue>> = T & {
|
|
6
|
+
_id: string;
|
|
7
|
+
};
|
|
8
|
+
export type Update = {
|
|
9
|
+
$set?: Record<string, JSONValue>;
|
|
10
|
+
$unset?: Record<string, true | 1>;
|
|
11
|
+
$inc?: Record<string, number>;
|
|
12
|
+
};
|
|
13
|
+
export interface DocumentClient {
|
|
14
|
+
execute(statement: {
|
|
15
|
+
sql: string;
|
|
16
|
+
args: SQLValue[];
|
|
17
|
+
}): PromiseLike<{
|
|
18
|
+
rows: readonly Record<string, unknown>[];
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
export interface DocumentStore {
|
|
22
|
+
ready(): Promise<void>;
|
|
23
|
+
collection<T extends object = Record<string, JSONValue>>(name: string): DocumentCollection<T>;
|
|
24
|
+
}
|
|
25
|
+
export interface OwnedDocumentStore extends DocumentStore {
|
|
26
|
+
close(): void | Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
type Execute = (sql: string, args?: SQLValue[]) => Promise<readonly Record<string, unknown>[]>;
|
|
29
|
+
/** A collection is a query namespace, not an authorization boundary. Give each
|
|
30
|
+
* project/environment its own database credential or local file. No retry occurs. */
|
|
31
|
+
export declare function createDocumentStore(client: DocumentClient): DocumentStore;
|
|
32
|
+
export declare class DocumentCursor<T extends object> {
|
|
33
|
+
private readonly collection;
|
|
34
|
+
private readonly execute;
|
|
35
|
+
private readonly filter;
|
|
36
|
+
private readonly order;
|
|
37
|
+
private readonly take;
|
|
38
|
+
private readonly offset;
|
|
39
|
+
constructor(collection: string, execute: Execute, filter: Fragment, order?: string, take?: number, offset?: number);
|
|
40
|
+
sort(sort: Sort): DocumentCursor<T>;
|
|
41
|
+
limit(n: number): DocumentCursor<T>;
|
|
42
|
+
skip(n: number): DocumentCursor<T>;
|
|
43
|
+
toArray(): Promise<Document<T>[]>;
|
|
44
|
+
}
|
|
45
|
+
export declare class DocumentCollection<T extends object> {
|
|
46
|
+
private readonly name;
|
|
47
|
+
private readonly execute;
|
|
48
|
+
constructor(name: string, execute: Execute);
|
|
49
|
+
insertOne(document: Omit<T, '_id'> & {
|
|
50
|
+
_id?: string;
|
|
51
|
+
}): Promise<{
|
|
52
|
+
insertedId: string;
|
|
53
|
+
}>;
|
|
54
|
+
find(filter?: Filter): DocumentCursor<T>;
|
|
55
|
+
findOne(filter?: Filter): Promise<Document<T> | null>;
|
|
56
|
+
countDocuments(filter?: Filter): Promise<number>;
|
|
57
|
+
deleteOne(filter: Filter): Promise<{
|
|
58
|
+
deletedCount: number;
|
|
59
|
+
}>;
|
|
60
|
+
updateOne(filter: Filter, update: Update): Promise<{
|
|
61
|
+
matchedCount: number;
|
|
62
|
+
}>;
|
|
63
|
+
/** Indexes cover all collections, with collection as the leading key.
|
|
64
|
+
* Identical specs share one index. Queries still bind the collection. */
|
|
65
|
+
createIndex(spec: Sort): Promise<string>;
|
|
66
|
+
}
|