@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.
- package/dist/cjs/bun-postgres.js +75 -0
- package/dist/cjs/bun-sqlite.js +94 -0
- package/dist/cjs/index.js +453 -0
- package/dist/cjs/standard-schema.js +240 -0
- package/dist/cjs/utils.js +17 -0
- package/dist/esm/bun-postgres.js +71 -0
- package/dist/esm/bun-sqlite.js +90 -0
- package/dist/esm/index.js +408 -0
- package/dist/esm/standard-schema.js +229 -0
- package/dist/esm/utils.js +10 -0
- package/dist/types/bun-postgres.d.ts +16 -0
- package/dist/types/bun-sqlite.d.ts +16 -0
- package/dist/types/index.d.ts +315 -0
- package/dist/types/standard-schema.d.ts +174 -0
- package/dist/types/utils.d.ts +11 -0
- package/package.json +6 -6
- package/dist/.gitignore +0 -2
|
@@ -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, };
|