@rwillians/exto 0.1.0 → 0.1.2

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,408 @@
1
+ import * as std from './standard-schema';
2
+ import { ValidationError } from './standard-schema';
3
+ import { camelCase, snakeCase, wrap } from './utils';
4
+ class ColumnBuilder {
5
+ props;
6
+ constructor(props) {
7
+ this.props = props;
8
+ }
9
+ default(value) {
10
+ return new ColumnBuilder({ ...this.props, default: value });
11
+ }
12
+ generated() {
13
+ if (this.props.type !== 'UUID')
14
+ throw new Error('Only UUID columns can be generated, use t.sequence() for integers');
15
+ return new ColumnBuilder({ ...this.props, generated: true });
16
+ }
17
+ nullable() {
18
+ if (this.props.primaryKey)
19
+ throw new Error('Cannot make a primary key column nullable');
20
+ return new ColumnBuilder({ ...this.props, nullable: true, schema: std.nullable(this.props.schema) });
21
+ }
22
+ primaryKey() {
23
+ if (this.props.nullable)
24
+ throw new Error('Cannot make a nullable column a primary key');
25
+ return new ColumnBuilder({ ...this.props, primaryKey: true });
26
+ }
27
+ unique() {
28
+ if (this.props.primaryKey)
29
+ throw new Error('Primary key columns are already unique');
30
+ return new ColumnBuilder({ ...this.props, unique: true });
31
+ }
32
+ }
33
+ const column = (props) => new ColumnBuilder(props);
34
+ const types = {
35
+ bigint: () => column({ type: 'BIGINT', schema: std.bigint() }),
36
+ binary: () => column({ type: 'BINARY', schema: std.instanceOf(Uint8Array) }),
37
+ boolean: () => column({ type: 'BOOLEAN', schema: std.boolean() }),
38
+ // case-insensitive text; postgres needs `CREATE EXTENSION citext` for it
39
+ citext: () => column({ type: 'CITEXT', schema: std.string() }),
40
+ date: () => column({ type: 'DATE', schema: std.isoDate() }),
41
+ datetime: () => column({ type: 'DATETIME', schema: std.date() }),
42
+ // `precision` and `scale` only shape postgres' NUMERIC; values are still
43
+ // numbers, so they're as exact as a double
44
+ decimal: (options) => column({ type: 'DECIMAL', schema: std.number(), ...options }),
45
+ enum: (values) => column({
46
+ type: 'STRING',
47
+ schema: std.enumOf(values),
48
+ length: Math.max(...values.map(value => value.length)),
49
+ }),
50
+ float: () => column({ type: 'FLOAT', schema: std.number() }),
51
+ integer: () => column({ type: 'INTEGER', schema: std.integer() }),
52
+ json: (schema) => column({ type: 'JSON', schema }),
53
+ sequence: () => column({ type: 'SEQUENCE', schema: std.integer(), generated: true }),
54
+ string: ({ size = 255 } = {}) => column({ type: 'STRING', schema: std.string({ max: size }), length: size }),
55
+ text: () => column({ type: 'TEXT', schema: std.string() }),
56
+ uuid: () => column({ type: 'UUID', schema: std.uuid() }),
57
+ };
58
+ const defineTable = (name, shapeFn) => {
59
+ const columns = Object.fromEntries(Object
60
+ .entries(shapeFn(types))
61
+ .map(([key, { props }]) => [camelCase(key), { ...props, key: camelCase(key), name: snakeCase(camelCase(key)) }]));
62
+ const aliased = (alias) => ({ name, alias, columns, as: aliased });
63
+ return aliased(name);
64
+ };
65
+ const columnOf = (table, key) => {
66
+ const col = table.columns[key];
67
+ if (!col)
68
+ throw new Error(`Unknown column "${key}" in table "${table.name}"`);
69
+ return col;
70
+ };
71
+ const quote = (ident) => `"${ident.replaceAll('"', '""')}"`;
72
+ const ref = (col, qualifier) => qualifier
73
+ ? `${quote(qualifier)}.${quote(col.name)}`
74
+ : quote(col.name);
75
+ class SqlBuilder {
76
+ dialect;
77
+ frags = [];
78
+ params = [];
79
+ constructor(dialect) {
80
+ this.dialect = dialect;
81
+ }
82
+ push(...frags) {
83
+ this.frags.push(...frags);
84
+ return this;
85
+ }
86
+ param(value) {
87
+ this.params.push(value);
88
+ return this.push(this.dialect.placeholder(this.params.length));
89
+ }
90
+ join(items, separator, fn) {
91
+ items.forEach((item, i) => {
92
+ if (i > 0)
93
+ this.push(separator);
94
+ fn(item);
95
+ });
96
+ return this;
97
+ }
98
+ build() {
99
+ return { sql: this.frags.join(''), params: this.params };
100
+ }
101
+ }
102
+ const encode = (dialect, col, value) => value === null || value === undefined
103
+ ? null
104
+ : dialect.codecs[col.type].encode(value);
105
+ const decode = (dialect, col, value) => value === null || value === undefined
106
+ ? null
107
+ : dialect.codecs[col.type].decode(value);
108
+ const decoder = (dialect, cols) => (row) => Object.fromEntries(cols.map(col => [col.key, decode(dialect, col, row[col.key])]));
109
+ const run = async (db, { sql, params }) => {
110
+ db.loggers.forEach(logger => logger.debug(sql, params));
111
+ try {
112
+ return await db.execute(sql, params);
113
+ }
114
+ catch (error) {
115
+ db.loggers.forEach(logger => logger.error(sql, params, error));
116
+ throw error;
117
+ }
118
+ };
119
+ // runs a raw statement, e.g. `CREATE EXTENSION` in a migration
120
+ const exec = async (db, sql, params = []) => {
121
+ await run(db, { sql, params });
122
+ };
123
+ const runReturning = async (db, b, table, returning) => {
124
+ if (returning === false) {
125
+ await run(db, b.build());
126
+ return undefined;
127
+ }
128
+ const cols = returning === true
129
+ ? Object.values(table.columns)
130
+ : returning.map(key => columnOf(table, key));
131
+ // an empty RETURNING isn't valid SQL, but it still takes one to know how
132
+ // many rows were affected
133
+ if (cols.length === 0) {
134
+ const rows = await run(db, b.push(' RETURNING 1').build());
135
+ return rows.map(() => ({}));
136
+ }
137
+ b.push(' RETURNING ').join(cols, ', ', col => b.push(ref(col), ' AS ', quote(col.key)));
138
+ const rows = await run(db, b.build());
139
+ return rows.map(decoder(db.dialect, cols));
140
+ };
141
+ const expr = Object.assign((key, op, value) => ({ key, op, value }), {
142
+ and: (exprs) => ({ group: 'AND', exprs }),
143
+ or: (exprs) => ({ group: 'OR', exprs }),
144
+ });
145
+ const toCondition = (table, node) => 'group' in node
146
+ ? { group: node.group, conditions: node.exprs.map(expr => toCondition(table, expr)) }
147
+ : { col: columnOf(table, node.key), op: node.op, value: node.value };
148
+ const renderCondition = (b, qualifier) => (condition) => {
149
+ if ('group' in condition) {
150
+ const { group, conditions } = condition;
151
+ if (conditions.length === 0)
152
+ return b.push(group === 'AND' ? '1 = 1' : '1 = 0');
153
+ return b.push('(').join(conditions, ` ${group} `, renderCondition(b, qualifier)).push(')');
154
+ }
155
+ const { col, op, value } = condition;
156
+ if (op === 'is' || op === 'is not')
157
+ return b.push(ref(col, qualifier), ' ', op.toUpperCase(), ' NULL');
158
+ if (op !== 'in' && op !== 'not in')
159
+ return b.push(ref(col, qualifier), ' ', op.toUpperCase(), ' ').param(encode(b.dialect, col, value));
160
+ const values = value;
161
+ if (values.length === 0)
162
+ return b.push(op === 'in' ? '1 = 0' : '1 = 1');
163
+ return b
164
+ .push(ref(col, qualifier), ' ', op.toUpperCase(), ' (')
165
+ .join(values, ', ', v => b.param(encode(b.dialect, col, v)))
166
+ .push(')');
167
+ };
168
+ const renderWhere = (b, conditions, qualifier) => conditions.length === 0
169
+ ? b
170
+ : b.push(' WHERE ').join(conditions, ' AND ', renderCondition(b, qualifier));
171
+ const parseChanges = (table, { set = {}, inc = {}, replace = [] }) => {
172
+ const updatable = Object.values(table.columns).filter(col => !col.generated && !col.primaryKey);
173
+ const numeric = updatable.filter(col => ['BIGINT', 'DECIMAL', 'FLOAT', 'INTEGER'].includes(col.type));
174
+ const sets = std.parseOrThrow(std.strictObject(Object.fromEntries(updatable.map(col => [col.key, std.optional(col.schema)]))), set);
175
+ const incs = std.parseOrThrow(std.strictObject(Object.fromEntries(numeric.map(col => [col.key, std.optional(std.number())]))), inc);
176
+ const replaced = replace.map(key => {
177
+ const col = updatable.find(col => col.key === key);
178
+ if (!col)
179
+ throw new Error(`Cannot replace "${key}": it's not an updatable field of "${table.name}"`);
180
+ return col;
181
+ });
182
+ return [
183
+ ...Object.entries(sets).filter(([, value]) => value !== undefined).map(([key, value]) => ({ col: columnOf(table, key), op: 'set', value })),
184
+ ...Object.entries(incs).filter(([, value]) => value !== undefined).map(([key, value]) => ({ col: columnOf(table, key), op: 'inc', value })),
185
+ ...replaced.map(col => ({ col, op: 'replace' })),
186
+ ];
187
+ };
188
+ // on conflict, `qualifier` tells the row's current value apart from EXCLUDED's
189
+ const renderAssignments = (b, assignments, qualifier) => b
190
+ .join(assignments, ', ', ({ col, op, value }) => {
191
+ if (op === 'replace')
192
+ return b.push(ref(col), ' = EXCLUDED.', quote(col.name));
193
+ if (op === 'inc')
194
+ return b.push(ref(col), ' = ', ref(col, qualifier), ' + ').param(value);
195
+ return b.push(ref(col), ' = ').param(encode(b.dialect, col, value));
196
+ });
197
+ const CLIENT_GENERATORS = {
198
+ UUID: () => Bun.randomUUIDv7(),
199
+ };
200
+ const isGeneratedByClient = (dialect, col) => col.generated === true && dialect.generated(col) === undefined;
201
+ const resolveInsertValue = (col, row) => {
202
+ if (col.generated) {
203
+ const generate = CLIENT_GENERATORS[col.type];
204
+ if (!generate)
205
+ throw new Error(`Cannot generate a value for column "${col.name}" client-side`);
206
+ return generate();
207
+ }
208
+ if (row[col.key] !== undefined)
209
+ return row[col.key];
210
+ if ('default' in col)
211
+ return typeof col.default === 'function' ? col.default() : col.default;
212
+ if (col.nullable)
213
+ return null;
214
+ return undefined;
215
+ };
216
+ class InsertBuilder {
217
+ table;
218
+ rows;
219
+ constructor(table, rows = []) {
220
+ this.table = table;
221
+ this.rows = rows;
222
+ }
223
+ values(rows) {
224
+ return new InsertBuilder(this.table, [...this.rows, ...wrap(rows)]);
225
+ }
226
+ async insert(db, { returning = true, conflict } = {}) {
227
+ if (this.rows.length === 0)
228
+ return returning === false ? undefined : [];
229
+ const cols = Object.values(this.table.columns);
230
+ const insertable = cols.filter(col => !col.generated || isGeneratedByClient(db.dialect, col));
231
+ const schema = std.array(std.strictObject(Object.fromEntries(insertable.map(col => [col.key, col.schema]))));
232
+ const rows = std.parseOrThrow(schema, this.rows.map(row => ({
233
+ ...row,
234
+ ...Object.fromEntries(insertable.map(col => [col.key, resolveInsertValue(col, row)])),
235
+ })));
236
+ const b = new SqlBuilder(db.dialect)
237
+ .push('INSERT INTO ', quote(this.table.name), ' (', insertable.map(col => quote(col.name)).join(', '), ') VALUES ');
238
+ b.join(rows, ', ', row => b
239
+ .push('(')
240
+ .join(insertable, ', ', col => b.param(encode(db.dialect, col, row[col.key])))
241
+ .push(')'));
242
+ if (conflict)
243
+ this.renderConflict(b, conflict);
244
+ return await runReturning(db, b, this.table, returning);
245
+ }
246
+ renderConflict(b, conflict) {
247
+ if (conflict.do === 'throw')
248
+ return b;
249
+ b.push(' ON CONFLICT');
250
+ if (conflict.on.length > 0)
251
+ b.push(' (', conflict.on.map(key => quote(columnOf(this.table, key).name)).join(', '), ')');
252
+ if (conflict.do === 'nothing')
253
+ return b.push(' DO NOTHING');
254
+ const assignments = parseChanges(this.table, conflict.do);
255
+ if (assignments.length === 0)
256
+ throw new Error('Nothing to update on conflict: `set`, `inc` and `replace` are empty');
257
+ return renderAssignments(b.push(' DO UPDATE SET '), assignments, this.table.name);
258
+ }
259
+ }
260
+ const into = (table) => new InsertBuilder(table);
261
+ // `S` holds the selected fields, every field unless `select` says otherwise
262
+ class QueryBuilder {
263
+ query;
264
+ constructor(query) {
265
+ this.query = query;
266
+ }
267
+ select(keys) {
268
+ return new QueryBuilder({ ...this.query, select: keys });
269
+ }
270
+ where(...args) {
271
+ const node = args.length === 1 ? args[0] : { key: args[0], op: args[1], value: args[2] };
272
+ return new QueryBuilder({ ...this.query, where: [...this.query.where, toCondition(this.query.table, node)] });
273
+ }
274
+ orderBy(keys) {
275
+ const orderBy = keys.map(key => typeof key === 'string'
276
+ ? [columnOf(this.query.table, key), 'ASC']
277
+ : [columnOf(this.query.table, key[0]), key[1]]);
278
+ return new QueryBuilder({ ...this.query, orderBy: [...this.query.orderBy, ...orderBy] });
279
+ }
280
+ limit(n) {
281
+ return new QueryBuilder({ ...this.query, limit: n });
282
+ }
283
+ offset(n) {
284
+ return new QueryBuilder({ ...this.query, offset: n });
285
+ }
286
+ async all(db) {
287
+ const { table, select } = this.query;
288
+ const cols = select
289
+ ? select.map(key => columnOf(table, key))
290
+ : Object.values(table.columns);
291
+ const rows = await run(db, this.renderSelect(new SqlBuilder(db.dialect), cols).build());
292
+ return rows.map(decoder(db.dialect, cols));
293
+ }
294
+ async one(db) {
295
+ const [row] = await this.top().all(db);
296
+ return row ?? null;
297
+ }
298
+ async exists(db) {
299
+ const rows = await this.top().select([]).all(db);
300
+ return rows.length > 0;
301
+ }
302
+ // counts the rows `select` would return: as a subquery, limit and offset
303
+ // still apply, and postgres doesn't take ORDER BY next to a bare COUNT(*)
304
+ async count(db) {
305
+ const b = new SqlBuilder(db.dialect).push('SELECT COUNT(*) AS "count" FROM (');
306
+ const [row] = await run(db, this.renderSelect(b, []).push(') AS "q"').build());
307
+ // postgres returns COUNT(*) as a bigint
308
+ return Number(row['count']);
309
+ }
310
+ async update(db, { returning = true, ...changes }) {
311
+ this.assertUnbounded('update');
312
+ const { table, where } = this.query;
313
+ const assignments = parseChanges(table, changes);
314
+ if (assignments.length === 0)
315
+ throw new Error('Nothing to update: both `set` and `inc` are empty');
316
+ const b = new SqlBuilder(db.dialect).push('UPDATE ', quote(table.name), ' SET ');
317
+ renderAssignments(b, assignments);
318
+ renderWhere(b, where);
319
+ return await runReturning(db, b, table, returning);
320
+ }
321
+ async delete(db, { returning = false } = {}) {
322
+ this.assertUnbounded('delete');
323
+ const { table, where } = this.query;
324
+ const b = new SqlBuilder(db.dialect).push('DELETE FROM ', quote(table.name));
325
+ renderWhere(b, where);
326
+ return await runReturning(db, b, table, returning);
327
+ }
328
+ renderSelect(b, cols) {
329
+ const { table, where, orderBy, limit, offset } = this.query;
330
+ b.push('SELECT ');
331
+ // an empty select list isn't valid SQL, but each matching row still needs
332
+ // an (empty) object
333
+ if (cols.length === 0)
334
+ b.push('1');
335
+ else
336
+ b.join(cols, ', ', col => b.push(ref(col, table.alias), ' AS ', quote(col.key)));
337
+ b.push(' FROM ', quote(table.name), ' AS ', quote(table.alias));
338
+ renderWhere(b, where, table.alias);
339
+ if (orderBy.length > 0)
340
+ b.push(' ORDER BY ').join(orderBy, ', ', ([col, dir]) => b.push(ref(col, table.alias), ' ', dir));
341
+ // sqlite requires LIMIT whenever there's an OFFSET, and there's no "no
342
+ // limit" value both sqlite and postgres accept other than a huge number
343
+ if (limit !== undefined || offset !== undefined)
344
+ b.push(' LIMIT ').param(limit ?? Number.MAX_SAFE_INTEGER);
345
+ if (offset !== undefined)
346
+ b.push(' OFFSET ').param(offset);
347
+ return b;
348
+ }
349
+ // keeps a `limit(0)`, which can never match
350
+ top() {
351
+ return this.limit(Math.min(this.query.limit ?? 1, 1));
352
+ }
353
+ // UPDATE/DELETE ... ORDER BY/LIMIT isn't portable, so refuse it rather than
354
+ // silently affecting more rows than the query suggests
355
+ assertUnbounded(operation) {
356
+ const { orderBy, limit, offset } = this.query;
357
+ if (orderBy.length > 0 || limit !== undefined || offset !== undefined) {
358
+ throw new Error(`Cannot ${operation} with orderBy, limit or offset`);
359
+ }
360
+ }
361
+ }
362
+ const from = (table) => new QueryBuilder({ table, where: [], orderBy: [] });
363
+ // ─── ddl ─────────────────────────────────────────────────────────────────────
364
+ const renderColumnDefinition = (dialect, col) => [
365
+ quote(col.name),
366
+ dialect.type(col),
367
+ col.primaryKey && 'PRIMARY KEY',
368
+ col.generated && dialect.generated(col),
369
+ !col.nullable && 'NOT NULL',
370
+ col.unique && 'UNIQUE',
371
+ ].filter(Boolean).join(' ');
372
+ const createIndex = (table, keys, { ifNotExists = false, name, unique = false, } = {}) => ({
373
+ onto: async (db) => {
374
+ const cols = keys.map(key => columnOf(table, key).name);
375
+ const index = name ?? `${table.name}_${cols.join('_')}_index`;
376
+ const sql = `CREATE ${unique ? 'UNIQUE ' : ''}INDEX ${ifNotExists ? 'IF NOT EXISTS ' : ''}${quote(index)} ON ${quote(table.name)} (${cols.map(quote).join(', ')})`;
377
+ await run(db, { sql, params: [] });
378
+ },
379
+ });
380
+ const create = {
381
+ table: (table, { ifNotExists = false } = {}) => ({
382
+ onto: async (db) => {
383
+ const cols = Object.values(table.columns).map(col => renderColumnDefinition(db.dialect, col));
384
+ const sql = `CREATE TABLE ${ifNotExists ? 'IF NOT EXISTS ' : ''}${quote(table.name)} (${cols.join(', ')})`;
385
+ await run(db, { sql, params: [] });
386
+ },
387
+ }),
388
+ index: createIndex,
389
+ uniqueIndex: (table, keys, options = {}) => createIndex(table, keys, { ...options, unique: true }),
390
+ };
391
+ // ─── migrations ──────────────────────────────────────────────────────────────
392
+ const schemaMigrations = defineTable('schema_migrations', t => ({
393
+ id: t.string().primaryKey(),
394
+ migratedAt: t.datetime(),
395
+ }));
396
+ const defineMigrations = (migrations) => async (db) => {
397
+ await create.table(schemaMigrations, { ifNotExists: true }).onto(db);
398
+ const applied = new Set((await from(schemaMigrations).select(['id']).all(db)).map(row => row.id));
399
+ for (const id of Object.keys(migrations).sort()) {
400
+ if (applied.has(id))
401
+ continue;
402
+ await db.transaction(async (tx) => {
403
+ await migrations[id](tx);
404
+ await into(schemaMigrations).values({ id, migratedAt: new Date() }).insert(tx);
405
+ });
406
+ }
407
+ };
408
+ export { ValidationError, column, create, defineMigrations, defineTable as table, exec, expr, from, into, types as t, };
@@ -0,0 +1,229 @@
1
+ // // // // // // // // // // // // // // // // // // // // // // // //
2
+ // STANDARD SCHEMA SPEC //
3
+ // // // // // // // // // // // // // // // // // // // // // // // //
4
+ // // // // // // // // // // // // // // // // // // // // // // // //
5
+ // STANDARD SCHEMA API //
6
+ // // // // // // // // // // // // // // // // // // // // // // // //
7
+ /**
8
+ * @public The Standard Schema interface.
9
+ * @since 0.1.0
10
+ * @version 1
11
+ */
12
+ export {};
13
+ /**
14
+ * @public Thrown when a value fails validation, carrying every issue found.
15
+ * @since 0.1.0
16
+ * @version 1
17
+ */
18
+ export class ValidationError extends Error {
19
+ issues;
20
+ constructor(issues) {
21
+ super('Failed validation:\n' + issues.map(issue => ` - ${formatPath(issue.path)}${issue.message}`).join('\n'));
22
+ this.issues = issues;
23
+ this.name = 'ValidationError';
24
+ }
25
+ }
26
+ /**
27
+ * @public Use any standard schema to parse a value.
28
+ * @since 0.1.0
29
+ * @version 1
30
+ */
31
+ export const parse = (schema, value) => {
32
+ const parsed = schema['~standard'].validate(value);
33
+ if (parsed instanceof Promise) {
34
+ throw new Error('async standard schema validators are not supported');
35
+ }
36
+ return parsed;
37
+ };
38
+ /**
39
+ * @public Like {@link parse}, but returns the value or throws a
40
+ * {@link ValidationError}.
41
+ * @since 0.1.0
42
+ * @version 1
43
+ */
44
+ export const parseOrThrow = (schema, value) => {
45
+ const parsed = parse(schema, value);
46
+ if (parsed.issues)
47
+ throw new ValidationError(parsed.issues);
48
+ return parsed.value;
49
+ };
50
+ // // // // // // // // // // // // // // // // // // // // // // // //
51
+ // UTILS //
52
+ // // // // // // // // // // // // // // // // // // // // // // // //
53
+ const formatPath = (path) => path && path.length > 0
54
+ ? path.map(segment => String(typeof segment === 'object' ? segment.key : segment)).join('.') + ': '
55
+ : '';
56
+ const prependPath = (path) => (issue) => ({
57
+ ...issue,
58
+ path: [path, ...(issue.path ?? [])],
59
+ });
60
+ const define = (validate) => ({
61
+ '~standard': { version: 1, vendor: 'exto', validate },
62
+ });
63
+ const fail = (message) => ({ issues: [{ message }] });
64
+ // // // // // // // // // // // // // // // // // // // // // // // //
65
+ // BUILT-IN VALIDATORS //
66
+ // // // // // // // // // // // // // // // // // // // // // // // //
67
+ /**
68
+ * @public Defines an array of a given standard schema.
69
+ * @since 0.1.0
70
+ * @version 1
71
+ */
72
+ export const array = (schema) => define((input) => {
73
+ if (!Array.isArray(input))
74
+ return fail('must be an array');
75
+ const issues = [];
76
+ const value = [];
77
+ input.forEach((item, i) => {
78
+ const parsed = parse(schema, item);
79
+ if (parsed.issues)
80
+ issues.push(...parsed.issues.map(prependPath(i)));
81
+ else
82
+ value.push(parsed.value);
83
+ });
84
+ return issues.length > 0
85
+ ? { issues }
86
+ : { value };
87
+ });
88
+ /**
89
+ * @public Defines a standard schema for bigints.
90
+ * @since 0.1.0
91
+ * @version 1
92
+ */
93
+ export const bigint = () => define((input) => typeof input === 'bigint'
94
+ ? { value: input }
95
+ : fail('must be a bigint'));
96
+ /**
97
+ * @public Defines a standard schema for boolean values.
98
+ * @since 0.1.0
99
+ * @version 1
100
+ */
101
+ export const boolean = () => define((input) => typeof input === 'boolean'
102
+ ? { value: input }
103
+ : fail('must be a boolean'));
104
+ /**
105
+ * @public Defines a standard schema for dates, coercing ISO 8601 strings
106
+ * and epoch timestamps (in milliseconds) into `Date`.
107
+ * @since 0.1.0
108
+ * @version 1
109
+ */
110
+ export const date = () => define((input) => {
111
+ const value = input instanceof Date ? input
112
+ : typeof input === 'string' || typeof input === 'number' ? new Date(input)
113
+ : undefined;
114
+ return value && !Number.isNaN(value.getTime())
115
+ ? { value }
116
+ : fail('must be a valid date, ISO 8601 string or epoch timestamp in milliseconds');
117
+ });
118
+ /**
119
+ * @public Defines a standard schema for one of the given string values.
120
+ * @since 0.1.0
121
+ * @version 1
122
+ */
123
+ export const enumOf = (values) => define((input) => values.includes(input)
124
+ ? { value: input }
125
+ : fail(`must be one of: ${values.join(', ')}`));
126
+ /**
127
+ * @public Defines a standard schema for instances of a given class.
128
+ * @since 0.1.0
129
+ * @version 1
130
+ */
131
+ export const instanceOf = (ctor) => define((input) => input instanceof ctor
132
+ ? { value: input }
133
+ : fail(`must be an instance of ${ctor.name}`));
134
+ /**
135
+ * @public Defines a standard schema for integers, within the safe integer
136
+ * range by default.
137
+ * @since 0.1.0
138
+ * @version 1
139
+ */
140
+ export const integer = ({ min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = {}) => define((input) => {
141
+ if (!Number.isInteger(input))
142
+ return fail('must be an integer');
143
+ if (input < min)
144
+ return fail(`must be greater than or equal to ${min}`);
145
+ if (input > max)
146
+ return fail(`must be less than or equal to ${max}`);
147
+ return { value: input };
148
+ });
149
+ /**
150
+ * @public Defines a standard schema for calendar dates as `YYYY-MM-DD`
151
+ * strings.
152
+ * @since 0.1.0
153
+ * @version 1
154
+ */
155
+ export const isoDate = () => define((input) => {
156
+ const valid = typeof input === 'string'
157
+ && /^\d{4}-\d{2}-\d{2}$/.test(input)
158
+ && new Date(`${input}T00:00:00Z`).toISOString().startsWith(input);
159
+ return valid
160
+ ? { value: input }
161
+ : fail('must be a valid date in the YYYY-MM-DD format');
162
+ });
163
+ /**
164
+ * @public Makes a given standard schema accept `null`.
165
+ * @since 0.1.0
166
+ * @version 1
167
+ */
168
+ export const nullable = (schema) => define((input) => input === null
169
+ ? { value: null }
170
+ : parse(schema, input));
171
+ /**
172
+ * @public Defines a standard schema for finite numbers.
173
+ * @since 0.1.0
174
+ * @version 1
175
+ */
176
+ export const number = () => define((input) => Number.isFinite(input)
177
+ ? { value: input }
178
+ : fail('must be a finite number'));
179
+ /**
180
+ * @public Makes a given standard schema accept `undefined`.
181
+ * @since 0.1.0
182
+ * @version 1
183
+ */
184
+ export const optional = (schema) => define((input) => input === undefined
185
+ ? { value: undefined }
186
+ : parse(schema, input));
187
+ /**
188
+ * @public Defines a standard schema for strings, optionally limited in
189
+ * length.
190
+ * @since 0.1.0
191
+ * @version 1
192
+ */
193
+ export const string = ({ max } = {}) => define((input) => {
194
+ if (typeof input !== 'string')
195
+ return fail('must be a string');
196
+ if (max !== undefined && input.length > max)
197
+ return fail(`must be at most ${max} characters long`);
198
+ return { value: input };
199
+ });
200
+ export const strictObject = (shape) => define((input) => {
201
+ if (typeof input !== 'object' || input === null || Array.isArray(input))
202
+ return fail('must be an object');
203
+ const record = input;
204
+ const issues = Object
205
+ .keys(record)
206
+ .filter(key => !(key in shape))
207
+ .map(key => ({ path: [key], message: 'unknown field' }));
208
+ const value = {};
209
+ for (const [key, schema] of Object.entries(shape)) {
210
+ const parsed = parse(schema, record[key]);
211
+ if (parsed.issues && record[key] === undefined)
212
+ issues.push({ path: [key], message: 'is required' });
213
+ else if (parsed.issues)
214
+ issues.push(...parsed.issues.map(prependPath(key)));
215
+ else if (parsed.value !== undefined)
216
+ value[key] = parsed.value;
217
+ }
218
+ return issues.length > 0
219
+ ? { issues }
220
+ : { value: value };
221
+ });
222
+ /**
223
+ * @public Defines a standard schema for UUID strings (any version).
224
+ * @since 0.1.0
225
+ * @version 1
226
+ */
227
+ export const uuid = () => define((input) => typeof input === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(input)
228
+ ? { value: input }
229
+ : fail('must be a valid UUID'));
@@ -0,0 +1,10 @@
1
+ export const camelCase = (str) => str.replace(/_([a-z0-9])/g, (_, char) => char.toUpperCase());
2
+ export const snakeCase = (str) => str.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`);
3
+ export const wrap = (value) => Array.isArray(value)
4
+ ? value
5
+ : [value];
6
+ export function raise(ctorOrMessage, messageOrCause, cause) {
7
+ if (typeof ctorOrMessage === 'string')
8
+ throw new Error(ctorOrMessage, { cause: messageOrCause });
9
+ throw new ctorOrMessage(messageOrCause, { cause });
10
+ }
@@ -0,0 +1,16 @@
1
+ import { SQL } from 'bun';
2
+ import type { Dialect, IDatabase, ILogger, Row } from './index';
3
+ declare const dialect: Dialect;
4
+ declare class BunPostgres implements IDatabase {
5
+ private readonly sql;
6
+ readonly loggers: ILogger[];
7
+ private readonly inTransaction;
8
+ readonly dialect: Dialect;
9
+ constructor(sql: SQL, loggers?: ILogger[], inTransaction?: boolean);
10
+ execute(sql: string, params: unknown[]): Promise<Row[] & SQL.ResultMetadata>;
11
+ transaction<T>(fn: (tx: IDatabase) => Promise<T>): Promise<T>;
12
+ }
13
+ declare const connect: (url: string, { loggers }?: {
14
+ loggers?: ILogger[];
15
+ }) => BunPostgres;
16
+ export { connect, dialect, };
@@ -0,0 +1,16 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import type { Dialect, IDatabase, ILogger, Row } from './index';
3
+ declare const dialect: Dialect;
4
+ declare class BunSQLite implements IDatabase {
5
+ private readonly conn;
6
+ readonly loggers: ILogger[];
7
+ private readonly depth;
8
+ readonly dialect: Dialect;
9
+ constructor(conn: Database, loggers?: ILogger[], depth?: number);
10
+ execute(sql: string, params: unknown[]): Promise<Row[]>;
11
+ transaction<T>(fn: (tx: IDatabase) => Promise<T>): Promise<T>;
12
+ }
13
+ declare const connect: (filename?: string, { loggers }?: {
14
+ loggers?: ILogger[];
15
+ }) => BunSQLite;
16
+ export { connect, dialect, };