@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.
@@ -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 {};
@@ -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
- # Temporary Holding Version
1
+ # @bezoar/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
  }