@bezoar/core 0.0.0-stage → 0.0.1
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/index.d.mts +296 -0
- package/.dist/index.mjs +237 -0
- package/LICENSE +21 -0
- package/README.md +161 -2
- package/package.json +31 -3
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { Arguments, Bundle, Definition, Issue, Query, Returns, Row, RowQuery, Schema } from "@bezoar/schema";
|
|
2
|
+
/**
|
|
3
|
+
* The members of a service. A query group cannot take one of these names.
|
|
4
|
+
*/
|
|
5
|
+
export declare const RESERVED: readonly ["config", "schema", "query", "first", "transaction", "migrate", "seed"];
|
|
6
|
+
/**
|
|
7
|
+
* The table that records each applied migration: its version, its name, its
|
|
8
|
+
* checksum, and when it was applied.
|
|
9
|
+
*/
|
|
10
|
+
export declare const TABLE = "bezoar_migrations";
|
|
11
|
+
/**
|
|
12
|
+
* The key of the advisory lock a migration run holds. Two runs against one
|
|
13
|
+
* database wait for each other.
|
|
14
|
+
*/
|
|
15
|
+
export declare const LOCK = 6451064;
|
|
16
|
+
/**
|
|
17
|
+
* Takes the advisory lock for the rest of the transaction.
|
|
18
|
+
*/
|
|
19
|
+
export declare const ACQUIRE = "SELECT pg_advisory_xact_lock($1)";
|
|
20
|
+
/**
|
|
21
|
+
* Makes the tracking table when the database lacks it.
|
|
22
|
+
*/
|
|
23
|
+
export declare const CREATE = "CREATE TABLE IF NOT EXISTS bezoar_migrations (\n version text PRIMARY KEY,\n name text NOT NULL,\n checksum text NOT NULL,\n applied_at timestamptz NOT NULL DEFAULT now()\n)";
|
|
24
|
+
/**
|
|
25
|
+
* Reads the version and the checksum of each applied migration.
|
|
26
|
+
*/
|
|
27
|
+
export declare const APPLIED = "SELECT version, checksum FROM bezoar_migrations ORDER BY version";
|
|
28
|
+
/**
|
|
29
|
+
* Records one applied migration.
|
|
30
|
+
*/
|
|
31
|
+
export declare const RECORD = "INSERT INTO bezoar_migrations (version, name, checksum) VALUES ($1, $2, $3)";
|
|
32
|
+
/**
|
|
33
|
+
* Raised at construction when the SQL bundle does not match the contract: a
|
|
34
|
+
* query without SQL, or SQL for a query the contract does not declare.
|
|
35
|
+
* Carries the schema's issues.
|
|
36
|
+
*/
|
|
37
|
+
export declare class InvalidBundleError extends Error {
|
|
38
|
+
readonly issues: Issue[];
|
|
39
|
+
constructor(issues: Issue[]);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Raised when the arguments of a call do not fit the slots of the query.
|
|
43
|
+
* Carries the query name and the schema's issues.
|
|
44
|
+
*/
|
|
45
|
+
export declare class InvalidArgumentsError extends Error {
|
|
46
|
+
readonly query: string;
|
|
47
|
+
readonly issues: Issue[];
|
|
48
|
+
constructor(query: string, issues: Issue[]);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Raised when a row the driver returned does not fit the columns of the
|
|
52
|
+
* query. Carries the query name, the index of the row, and the schema's
|
|
53
|
+
* issues.
|
|
54
|
+
*/
|
|
55
|
+
export declare class InvalidRowError extends Error {
|
|
56
|
+
readonly query: string;
|
|
57
|
+
readonly index: number;
|
|
58
|
+
readonly issues: Issue[];
|
|
59
|
+
constructor(query: string, index: number, issues: Issue[]);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Raised at construction when a query group takes the name of a member of
|
|
63
|
+
* the service, such as `migrate`.
|
|
64
|
+
*/
|
|
65
|
+
export declare class ReservedQueryError extends Error {
|
|
66
|
+
readonly query: string;
|
|
67
|
+
constructor(query: string);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Raised at construction when a name is both a query and a group, as
|
|
71
|
+
* `events` is when `events` and `events.list` are both queries.
|
|
72
|
+
*/
|
|
73
|
+
export declare class AmbiguousQueryError extends Error {
|
|
74
|
+
readonly query: string;
|
|
75
|
+
constructor(query: string);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Raised when the driver fails to run a query. Carries the query name and
|
|
79
|
+
* the driver's error as `cause`.
|
|
80
|
+
*/
|
|
81
|
+
export declare class QueryError extends Error {
|
|
82
|
+
readonly query: string;
|
|
83
|
+
constructor(query: string, cause: unknown);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Raised when a migration fails to apply. Carries the version and the
|
|
87
|
+
* driver's error as `cause`. The run rolls back. No migration of the run is
|
|
88
|
+
* kept.
|
|
89
|
+
*/
|
|
90
|
+
export declare class MigrationError extends Error {
|
|
91
|
+
readonly version: string;
|
|
92
|
+
constructor(version: string, cause: unknown);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Raised when the database holds a migration that the list does not match:
|
|
96
|
+
* the checksum changed, or the version is not in the list. The database and
|
|
97
|
+
* the build disagree.
|
|
98
|
+
*/
|
|
99
|
+
export declare class MigrationDriftError extends Error {
|
|
100
|
+
readonly version: string;
|
|
101
|
+
constructor(version: string, reason: string);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Raised when a seed fails to run. Carries the name and the driver's error
|
|
105
|
+
* as `cause`. The transaction rolls back.
|
|
106
|
+
*/
|
|
107
|
+
export declare class SeedError extends Error {
|
|
108
|
+
readonly seed: string;
|
|
109
|
+
constructor(seed: string, cause: unknown);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Runs a schema check and rethrows its {@link SchemaError} as the error that
|
|
113
|
+
* `frame` makes from the issues. Any other error propagates untouched.
|
|
114
|
+
*
|
|
115
|
+
* @param frame - Makes the core error from the issues.
|
|
116
|
+
* @param run - The check.
|
|
117
|
+
*/
|
|
118
|
+
export declare const reframe: <R>(frame: (issues: Issue[]) => Error, run: () => R) => R;
|
|
119
|
+
/**
|
|
120
|
+
* What a driver answers a query with. `rows` is each row as an object, by
|
|
121
|
+
* column name, with the values parsed to the types the schema table
|
|
122
|
+
* declares. `count` is the number of rows the statement changed, or
|
|
123
|
+
* returned when it changed none.
|
|
124
|
+
*/
|
|
125
|
+
export interface Outcome {
|
|
126
|
+
readonly rows: readonly Record<string, unknown>[];
|
|
127
|
+
readonly count: number;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A connection to Postgres, as a driver package provides one. The service
|
|
131
|
+
* knows nothing of the driver beyond this.
|
|
132
|
+
*
|
|
133
|
+
* `query` runs one statement with positional parameters, through the
|
|
134
|
+
* extended protocol. `exec` runs text that can hold many statements and no
|
|
135
|
+
* parameters, through the simple protocol. A migration runs through `exec`.
|
|
136
|
+
* `transaction` runs a function with an executor bound to one transaction.
|
|
137
|
+
* The transaction commits when the function returns and rolls back when it
|
|
138
|
+
* throws.
|
|
139
|
+
*/
|
|
140
|
+
export interface Executor {
|
|
141
|
+
query(sql: string, params: readonly unknown[]): Promise<Outcome>;
|
|
142
|
+
exec(sql: string): Promise<void>;
|
|
143
|
+
transaction<T>(run: (executor: Executor) => Promise<T>): Promise<T>;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The caller-owned state a {@link Bezoar} service reads: the executor it
|
|
147
|
+
* runs on and the SQL of every query. The service reads the executor on each
|
|
148
|
+
* call. A caller can replace it.
|
|
149
|
+
*/
|
|
150
|
+
export type Config<D extends Definition> = {
|
|
151
|
+
/** The connection the service runs on. */
|
|
152
|
+
executor: Executor;
|
|
153
|
+
/** The SQL of each query, by name, as `@bezoar/kit` built it. */
|
|
154
|
+
queries: Bundle<D>;
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* What the service reports after a query runs: the name, the arguments, the
|
|
158
|
+
* time it took in milliseconds, and the count of the outcome.
|
|
159
|
+
*/
|
|
160
|
+
export interface Report<D extends Definition> {
|
|
161
|
+
readonly query: Query<D>;
|
|
162
|
+
readonly arguments: unknown;
|
|
163
|
+
readonly ms: number;
|
|
164
|
+
readonly count: number;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The runtime features of a service.
|
|
168
|
+
*/
|
|
169
|
+
export type Options<D extends Definition> = {
|
|
170
|
+
/**
|
|
171
|
+
* Called after each query runs, with its {@link Report}. A place for a log
|
|
172
|
+
* or a trace. A transaction shares the option with the service that
|
|
173
|
+
* opened it.
|
|
174
|
+
*/
|
|
175
|
+
onQuery?: (report: Report<D>) => void;
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* One migration, as `@bezoar/kit` built it: a version that orders it, a
|
|
179
|
+
* name, its SQL, and a checksum of the SQL. The runner keeps the version
|
|
180
|
+
* and the checksum in the database, and refuses a migration whose checksum
|
|
181
|
+
* changed after it was applied.
|
|
182
|
+
*/
|
|
183
|
+
export interface Migration {
|
|
184
|
+
readonly version: string;
|
|
185
|
+
readonly name: string;
|
|
186
|
+
readonly sql: string;
|
|
187
|
+
readonly checksum: string;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* One seed, as `@bezoar/kit` built it: a name and its SQL. A seed is not
|
|
191
|
+
* tracked. A seed must be safe to run again.
|
|
192
|
+
*/
|
|
193
|
+
export interface Seed {
|
|
194
|
+
readonly name: string;
|
|
195
|
+
readonly sql: string;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The arguments a query is called with: its arguments when it has a required
|
|
199
|
+
* slot, and optional arguments when every slot is nullable or there is none.
|
|
200
|
+
*/
|
|
201
|
+
export type Args<D extends Definition, Q extends Query<D>> = Record<never, never> extends Arguments<D, Q> ? [args?: Arguments<D, Q>] : [args: Arguments<D, Q>];
|
|
202
|
+
/**
|
|
203
|
+
* The function of one query. The function takes the arguments of the query
|
|
204
|
+
* and returns its rows, or its count when it has no column. A query with a
|
|
205
|
+
* column also has `first`, which returns the first row or `null`.
|
|
206
|
+
*/
|
|
207
|
+
export type Method<D extends Definition, Q extends Query<D>> = ((...args: Args<D, Q>) => Promise<Returns<D, Q>>) & (Q extends RowQuery<D> ? {
|
|
208
|
+
readonly first: (...args: Args<D, Q>) => Promise<Row<D, Q> | null>;
|
|
209
|
+
} : unknown);
|
|
210
|
+
/**
|
|
211
|
+
* One level of the {@link Groups}: a function for every name that completes
|
|
212
|
+
* a query name, and the next level for every name that is a group. `K` is
|
|
213
|
+
* what is left of the names below the prefix `P`.
|
|
214
|
+
*/
|
|
215
|
+
type Tree<D extends Definition, K extends string, P extends string = ""> = { readonly [H in Exclude<K, `${string}.${string}`>]: Method<D, `${P}${H}` & Query<D>>; } & { readonly [H in K extends `${infer S}.${string}` ? S : never]: Tree<D, K extends `${H}.${infer R}` ? R : never, `${P}${H}.`>; };
|
|
216
|
+
/**
|
|
217
|
+
* The queries of a contract as functions: `db.events.list({ day })`. A dot
|
|
218
|
+
* in a name is a level of the object. The query `events.list` is
|
|
219
|
+
* `db.events.list()`, and its first row is `db.events.list.first()`. The
|
|
220
|
+
* directory of a query file is its group and the file is its name.
|
|
221
|
+
*/
|
|
222
|
+
export type Groups<D extends Definition> = Tree<D, Query<D>>;
|
|
223
|
+
/**
|
|
224
|
+
* The members of a service other than its queries. A query group cannot
|
|
225
|
+
* take one of these names.
|
|
226
|
+
*/
|
|
227
|
+
export interface Service<D extends Definition> {
|
|
228
|
+
/**
|
|
229
|
+
* The caller-owned state: the executor and the SQL bundle.
|
|
230
|
+
*/
|
|
231
|
+
config: Config<D>;
|
|
232
|
+
/**
|
|
233
|
+
* The validation bundle for the contract.
|
|
234
|
+
*/
|
|
235
|
+
schema: Schema<D>;
|
|
236
|
+
/**
|
|
237
|
+
* Runs a query by name. The same call as the function under its group.
|
|
238
|
+
* Returns the rows of a query with a column, and the count of the rows
|
|
239
|
+
* the statement changed for one without. Throws `InvalidArgumentsError`
|
|
240
|
+
* when the arguments do not fit the slots, `QueryError` when the driver
|
|
241
|
+
* fails, and `InvalidRowError` when a row does not fit the columns.
|
|
242
|
+
*/
|
|
243
|
+
query: <Q extends Query<D>>(query: Q, ...args: Args<D, Q>) => Promise<Returns<D, Q>>;
|
|
244
|
+
/**
|
|
245
|
+
* Runs a query with a column by name and returns its first row, or `null`
|
|
246
|
+
* when it has none. The same call as `first` on the function under its
|
|
247
|
+
* group. Proves the row it returns and no other. Throws as `query` does.
|
|
248
|
+
*/
|
|
249
|
+
first: <Q extends RowQuery<D>>(query: Q, ...args: Args<D, Q>) => Promise<Row<D, Q> | null>;
|
|
250
|
+
/**
|
|
251
|
+
* Runs a function inside one transaction. The function receives a service
|
|
252
|
+
* over the transaction, with the same queries and options. The transaction
|
|
253
|
+
* commits when the function returns and rolls back when it throws.
|
|
254
|
+
*/
|
|
255
|
+
transaction: <T>(run: (tx: Bezoar<D>) => Promise<T>) => Promise<T>;
|
|
256
|
+
/**
|
|
257
|
+
* Applies the migrations the database lacks, in the order given, inside
|
|
258
|
+
* one transaction under an advisory lock. Returns the versions it applied.
|
|
259
|
+
* Throws `MigrationDriftError` when an applied migration has another
|
|
260
|
+
* checksum or is not in the list, and `MigrationError` when one fails.
|
|
261
|
+
*/
|
|
262
|
+
migrate: (migrations: readonly Migration[]) => Promise<string[]>;
|
|
263
|
+
/**
|
|
264
|
+
* Runs the seeds, in the order given, inside one transaction. Throws
|
|
265
|
+
* `SeedError` when one fails.
|
|
266
|
+
*/
|
|
267
|
+
seed: (seeds: readonly Seed[]) => Promise<void>;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* A runtime database service over a contract. The service has its members
|
|
271
|
+
* and, beside them, a function for every query under its group. It never
|
|
272
|
+
* connects: the caller brings an executor from a driver package.
|
|
273
|
+
*/
|
|
274
|
+
export type Bezoar<D extends Definition> = Service<D> & Groups<D>;
|
|
275
|
+
/**
|
|
276
|
+
* Builds the runtime {@link Bezoar} service over a contract.
|
|
277
|
+
*
|
|
278
|
+
* The service runs the queries of the contract by name, on the executor the
|
|
279
|
+
* container holds. A call proves its arguments against the slots of the
|
|
280
|
+
* query, binds them by position, runs the SQL of the bundle, proves each row
|
|
281
|
+
* against the columns, and returns the rows, or the count when the query
|
|
282
|
+
* has no column. `first` returns the first row instead. The service never
|
|
283
|
+
* connects: the caller brings an executor from a driver
|
|
284
|
+
* package, and a transaction is the same service over the executor of the
|
|
285
|
+
* transaction.
|
|
286
|
+
*
|
|
287
|
+
* @param definition - The contract: the kit's `contract` export.
|
|
288
|
+
* @param config - The caller-owned container: the executor and the bundle.
|
|
289
|
+
* @param options - The runtime features: a report after each query.
|
|
290
|
+
* @returns A {@link Bezoar} service bound to the container.
|
|
291
|
+
* @throws InvalidBundleError when the bundle does not match the contract.
|
|
292
|
+
* @throws ReservedQueryError when a query group takes a member's name.
|
|
293
|
+
* @throws AmbiguousQueryError when a name is both a query and a group.
|
|
294
|
+
*/
|
|
295
|
+
export declare const makeBezoar: <const D extends Definition>(definition: D, config: Config<D>, options?: Options<D>) => Bezoar<D>;
|
|
296
|
+
export {};
|
package/.dist/index.mjs
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { SchemaError, defineSchema } from "@bezoar/schema";
|
|
2
|
+
import { keys, record, rekey } from "objectively";
|
|
3
|
+
const RESERVED = [
|
|
4
|
+
"config",
|
|
5
|
+
"schema",
|
|
6
|
+
"query",
|
|
7
|
+
"first",
|
|
8
|
+
"transaction",
|
|
9
|
+
"migrate",
|
|
10
|
+
"seed"
|
|
11
|
+
];
|
|
12
|
+
const TABLE = "bezoar_migrations";
|
|
13
|
+
const LOCK = 6451064;
|
|
14
|
+
const ACQUIRE = "SELECT pg_advisory_xact_lock($1)";
|
|
15
|
+
const CREATE = `CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
16
|
+
version text PRIMARY KEY,
|
|
17
|
+
name text NOT NULL,
|
|
18
|
+
checksum text NOT NULL,
|
|
19
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
20
|
+
)`;
|
|
21
|
+
const APPLIED = `SELECT version, checksum FROM ${TABLE} ORDER BY version`;
|
|
22
|
+
const RECORD = `INSERT INTO ${TABLE} (version, name, checksum) VALUES ($1, $2, $3)`;
|
|
23
|
+
const summarize = (issues) => issues.map((issue) => issue.path.length ? `${issue.path.map(String).join(".")}: ${issue.message}` : issue.message).join("\n");
|
|
24
|
+
var InvalidBundleError = class extends Error {
|
|
25
|
+
issues;
|
|
26
|
+
constructor(issues) {
|
|
27
|
+
super(`@bezoar/core: the bundle is invalid —\n${summarize(issues)}`);
|
|
28
|
+
this.name = "InvalidBundleError";
|
|
29
|
+
this.issues = issues;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
var InvalidArgumentsError = class extends Error {
|
|
33
|
+
query;
|
|
34
|
+
issues;
|
|
35
|
+
constructor(query, issues) {
|
|
36
|
+
super(`@bezoar/core: the arguments of ${JSON.stringify(query)} are invalid —\n${summarize(issues)}`);
|
|
37
|
+
this.name = "InvalidArgumentsError";
|
|
38
|
+
this.query = query;
|
|
39
|
+
this.issues = issues;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
var InvalidRowError = class extends Error {
|
|
43
|
+
query;
|
|
44
|
+
index;
|
|
45
|
+
issues;
|
|
46
|
+
constructor(query, index, issues) {
|
|
47
|
+
super(`@bezoar/core: row ${index} of ${JSON.stringify(query)} is invalid —\n${summarize(issues)}`);
|
|
48
|
+
this.name = "InvalidRowError";
|
|
49
|
+
this.query = query;
|
|
50
|
+
this.index = index;
|
|
51
|
+
this.issues = issues;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
var ReservedQueryError = class extends Error {
|
|
55
|
+
query;
|
|
56
|
+
constructor(query) {
|
|
57
|
+
super(`@bezoar/core: ${JSON.stringify(query)} is reserved by the service`);
|
|
58
|
+
this.name = "ReservedQueryError";
|
|
59
|
+
this.query = query;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
var AmbiguousQueryError = class extends Error {
|
|
63
|
+
query;
|
|
64
|
+
constructor(query) {
|
|
65
|
+
super(`@bezoar/core: ${JSON.stringify(query)} is both a query and a group`);
|
|
66
|
+
this.name = "AmbiguousQueryError";
|
|
67
|
+
this.query = query;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var QueryError = class extends Error {
|
|
71
|
+
query;
|
|
72
|
+
constructor(query, cause) {
|
|
73
|
+
super(`@bezoar/core: ${JSON.stringify(query)} failed`, { cause });
|
|
74
|
+
this.name = "QueryError";
|
|
75
|
+
this.query = query;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
var MigrationError = class extends Error {
|
|
79
|
+
version;
|
|
80
|
+
constructor(version, cause) {
|
|
81
|
+
super(`@bezoar/core: migration ${version} failed`, { cause });
|
|
82
|
+
this.name = "MigrationError";
|
|
83
|
+
this.version = version;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var MigrationDriftError = class extends Error {
|
|
87
|
+
version;
|
|
88
|
+
constructor(version, reason) {
|
|
89
|
+
super(`@bezoar/core: migration ${version} ${reason}`);
|
|
90
|
+
this.name = "MigrationDriftError";
|
|
91
|
+
this.version = version;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
var SeedError = class extends Error {
|
|
95
|
+
seed;
|
|
96
|
+
constructor(seed, cause) {
|
|
97
|
+
super(`@bezoar/core: seed ${JSON.stringify(seed)} failed`, { cause });
|
|
98
|
+
this.name = "SeedError";
|
|
99
|
+
this.seed = seed;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const reframe = (frame, run) => {
|
|
103
|
+
try {
|
|
104
|
+
return run();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error instanceof SchemaError) throw frame(error.issues);
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const descend = (level, segment, name) => {
|
|
111
|
+
const next = level[segment] ?? {};
|
|
112
|
+
if (!record(next)) throw new AmbiguousQueryError(name);
|
|
113
|
+
level[segment] = next;
|
|
114
|
+
return next;
|
|
115
|
+
};
|
|
116
|
+
const place = (level, segment, name, fn) => {
|
|
117
|
+
if (segment in level) throw new AmbiguousQueryError(name);
|
|
118
|
+
level[segment] = fn;
|
|
119
|
+
};
|
|
120
|
+
const defineGroups = (names, reserved, method) => {
|
|
121
|
+
const root = {};
|
|
122
|
+
for (const name of names) {
|
|
123
|
+
const segments = name.split(".");
|
|
124
|
+
const last = segments.pop();
|
|
125
|
+
if (reserved.has(segments[0] ?? last)) throw new ReservedQueryError(name);
|
|
126
|
+
let level = root;
|
|
127
|
+
for (const segment of segments) level = descend(level, segment, name);
|
|
128
|
+
place(level, last, name, method(name));
|
|
129
|
+
}
|
|
130
|
+
return rekey(root, (key, value) => [key, value]);
|
|
131
|
+
};
|
|
132
|
+
const applied = async (tx) => {
|
|
133
|
+
const { rows } = await tx.query(APPLIED, []);
|
|
134
|
+
const found = /* @__PURE__ */ new Map();
|
|
135
|
+
for (const row of rows) found.set(String(row.version), String(row.checksum));
|
|
136
|
+
return found;
|
|
137
|
+
};
|
|
138
|
+
const compare = (checksums, migrations) => {
|
|
139
|
+
const listed = new Set(migrations.map((migration) => migration.version));
|
|
140
|
+
for (const version of checksums.keys()) if (!listed.has(version)) throw new MigrationDriftError(version, "is applied but not listed");
|
|
141
|
+
for (const migration of migrations) {
|
|
142
|
+
const checksum = checksums.get(migration.version);
|
|
143
|
+
if (checksum !== void 0 && checksum !== migration.checksum) throw new MigrationDriftError(migration.version, "changed after it was applied");
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const apply = async (tx, migration) => {
|
|
147
|
+
try {
|
|
148
|
+
await tx.exec(migration.sql);
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
throw new MigrationError(migration.version, cause);
|
|
151
|
+
}
|
|
152
|
+
await tx.query(RECORD, [
|
|
153
|
+
migration.version,
|
|
154
|
+
migration.name,
|
|
155
|
+
migration.checksum
|
|
156
|
+
]);
|
|
157
|
+
};
|
|
158
|
+
const migrate = (executor, migrations) => executor.transaction(async (tx) => {
|
|
159
|
+
await tx.query(ACQUIRE, [LOCK]);
|
|
160
|
+
await tx.exec(CREATE);
|
|
161
|
+
const checksums = await applied(tx);
|
|
162
|
+
compare(checksums, migrations);
|
|
163
|
+
const done = [];
|
|
164
|
+
for (const migration of migrations) if (!checksums.has(migration.version)) {
|
|
165
|
+
await apply(tx, migration);
|
|
166
|
+
done.push(migration.version);
|
|
167
|
+
}
|
|
168
|
+
return done;
|
|
169
|
+
});
|
|
170
|
+
const seed = (executor, seeds) => executor.transaction(async (tx) => {
|
|
171
|
+
for (const entry of seeds) try {
|
|
172
|
+
await tx.exec(entry.sql);
|
|
173
|
+
} catch (cause) {
|
|
174
|
+
throw new SeedError(entry.name, cause);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
const bind = (slots, given) => slots.map((slot) => given[slot.name] ?? null);
|
|
178
|
+
const defineService = (definition, schema, config, options) => {
|
|
179
|
+
const execute = async (query, params) => {
|
|
180
|
+
try {
|
|
181
|
+
return await config.executor.query(config.queries[query], params);
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
throw new QueryError(query, cause);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
const prove = (query, index, row) => reframe((issues) => new InvalidRowError(query, index, issues), () => schema.assert.row(query, row));
|
|
187
|
+
const perform = async (query, args) => {
|
|
188
|
+
const statement = definition.queries[query];
|
|
189
|
+
const given = reframe((issues) => new InvalidArgumentsError(query, issues), () => schema.parse.arguments(query, args));
|
|
190
|
+
const started = performance.now();
|
|
191
|
+
const outcome = await execute(query, bind(statement.slots, given));
|
|
192
|
+
options.onQuery?.({
|
|
193
|
+
query,
|
|
194
|
+
arguments: args,
|
|
195
|
+
ms: performance.now() - started,
|
|
196
|
+
count: outcome.count
|
|
197
|
+
});
|
|
198
|
+
return outcome;
|
|
199
|
+
};
|
|
200
|
+
const returnsRows = (query) => definition.queries[query].columns.length > 0;
|
|
201
|
+
async function run(query, args = {}) {
|
|
202
|
+
const outcome = await perform(query, args);
|
|
203
|
+
if (!returnsRows(query)) return outcome.count;
|
|
204
|
+
for (const [index, row] of outcome.rows.entries()) prove(query, index, row);
|
|
205
|
+
return outcome.rows;
|
|
206
|
+
}
|
|
207
|
+
async function take(query, args = {}) {
|
|
208
|
+
const row = (await perform(query, args)).rows[0];
|
|
209
|
+
if (row === void 0) return null;
|
|
210
|
+
prove(query, 0, row);
|
|
211
|
+
return row;
|
|
212
|
+
}
|
|
213
|
+
const transaction = (fn) => config.executor.transaction((executor) => fn(defineService(definition, schema, {
|
|
214
|
+
...config,
|
|
215
|
+
executor
|
|
216
|
+
}, options)));
|
|
217
|
+
const service = {
|
|
218
|
+
config,
|
|
219
|
+
schema,
|
|
220
|
+
query: run,
|
|
221
|
+
first: take,
|
|
222
|
+
transaction,
|
|
223
|
+
migrate: (migrations) => migrate(config.executor, migrations),
|
|
224
|
+
seed: (seeds) => seed(config.executor, seeds)
|
|
225
|
+
};
|
|
226
|
+
const groups = defineGroups(keys(definition.queries), new Set(RESERVED), (query) => {
|
|
227
|
+
const method = (args) => run(query, args);
|
|
228
|
+
return returnsRows(query) ? Object.assign(method, { first: (args) => take(query, args) }) : method;
|
|
229
|
+
});
|
|
230
|
+
return Object.assign(service, groups);
|
|
231
|
+
};
|
|
232
|
+
const makeBezoar = (definition, config, options = {}) => {
|
|
233
|
+
const schema = defineSchema(definition);
|
|
234
|
+
reframe((issues) => new InvalidBundleError(issues), () => schema.assert.bundle(config.queries));
|
|
235
|
+
return defineService(definition, schema, config, options);
|
|
236
|
+
};
|
|
237
|
+
export { ACQUIRE, APPLIED, AmbiguousQueryError, CREATE, InvalidArgumentsError, InvalidBundleError, InvalidRowError, LOCK, MigrationDriftError, MigrationError, QueryError, RECORD, RESERVED, ReservedQueryError, SeedError, TABLE, makeBezoar, reframe };
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Thorwaldson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,162 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @bezoar/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The runtime service of bezoar. The service runs the queries of a contract
|
|
4
|
+
by name, proves the arguments and the rows against the contract, and applies
|
|
5
|
+
migrations and seeds.
|
|
6
|
+
|
|
7
|
+
[`@bezoar/schema`](../schema) has the contract types and the validation.
|
|
8
|
+
A driver package provides the connection.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pnpm add @bezoar/core
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
`bezoar build` writes the contract to `.output/index.mjs` and the SQL to
|
|
19
|
+
`.output/queries.mjs`. `makeBezoar` makes the service from the contract
|
|
20
|
+
and a container with an executor and the SQL.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { makeBezoar } from "@bezoar/core";
|
|
24
|
+
import { pglite } from "@bezoar/pglite";
|
|
25
|
+
import { contract } from "@bbf/database";
|
|
26
|
+
import { queries } from "@bbf/database/queries";
|
|
27
|
+
|
|
28
|
+
const db = makeBezoar(contract, { executor: pglite(client), queries });
|
|
29
|
+
|
|
30
|
+
const events = await db.events.list({ day: "sunday" });
|
|
31
|
+
const found = await db.events.bySlug.first({ slug: "worship-service" });
|
|
32
|
+
const inserted = await db.events.insert({ slug: "prayer", title: "Prayer" });
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The directory of a query file is its group and the file is its name, so
|
|
36
|
+
`events/list.sql` is `db.events.list()`. `db.query("events.list", args)` is
|
|
37
|
+
the same call by name. The arguments complete to the slots of the query. A
|
|
38
|
+
required slot is required. A nullable slot is optional and takes `null`.
|
|
39
|
+
|
|
40
|
+
A query with a column returns its rows. A query with no column, a write
|
|
41
|
+
without `RETURNING`, returns the count of the rows the statement changed.
|
|
42
|
+
A query with a column also has `first`, which returns the first row or
|
|
43
|
+
`null` when there is none: `db.events.bySlug.first({ slug })`, or
|
|
44
|
+
`db.first("events.bySlug", { slug })` by name. A query with no column has
|
|
45
|
+
no `first`.
|
|
46
|
+
|
|
47
|
+
## The container
|
|
48
|
+
|
|
49
|
+
The container holds the executor and the SQL bundle. The service reads the
|
|
50
|
+
executor on each call, so a caller can replace it. The service never
|
|
51
|
+
connects. The caller brings an executor from a driver package.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
interface Executor {
|
|
55
|
+
query(sql: string, params: readonly unknown[]): Promise<Outcome>;
|
|
56
|
+
exec(sql: string): Promise<void>;
|
|
57
|
+
transaction<T>(run: (executor: Executor) => Promise<T>): Promise<T>;
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`query` runs one statement with positional parameters. `exec` runs text
|
|
62
|
+
that can hold many statements and no parameters, which is how a migration
|
|
63
|
+
runs. `transaction` runs a function on one transaction and commits or rolls
|
|
64
|
+
back by whether the function returns or throws. The rows of an outcome are
|
|
65
|
+
objects by column name, with the values parsed to the types the schema table
|
|
66
|
+
declares.
|
|
67
|
+
|
|
68
|
+
## A call
|
|
69
|
+
|
|
70
|
+
A call proves its arguments against the slots of the query before anything
|
|
71
|
+
runs, and throws `InvalidArgumentsError` with every issue. The service binds
|
|
72
|
+
the arguments by slot position and runs the SQL of the bundle. A driver
|
|
73
|
+
failure is a `QueryError` that names the query and carries the driver's
|
|
74
|
+
error as `cause`. For `one` and `many`, each row is proven against the
|
|
75
|
+
columns, and a row that does not fit is an `InvalidRowError` with its index.
|
|
76
|
+
|
|
77
|
+
The `onQuery` option receives a report after each call: the query name, the
|
|
78
|
+
arguments, the time in milliseconds, and the count of the outcome.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
const db = makeBezoar(contract, config, {
|
|
82
|
+
onQuery: ({ query, ms }) => console.log(`${query} ${ms.toFixed(1)}ms`),
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Transactions
|
|
87
|
+
|
|
88
|
+
`transaction` runs a function with a service over one transaction. The
|
|
89
|
+
service has the same queries and options.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
await db.transaction(async (tx) => {
|
|
93
|
+
await tx.events.insert({ slug: "prayer", title: "Prayer Meeting" });
|
|
94
|
+
await tx.events.archive({ ids: [3, 4] });
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The transaction commits when the function returns and rolls back when it
|
|
99
|
+
throws. Whether a transaction inside a transaction opens a savepoint is up
|
|
100
|
+
to the driver.
|
|
101
|
+
|
|
102
|
+
## Migrations and seeds
|
|
103
|
+
|
|
104
|
+
`migrate` applies the migrations the database lacks. The kit builds the
|
|
105
|
+
list: each entry has a version, a name, its SQL, and a checksum.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { migrations } from "@bbf/database/migrations";
|
|
109
|
+
import { seeds } from "@bbf/database/seeds";
|
|
110
|
+
|
|
111
|
+
const applied = await db.migrate(migrations); // the versions it applied
|
|
112
|
+
await db.seed(seeds);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The run is one transaction under an advisory lock. The lock makes two runs
|
|
116
|
+
wait for each other, so a rolling deploy applies each migration once. The
|
|
117
|
+
transaction makes the run all or nothing. The runner keeps each applied
|
|
118
|
+
version and its checksum in `bezoar_migrations`.
|
|
119
|
+
|
|
120
|
+
Before it applies anything, the runner compares the list with the database.
|
|
121
|
+
An applied version with another checksum, or an applied version that is not
|
|
122
|
+
in the list, is a `MigrationDriftError`: the database and the build
|
|
123
|
+
disagree, and the runner does not guess. A migration that fails is a
|
|
124
|
+
`MigrationError` with its version, and the run rolls back.
|
|
125
|
+
|
|
126
|
+
`seed` runs the seeds in order inside one transaction. Seeds are not
|
|
127
|
+
tracked. Write a seed so that it can run again. A seed that fails is a
|
|
128
|
+
`SeedError`, and the run rolls back.
|
|
129
|
+
|
|
130
|
+
## Names
|
|
131
|
+
|
|
132
|
+
The members of the service are `config`, `schema`, `query`, `transaction`,
|
|
133
|
+
`migrate`, and `seed`. A query group cannot take one of these names, and a
|
|
134
|
+
name cannot be both a query and a group. Either is an error at
|
|
135
|
+
construction: `ReservedQueryError` or `AmbiguousQueryError`.
|
|
136
|
+
|
|
137
|
+
## Errors
|
|
138
|
+
|
|
139
|
+
- `InvalidBundleError`: the SQL bundle does not match the contract.
|
|
140
|
+
- `InvalidArgumentsError`: the arguments of a call do not fit the slots.
|
|
141
|
+
- `InvalidRowError`: a row does not fit the columns.
|
|
142
|
+
- `QueryError`: the driver failed. `cause` is the driver's error.
|
|
143
|
+
- `MigrationDriftError`: the database holds a migration the list does not
|
|
144
|
+
match.
|
|
145
|
+
- `MigrationError`, `SeedError`: a migration or a seed failed. `cause` is
|
|
146
|
+
the driver's error.
|
|
147
|
+
- `ReservedQueryError`, `AmbiguousQueryError`: a query name the service
|
|
148
|
+
cannot place.
|
|
149
|
+
|
|
150
|
+
## Types
|
|
151
|
+
|
|
152
|
+
- `Bezoar<D>` is the service: `Service<D>` and the `Groups<D>`.
|
|
153
|
+
- `Config<D>` is the container. `Options<D>` has `onQuery`.
|
|
154
|
+
- `Executor` and `Outcome` are what a driver provides.
|
|
155
|
+
- `Method<D, Q>` is the function of one query. `Report<D>` is what `onQuery`
|
|
156
|
+
receives.
|
|
157
|
+
- `Migration` and `Seed` are what the kit builds for the runner.
|
|
158
|
+
|
|
159
|
+
## Related
|
|
160
|
+
|
|
161
|
+
- [`@bezoar/schema`](../schema): the contract types and the validation.
|
|
162
|
+
- `@bezoar/pglite`: an executor over PGlite.
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bezoar/core",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "The bezoar runtime service: run queries by name, migrate, seed",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/zoobzio/bezoar.git",
|
|
9
|
+
"directory": "packages/core"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./.dist/index.mjs",
|
|
13
|
+
"types": "./.dist/index.d.mts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./.dist/index.d.mts",
|
|
17
|
+
"import": "./.dist/index.mjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
".dist"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"objectively": "^0.3.0",
|
|
25
|
+
"@bezoar/schema": "0.0.1"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "obuild",
|
|
29
|
+
"stub": "obuild --stub",
|
|
30
|
+
"lint": "oxlint",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"test": "vitest run"
|
|
33
|
+
}
|
|
6
34
|
}
|