@bakery-framework/orm 1.0.0
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/LICENSE +19 -0
- package/README.md +88 -0
- package/package.json +59 -0
- package/src/adapters/base.ts +1128 -0
- package/src/adapters/mysql.ts +619 -0
- package/src/adapters/observe.ts +261 -0
- package/src/adapters/pgsql.ts +611 -0
- package/src/adapters/registry.ts +204 -0
- package/src/adapters/sqlite.ts +588 -0
- package/src/adapters.ts +72 -0
- package/src/backup.ts +37 -0
- package/src/connection.ts +69 -0
- package/src/define.ts +380 -0
- package/src/field.ts +595 -0
- package/src/globals.d.ts +22 -0
- package/src/index.ts +63 -0
- package/src/orm/index.ts +24 -0
- package/src/orm/mutation.ts +692 -0
- package/src/orm/query.ts +1680 -0
- package/src/pool.ts +83 -0
- package/src/schema-registry.ts +75 -0
- package/src/schema-util.ts +467 -0
- package/src/sync/builder.ts +618 -0
- package/src/sync/engine.ts +399 -0
- package/src/sync/helpers.ts +1119 -0
- package/src/sync/history.ts +169 -0
- package/src/sync/index.ts +113 -0
- package/src/sync/ledger.ts +335 -0
- package/src/sync/load.ts +368 -0
- package/src/sync/rollback.ts +200 -0
- package/src/sync/types.ts +101 -0
- package/src/sync/view-sql.ts +160 -0
- package/templates/schema.example.ts +94 -0
package/src/field.ts
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { throws } from '@bakery-framework/core/utils/common'
|
|
2
|
+
import type { DataTypes, TableDef } from './schema-util'
|
|
3
|
+
import type * as SyncTypes from './sync/types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build a column descriptor.
|
|
7
|
+
*
|
|
8
|
+
* The former `value()` primitive, now private and two arguments shorter.
|
|
9
|
+
* `autoIncrement` and `primary` were positional booleans that only
|
|
10
|
+
* `Field.Primary()` ever set, and it can state them directly — which is the
|
|
11
|
+
* whole reason `value('integer', undefined, false, true, true)` was worth
|
|
12
|
+
* replacing.
|
|
13
|
+
*
|
|
14
|
+
* `n === true`, not `n !== undefined`: treating *any* third argument as
|
|
15
|
+
* "nullable" is a bug this file has already had, where an explicit
|
|
16
|
+
* `false` produced a nullable column. `=== true` also matches what `TableDef`
|
|
17
|
+
* computes from `N extends true`, so the emitted DDL and the inferred row type
|
|
18
|
+
* cannot disagree.
|
|
19
|
+
*/
|
|
20
|
+
function column<T, N extends boolean = false, O extends boolean = false>(
|
|
21
|
+
sql: DataTypes,
|
|
22
|
+
d?: unknown,
|
|
23
|
+
nullable?: boolean,
|
|
24
|
+
): TableDef<T, N, O> {
|
|
25
|
+
const result: Record<string, unknown> = { type: sql }
|
|
26
|
+
if (d !== undefined) result.default = d
|
|
27
|
+
if (nullable === true || d === null) result.nullable = true
|
|
28
|
+
// The runtime object is byte-for-byte what it was before `TableDef` changed
|
|
29
|
+
// shape: `type` holds the dialect name, `optional` is type-level only. That
|
|
30
|
+
// is the whole safety argument for this change — no adapter, no generated
|
|
31
|
+
// file and no stored ledger payload sees any difference.
|
|
32
|
+
return result as unknown as TableDef<T, N, O>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `T` when the column is NOT NULL, `T | null` when it is nullable. */
|
|
36
|
+
type Nullable<T, N extends boolean> = N extends true ? T | null : T
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Optional on insert when there is a default, or the column is nullable.
|
|
40
|
+
*
|
|
41
|
+
* Computed once here so each builder does not restate it, and so the rule is
|
|
42
|
+
* in one place rather than implied by five `TableDef` arguments.
|
|
43
|
+
*/
|
|
44
|
+
type OptionalFor<D> = D extends undefined ? false : true
|
|
45
|
+
|
|
46
|
+
const isColumnValue = (v: unknown): v is ColumnValue =>
|
|
47
|
+
Boolean(v) &&
|
|
48
|
+
typeof v === 'object' &&
|
|
49
|
+
'__table' in (v as any) &&
|
|
50
|
+
'__column' in (v as any)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve either calling convention to `{ table, cols }`.
|
|
54
|
+
*
|
|
55
|
+
* The string form — `Field.Index('posts', ['authorId'])` — cannot catch a typo
|
|
56
|
+
* in either argument until sync time, if then. The column form —
|
|
57
|
+
* `Field.Index(posts.authorId)` — carries its own table, so that argument
|
|
58
|
+
* disappears and a mistake becomes a compile error.
|
|
59
|
+
*/
|
|
60
|
+
function resolveTarget(
|
|
61
|
+
first: string | ColumnValue,
|
|
62
|
+
rest: (string | ColumnValue | string[])[],
|
|
63
|
+
): { table: string; cols: string[] } {
|
|
64
|
+
if (isColumnValue(first)) {
|
|
65
|
+
const columns = [first, ...rest.filter(isColumnValue)]
|
|
66
|
+
const tables = new Set(columns.map(c => c.__table))
|
|
67
|
+
if (tables.size > 1) {
|
|
68
|
+
throws(`Constraint spans more than one table: ${[...tables].join(', ')}`)
|
|
69
|
+
}
|
|
70
|
+
return { table: first.__table, cols: columns.map(c => c.__column) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const cols = rest[0]
|
|
74
|
+
return {
|
|
75
|
+
table: first,
|
|
76
|
+
cols: Array.isArray(cols) ? cols : [cols as string],
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Referential actions. Both default to `NO ACTION`, as SQL does. */
|
|
81
|
+
export interface ForeignKeyActions {
|
|
82
|
+
onDelete?: SyncTypes.ForeignKeyAction
|
|
83
|
+
onUpdate?: SyncTypes.ForeignKeyAction
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The shape `Field.Foreign()` contributes to a row type.
|
|
88
|
+
*
|
|
89
|
+
* Always `integer`, because that is what `Field.Primary()` always is — an
|
|
90
|
+
* `INTEGER PRIMARY KEY AUTOINCREMENT` — and a foreign key exists to point at
|
|
91
|
+
* one. Nullable adds `| null`, which is the only variation worth having.
|
|
92
|
+
*
|
|
93
|
+
* The runtime object still resolves its type from the referenced column (see
|
|
94
|
+
* `Foreign` below), which matters for the rarer case of referencing a
|
|
95
|
+
* non-integer unique column: MySQL refuses a key whose types do not match
|
|
96
|
+
* exactly, so the DDL has to follow the target even where the row type says
|
|
97
|
+
* `number`.
|
|
98
|
+
*/
|
|
99
|
+
type ForeignDef<N extends true | undefined> = TableDef<
|
|
100
|
+
N extends true ? number | null : number,
|
|
101
|
+
N extends true ? true : false,
|
|
102
|
+
N extends true ? true : false
|
|
103
|
+
>
|
|
104
|
+
|
|
105
|
+
/** What `Field.Index` / `Field.Unique` accept — a column produced by `table()`. */
|
|
106
|
+
type ColumnValue = { __table: string; __column: string }
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What `Field.Enum` accepts: a literal array, or a TypeScript string enum.
|
|
110
|
+
*
|
|
111
|
+
* A string enum is an ordinary object at runtime (`{ Draft: 'draft' }`), so
|
|
112
|
+
* both forms reduce to the same list of members — `Object.values` for the
|
|
113
|
+
* object, the tuple itself for the array.
|
|
114
|
+
*
|
|
115
|
+
* **String enums only.** A *numeric* enum compiles to an object with a reverse
|
|
116
|
+
* mapping (`{ 0: 'A', A: 0 }`), so its `Object.values` are half names and half
|
|
117
|
+
* numbers, and the column here is text with a `CHECK` over string literals.
|
|
118
|
+
* Excluded by the constraint, and again at runtime for callers arriving from
|
|
119
|
+
* JavaScript, rather than quietly producing a column constrained to the wrong
|
|
120
|
+
* four values.
|
|
121
|
+
*/
|
|
122
|
+
type EnumSource = readonly string[] | Record<string, string>
|
|
123
|
+
|
|
124
|
+
/** The member union of either form. */
|
|
125
|
+
type EnumValues<V> = V extends readonly (infer U extends string)[]
|
|
126
|
+
? U
|
|
127
|
+
: V[keyof V]
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `Field` — the column vocabulary, namespaced so it is discoverable.
|
|
131
|
+
*
|
|
132
|
+
* This replaced `value('string', null, true, false, false)`, which required
|
|
133
|
+
* remembering a type string *and* the meaning of four positional booleans.
|
|
134
|
+
* `Field.String(null)` needs neither, and typing `Field.` lists everything a
|
|
135
|
+
* column can be.
|
|
136
|
+
*
|
|
137
|
+
* **`Field` is now the whole vocabulary, not sugar over one.** `value`,
|
|
138
|
+
* `primary`, `index`, `unique` and `foreign` are gone; the construction they
|
|
139
|
+
* did lives in `column()` and `resolveTarget()` above, private to this file.
|
|
140
|
+
* What the sync engine and the type inference consume is unchanged — plain
|
|
141
|
+
* descriptor objects — so this moved the API without moving the contract.
|
|
142
|
+
*
|
|
143
|
+
* The one shape deliberately left unspellable is **nullable *and* defaulted to
|
|
144
|
+
* something other than null**, because a null default is how you say nullable.
|
|
145
|
+
* Write that one as a literal (`{ type: 'integer', default: 0, nullable: true }`),
|
|
146
|
+
* which is what the schema generator emits for it too.
|
|
147
|
+
*
|
|
148
|
+
* Two conventions worth stating:
|
|
149
|
+
*
|
|
150
|
+
* - **`null` as the default means nullable**, as in `Field.String(null)`.
|
|
151
|
+
* does. `Field.String()` is NOT NULL with no default; `Field.String('')` is
|
|
152
|
+
* NOT NULL defaulting to empty; `Field.String(null)` is nullable.
|
|
153
|
+
* - **Modifiers are not chained.** A fluent `.nullable().primary()` has to
|
|
154
|
+
* return a builder that is also a `TableDef`, and that intersection is what
|
|
155
|
+
* broke inference when this was prototyped: `email` came out `string` rather
|
|
156
|
+
* than `string | null`. The named constructors below cover the real cases
|
|
157
|
+
* without the type gymnastics.
|
|
158
|
+
*/
|
|
159
|
+
export const Field = {
|
|
160
|
+
/**
|
|
161
|
+
* `INTEGER PRIMARY KEY AUTOINCREMENT` — the id column, spelled once.
|
|
162
|
+
*
|
|
163
|
+
* The single most repeated line in any schema, and the one most likely to be
|
|
164
|
+
* written wrong by hand: `value('integer', undefined, false, true, true)`.
|
|
165
|
+
*
|
|
166
|
+
* **Always an integer.** There is no string- or UUID-keyed variant here on
|
|
167
|
+
* purpose: `Field.Uuid()` gives you a generated UUID column, and pairing it
|
|
168
|
+
* with `Field.Unique()` is how you key a table on one. Keeping `Primary()` to
|
|
169
|
+
* exactly one meaning is what lets `Field.Foreign()` state its own type
|
|
170
|
+
* instead of inferring it.
|
|
171
|
+
*/
|
|
172
|
+
Primary: () =>
|
|
173
|
+
({
|
|
174
|
+
type: 'integer',
|
|
175
|
+
autoIncrement: true,
|
|
176
|
+
primary: true,
|
|
177
|
+
}) as unknown as TableDef<number, false, true>,
|
|
178
|
+
|
|
179
|
+
/** A whole number. */
|
|
180
|
+
Int: <D extends number | null | undefined = undefined>(d?: D) =>
|
|
181
|
+
column<
|
|
182
|
+
Nullable<number, D extends null ? true : false>,
|
|
183
|
+
D extends null ? true : false,
|
|
184
|
+
OptionalFor<D>
|
|
185
|
+
>('integer', d),
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A fractional number — `DOUBLE` on MySQL, `DOUBLE PRECISION` on Postgres,
|
|
189
|
+
* `REAL` on SQLite.
|
|
190
|
+
*
|
|
191
|
+
* Named `Float` rather than mirroring the underlying `'number'` type string,
|
|
192
|
+
* because `number` says nothing about precision and reads as "any number"
|
|
193
|
+
* next to `Int`.
|
|
194
|
+
*/
|
|
195
|
+
Float: <D extends number | null | undefined = undefined>(d?: D) =>
|
|
196
|
+
column<
|
|
197
|
+
Nullable<number, D extends null ? true : false>,
|
|
198
|
+
D extends null ? true : false,
|
|
199
|
+
OptionalFor<D>
|
|
200
|
+
>('number', d),
|
|
201
|
+
|
|
202
|
+
/** Text. `Text()` and `Varchar()` say which kind; this stays the plain one. */
|
|
203
|
+
String: <D extends string | null | undefined = undefined>(d?: D) =>
|
|
204
|
+
column<
|
|
205
|
+
Nullable<string, D extends null ? true : false>,
|
|
206
|
+
D extends null ? true : false,
|
|
207
|
+
OptionalFor<D>
|
|
208
|
+
>('string', d),
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Unbounded text — `TEXT` on every dialect.
|
|
212
|
+
*
|
|
213
|
+
* **MySQL rejects a literal `DEFAULT` on TEXT**, so this takes no default.
|
|
214
|
+
* That is not an omission: `Field.String('')` emits
|
|
215
|
+
* `TEXT NOT NULL DEFAULT ''`, which MySQL refuses outright with "BLOB, TEXT,
|
|
216
|
+
* GEOMETRY or JSON column can't have a default value" — the shipped schema
|
|
217
|
+
* template could not `db:sync` against MySQL because of exactly this. Use
|
|
218
|
+
* `Varchar` when you need a default.
|
|
219
|
+
*/
|
|
220
|
+
// Overloaded, so each call has one concrete type. A bare ternary infers the
|
|
221
|
+
// *union* of both branches, and `Field.Text(true).nullable` then fails to
|
|
222
|
+
// compile because `nullable` is absent from the other member.
|
|
223
|
+
Text: ((nullable?: true) =>
|
|
224
|
+
nullable ? column('string', null) : column('string')) as {
|
|
225
|
+
(): TableDef<string, false, false>
|
|
226
|
+
(nullable: true): TableDef<string | null, true, true>
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Sized text — `VARCHAR(n)`, and the answer to TEXT's default problem, since
|
|
231
|
+
* every dialect accepts a default on a sized column.
|
|
232
|
+
*
|
|
233
|
+
* slug: Field.Varchar(255, ''),
|
|
234
|
+
*
|
|
235
|
+
* SQLite has no real `VARCHAR` — all text is TEXT affinity — but it stores
|
|
236
|
+
* the declared type verbatim and reads it back, so one schema round-trips on
|
|
237
|
+
* all three.
|
|
238
|
+
*
|
|
239
|
+
* `length` is not part of the column diff, so **widening a Varchar does not
|
|
240
|
+
* migrate on its own**; see `ColumnConstraint.length` for why that is
|
|
241
|
+
* deliberate rather than missing.
|
|
242
|
+
*/
|
|
243
|
+
Varchar: <D extends string | null | undefined = undefined>(
|
|
244
|
+
length: number,
|
|
245
|
+
d?: D,
|
|
246
|
+
) =>
|
|
247
|
+
Object.assign(
|
|
248
|
+
column<
|
|
249
|
+
Nullable<string, D extends null ? true : false>,
|
|
250
|
+
D extends null ? true : false,
|
|
251
|
+
OptionalFor<D>
|
|
252
|
+
>('string', d),
|
|
253
|
+
{ length },
|
|
254
|
+
),
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* A 64-bit integer — `BIGINT` everywhere.
|
|
258
|
+
*
|
|
259
|
+
* Reads back as a **string** on MySQL and Postgres, which is how they avoid
|
|
260
|
+
* losing precision, and as a **number** on SQLite, which does not: values
|
|
261
|
+
* past 2^53 round. Measured on live servers, not assumed. If you need exact
|
|
262
|
+
* large integers on SQLite, store them as `Varchar`.
|
|
263
|
+
*/
|
|
264
|
+
BigInt: <D extends number | null | undefined = undefined>(d?: D) =>
|
|
265
|
+
column<
|
|
266
|
+
Nullable<number, D extends null ? true : false>,
|
|
267
|
+
D extends null ? true : false,
|
|
268
|
+
OptionalFor<D>
|
|
269
|
+
>('bigint' as any, d),
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A JSON document — `JSON` on MySQL, `JSONB` on Postgres, a `JSON`-declared
|
|
273
|
+
* text column on SQLite.
|
|
274
|
+
*
|
|
275
|
+
* MySQL and Postgres parse it into an object on read; SQLite hands back the
|
|
276
|
+
* raw string. The row type is therefore `unknown` — narrow it where you use
|
|
277
|
+
* it rather than trusting a type that would be wrong on one of the three.
|
|
278
|
+
*
|
|
279
|
+
* Takes no default, for the same reason `Text` does not: MySQL refuses a
|
|
280
|
+
* literal default on a JSON column.
|
|
281
|
+
*/
|
|
282
|
+
// Overloaded for the same reason as `Text` above.
|
|
283
|
+
Json: ((nullable?: true) =>
|
|
284
|
+
nullable ? column('json' as any, null) : column('json' as any)) as {
|
|
285
|
+
(): TableDef<unknown, false, false>
|
|
286
|
+
(nullable: true): TableDef<unknown, true, true>
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
/** True/false — `BOOLEAN` on Postgres, `TINYINT(1)` on MySQL. */
|
|
290
|
+
Bool: <D extends boolean | null | undefined = undefined>(d?: D) =>
|
|
291
|
+
column<
|
|
292
|
+
Nullable<boolean, D extends null ? true : false>,
|
|
293
|
+
D extends null ? true : false,
|
|
294
|
+
OptionalFor<D>
|
|
295
|
+
>('boolean', d),
|
|
296
|
+
|
|
297
|
+
/** Binary. Always nullable: no dialect here takes a binary literal default. */
|
|
298
|
+
Blob: () => column<Buffer | null, true, true>('buffer', null),
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A column that references another table's column.
|
|
302
|
+
*
|
|
303
|
+
* export const posts = table('posts', {
|
|
304
|
+
* id: Field.Primary(),
|
|
305
|
+
* authorId: Field.Foreign(users.id),
|
|
306
|
+
* })
|
|
307
|
+
*
|
|
308
|
+
* Replaces a separate `foreign(posts.authorId).references(users.id)` export
|
|
309
|
+
* for the common single-column case, and puts the reference on the column it
|
|
310
|
+
* constrains rather than somewhere else in the file where it can be forgotten
|
|
311
|
+
* or left unexported.
|
|
312
|
+
*
|
|
313
|
+
* **The column's type is copied from the target, not declared here**, and
|
|
314
|
+
* that is the real reason to prefer this form. MySQL refuses a foreign key
|
|
315
|
+
* whose column type does not match the referenced key *exactly* — an
|
|
316
|
+
* `INT` child against a `BIGINT` parent is rejected outright — and that
|
|
317
|
+
* mismatch is invisible in a schema where the two columns are declared pages
|
|
318
|
+
* apart. Resolution happens in `resolveColumnForeignKeys()`, where the whole
|
|
319
|
+
* schema is in scope, so the two cannot disagree.
|
|
320
|
+
*
|
|
321
|
+
* Composite keys still use `foreign()`: a multi-column reference has no
|
|
322
|
+
* single column to hang off.
|
|
323
|
+
*/
|
|
324
|
+
Foreign: Object.assign(
|
|
325
|
+
<N extends true | undefined = undefined>(
|
|
326
|
+
target: ColumnValue,
|
|
327
|
+
options: {
|
|
328
|
+
nullable?: N
|
|
329
|
+
/** Defaults to NO ACTION, as SQL does. */
|
|
330
|
+
onDelete?: SyncTypes.ForeignKeyAction
|
|
331
|
+
onUpdate?: SyncTypes.ForeignKeyAction
|
|
332
|
+
} = {},
|
|
333
|
+
) =>
|
|
334
|
+
({
|
|
335
|
+
// `integer` in the row type, always — see `ForeignDef`. At *runtime* the
|
|
336
|
+
// type is still overwritten from the referenced column by
|
|
337
|
+
// `resolveColumnForeignKeys()`, because MySQL refuses a key whose column
|
|
338
|
+
// type does not match the target exactly. For the ordinary case — a key
|
|
339
|
+
// pointing at a `Field.Primary()` — the two agree and there is nothing to
|
|
340
|
+
// reconcile.
|
|
341
|
+
type: 'integer',
|
|
342
|
+
...(options.nullable ? { nullable: true, default: null } : {}),
|
|
343
|
+
_references: {
|
|
344
|
+
table: target.__table,
|
|
345
|
+
column: target.__column,
|
|
346
|
+
onDelete: options.onDelete,
|
|
347
|
+
onUpdate: options.onUpdate,
|
|
348
|
+
},
|
|
349
|
+
}) as unknown as ForeignDef<N>,
|
|
350
|
+
{
|
|
351
|
+
/**
|
|
352
|
+
* A key spanning more than one column.
|
|
353
|
+
*
|
|
354
|
+
* Field.Foreign.composite(items.orderId, items.sku)
|
|
355
|
+
* .references(orders.id, orders.sku, { onDelete: 'CASCADE' })
|
|
356
|
+
*
|
|
357
|
+
* Separate from `Field.Foreign()` rather than an overload of it, because
|
|
358
|
+
* the two return different *kinds* of thing: `Field.Foreign(users.id)` is
|
|
359
|
+
* a column definition that goes inside a table, and this is a table-level
|
|
360
|
+
* constraint that goes beside one. Distinguishing them by argument count
|
|
361
|
+
* would make two calls that look alike mean different things.
|
|
362
|
+
*
|
|
363
|
+
* Variadic on both sides. `cols`/`refCols` have always been arrays and
|
|
364
|
+
* every adapter already emits a multi-column
|
|
365
|
+
* `FOREIGN KEY (a, b) REFERENCES t (x, y)`.
|
|
366
|
+
*/
|
|
367
|
+
composite: (...columns: ColumnValue[]) => {
|
|
368
|
+
// Validated here rather than in `references`, so a mistake is caught on
|
|
369
|
+
// the side that made it.
|
|
370
|
+
if (!columns.length) throws('Field.Foreign.composite() needs a column')
|
|
371
|
+
const table = columns[0]!.__table
|
|
372
|
+
if (columns.some(c => c.__table !== table))
|
|
373
|
+
throws(
|
|
374
|
+
`Field.Foreign.composite() columns must all belong to one table; got ${[
|
|
375
|
+
...new Set(columns.map(c => c.__table)),
|
|
376
|
+
].join(', ')}.`,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
references(...args: (ColumnValue | ForeignKeyActions)[]): any {
|
|
381
|
+
const targets = args.filter(isColumnValue) as ColumnValue[]
|
|
382
|
+
// The options object, when present, is the only non-column argument.
|
|
383
|
+
const actions =
|
|
384
|
+
(args.find(a => a && !isColumnValue(a)) as
|
|
385
|
+
| ForeignKeyActions
|
|
386
|
+
| undefined) ?? {}
|
|
387
|
+
|
|
388
|
+
if (!targets.length)
|
|
389
|
+
throws(
|
|
390
|
+
'Field.Foreign.composite().references() needs a target column',
|
|
391
|
+
)
|
|
392
|
+
if (targets.length !== columns.length)
|
|
393
|
+
throws(
|
|
394
|
+
`Field.Foreign.composite() references the wrong number of columns: ` +
|
|
395
|
+
`${columns.length} on ${table}, ${targets.length} on the target. ` +
|
|
396
|
+
'A composite key must name the same count on both sides, in the ' +
|
|
397
|
+
'same order.',
|
|
398
|
+
)
|
|
399
|
+
const refTable = targets[0]!.__table
|
|
400
|
+
if (targets.some(t => t.__table !== refTable))
|
|
401
|
+
throws(
|
|
402
|
+
`Field.Foreign.composite().references() targets must all belong to one table; got ${[
|
|
403
|
+
...new Set(targets.map(t => t.__table)),
|
|
404
|
+
].join(', ')}.`,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
table,
|
|
409
|
+
type: 'foreign',
|
|
410
|
+
cols: columns.map(c => c.__column),
|
|
411
|
+
refTable,
|
|
412
|
+
refCols: targets.map(t => t.__column),
|
|
413
|
+
onDelete: actions.onDelete,
|
|
414
|
+
onUpdate: actions.onUpdate,
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
),
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* A non-unique index.
|
|
424
|
+
*
|
|
425
|
+
* Field.Index(posts.authorId) // table() columns
|
|
426
|
+
* Field.Index(posts.authorId, posts.createdAt) // composite, in order
|
|
427
|
+
* Field.Index('posts', ['authorId']) // DBInfo layout
|
|
428
|
+
*
|
|
429
|
+
* The column form carries its own table, so there is no separate table
|
|
430
|
+
* argument to get wrong; the string form exists because the `DBInfo`
|
|
431
|
+
* namespace layout has no `table()` values to point at. Several columns make
|
|
432
|
+
* one composite index, in the order given — which is the order that decides
|
|
433
|
+
* which queries it can serve.
|
|
434
|
+
*
|
|
435
|
+
* A direct alias of `index()` rather than a wrapper, so the two cannot drift
|
|
436
|
+
* and both call signatures come along for free.
|
|
437
|
+
*/
|
|
438
|
+
Index: ((first: any, ...rest: any[]) => ({
|
|
439
|
+
type: 'index',
|
|
440
|
+
...resolveTarget(first, rest),
|
|
441
|
+
})) as {
|
|
442
|
+
(table: string, cols: string | string[]): any
|
|
443
|
+
(...cols: ColumnValue[]): any
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* A uniqueness constraint. Same call shapes as {@link Field.Index}.
|
|
448
|
+
*
|
|
449
|
+
* Also what makes a column a legal foreign-key *target*: SQL requires the
|
|
450
|
+
* referenced column to be a PRIMARY KEY or carry a UNIQUE index, and without
|
|
451
|
+
* one MySQL and Postgres refuse the CREATE while SQLite accepts it and then
|
|
452
|
+
* fails every insert with "foreign key mismatch".
|
|
453
|
+
*/
|
|
454
|
+
Unique: ((first: any, ...rest: any[]) => ({
|
|
455
|
+
type: 'unique',
|
|
456
|
+
...resolveTarget(first, rest),
|
|
457
|
+
})) as {
|
|
458
|
+
(table: string, cols: string | string[]): any
|
|
459
|
+
(...cols: ColumnValue[]): any
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* A UUID, generated by the database — `CHAR(36)` sized text with a
|
|
464
|
+
* per-dialect default expression.
|
|
465
|
+
*
|
|
466
|
+
* id: Field.Uuid(),
|
|
467
|
+
*
|
|
468
|
+
* `gen_random_uuid()` on Postgres, `UUID()` on MySQL, and
|
|
469
|
+
* `lower(hex(randomblob(16)))` shaped into the canonical form on SQLite,
|
|
470
|
+
* which has no UUID function of its own. All three round-trip through the
|
|
471
|
+
* `%uuid%` marker, the same way `Field.Date.now()` round-trips `%dateNow%` —
|
|
472
|
+
* without the read-back half the database reports its own expression, the
|
|
473
|
+
* schema says `%uuid%`, and the column rebuilds on every sync forever.
|
|
474
|
+
*
|
|
475
|
+
* Not a primary key by itself. `Field.Uuid()` next to no `Field.Primary()`
|
|
476
|
+
* gives a table with a unique-looking column and no key; add `unique()` or
|
|
477
|
+
* use it as one deliberately.
|
|
478
|
+
*/
|
|
479
|
+
Uuid: (nullable?: true) =>
|
|
480
|
+
Object.assign(
|
|
481
|
+
nullable
|
|
482
|
+
? column<string | null, true, true>('string', null)
|
|
483
|
+
: column<string, false, true>('string', '%uuid%'),
|
|
484
|
+
{ length: 36 },
|
|
485
|
+
),
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Text restricted to a fixed set of values, with the *union* as its type.
|
|
489
|
+
*
|
|
490
|
+
* status: Field.Enum(['draft', 'published'] as const, 'draft'),
|
|
491
|
+
*
|
|
492
|
+
* The row type is `'draft' | 'published'`, not `string`, so a typo is a
|
|
493
|
+
* compile error at the call site rather than a row nobody notices. `as const`
|
|
494
|
+
* is what makes that work; without it TypeScript widens the array to
|
|
495
|
+
* `string[]` and the column is just text.
|
|
496
|
+
*
|
|
497
|
+
* Enforced in the database too, and **the same way on all three dialects**:
|
|
498
|
+
* a `CHECK (col IN (…))`. MySQL has a native `ENUM` and this deliberately
|
|
499
|
+
* does not use it — a value rejected by MySQL and accepted by SQLite means
|
|
500
|
+
* an app that behaves differently depending on where it runs, which is worse
|
|
501
|
+
* than either choice made consistently.
|
|
502
|
+
*
|
|
503
|
+
* The members are **not part of the column diff** — see
|
|
504
|
+
* `ColumnConstraint._enum`. Adding or removing one does not migrate on its
|
|
505
|
+
* own; the table has to be rebuilt for the CHECK to change.
|
|
506
|
+
*/
|
|
507
|
+
Enum: <
|
|
508
|
+
const V extends EnumSource,
|
|
509
|
+
D extends EnumValues<V> | null | undefined = undefined,
|
|
510
|
+
>(
|
|
511
|
+
values: V,
|
|
512
|
+
d?: D,
|
|
513
|
+
) => {
|
|
514
|
+
const members = (
|
|
515
|
+
Array.isArray(values) ? [...values] : Object.values(values)
|
|
516
|
+
) as string[]
|
|
517
|
+
if (!members.length) throws('Field.Enum() needs at least one member')
|
|
518
|
+
const bad = members.find(m => typeof m !== 'string')
|
|
519
|
+
if (bad !== undefined) {
|
|
520
|
+
throws(
|
|
521
|
+
'Field.Enum() takes a string enum or an array of strings. A numeric ' +
|
|
522
|
+
'enum reverse-maps its members, so its values are half names and ' +
|
|
523
|
+
'half numbers — use Field.Int() and validate in your code, or give ' +
|
|
524
|
+
'the enum string values.',
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
return Object.assign(column('string', d as any), {
|
|
528
|
+
// Sized to the longest member so the column cannot be too small to hold
|
|
529
|
+
// a value the CHECK permits.
|
|
530
|
+
length: Math.max(1, ...members.map(m => m.length)),
|
|
531
|
+
// Typed as the member union, not `string[]`: `ExtractTableTypes` reads the
|
|
532
|
+
// element type out of here to build the row type, so widening it would
|
|
533
|
+
// silently turn the column back into plain text.
|
|
534
|
+
_enum: members,
|
|
535
|
+
// The enum union rides in `type` now, so it reaches the row type by the
|
|
536
|
+
// ordinary path. `ExtractTableTypes` no longer needs to read `_enum`
|
|
537
|
+
// *before* `type` to stop `TypeMap` widening it back to `string`.
|
|
538
|
+
}) as unknown as TableDef<
|
|
539
|
+
D extends null ? EnumValues<V> | null : EnumValues<V>,
|
|
540
|
+
D extends null ? true : false,
|
|
541
|
+
D extends undefined ? false : true
|
|
542
|
+
> & { length: number; _enum: readonly EnumValues<V>[] }
|
|
543
|
+
},
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The `createdAt` / `updatedAt` pair, spread into a table.
|
|
547
|
+
*
|
|
548
|
+
* users: {
|
|
549
|
+
* id: Field.Primary(),
|
|
550
|
+
* ...Field.Timestamps(),
|
|
551
|
+
* }
|
|
552
|
+
*
|
|
553
|
+
* Both are Unix seconds and both default to insert time.
|
|
554
|
+
*
|
|
555
|
+
* **`updatedAt` is not auto-maintained on write, and that is a deliberate
|
|
556
|
+
* limit rather than an oversight.** Doing it silently needs the query layer
|
|
557
|
+
* to know which tables have the column, and the query layer has no runtime
|
|
558
|
+
* view of the schema at all — `schema.ts` is loaded by the sync engine, not
|
|
559
|
+
* by the ORM. The alternatives were a `hasCol` probe on every UPDATE, or
|
|
560
|
+
* stamping a column that might not exist. Stamp it yourself:
|
|
561
|
+
*
|
|
562
|
+
* DB.Update('users').set({ name, updatedAt: Field.now() }).where(...)
|
|
563
|
+
*/
|
|
564
|
+
Timestamps: () => ({
|
|
565
|
+
createdAt: column<number, false, true>('integer', '%dateNow%'),
|
|
566
|
+
updatedAt: column<number, false, true>('integer', '%dateNow%'),
|
|
567
|
+
}),
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* The current time as Unix **seconds**, for a value position.
|
|
571
|
+
*
|
|
572
|
+
* A plain number, so it binds as an ordinary parameter and needs no dialect
|
|
573
|
+
* handling — unlike `%dateNow%`, which is a DDL default marker and means
|
|
574
|
+
* nothing in an INSERT or UPDATE.
|
|
575
|
+
*/
|
|
576
|
+
now: () => Math.floor(Date.now() / 1000),
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* A timestamp in Unix **seconds**, stored as an integer.
|
|
580
|
+
*
|
|
581
|
+
* `Field.Date.now()` fills in insert time via the `%dateNow%` marker, which
|
|
582
|
+
* each adapter renders in its own dialect. Seconds rather than milliseconds
|
|
583
|
+
* because that is what `%dateNow%` already produces on all three.
|
|
584
|
+
*/
|
|
585
|
+
Date: Object.assign(
|
|
586
|
+
<D extends number | null | undefined = undefined>(d?: D) =>
|
|
587
|
+
column<
|
|
588
|
+
Nullable<number, D extends null ? true : false>,
|
|
589
|
+
D extends null ? true : false,
|
|
590
|
+
OptionalFor<D>
|
|
591
|
+
>('integer', d),
|
|
592
|
+
// Optional on insert: the database supplies it.
|
|
593
|
+
{ now: () => column<number, false, true>('integer', '%dateNow%') },
|
|
594
|
+
),
|
|
595
|
+
}
|
package/src/globals.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AppDBOptionals, AppDBSchema } from './schema-registry'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ambient database types.
|
|
5
|
+
*
|
|
6
|
+
* These live with the orm package rather than in core's `global.d.ts`: they are
|
|
7
|
+
* derived from this package's schema registry, and core cannot reference orm —
|
|
8
|
+
* orm depends on core, so the reverse would be circular.
|
|
9
|
+
*
|
|
10
|
+
* Both are read only inside this package (`orm/query.ts`, `orm/mutation.ts`,
|
|
11
|
+
* `sync/builder.ts`), which is what makes moving them contained.
|
|
12
|
+
*/
|
|
13
|
+
declare global {
|
|
14
|
+
/** Table map, permissive until an app registers its schema. */
|
|
15
|
+
type DBSchema = AppDBSchema
|
|
16
|
+
/** Per-table union of columns that are optional on insert. */
|
|
17
|
+
type DBOptionals = AppDBOptionals
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// No `export {}` needed: the `import type` above already makes this a module,
|
|
21
|
+
// which is what `declare global` requires. Adding one as well is the reflex,
|
|
22
|
+
// and it is what `noUselessEmptyExport` flags.
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { DB, Mutation } from './orm'
|
|
2
|
+
|
|
3
|
+
export default DB
|
|
4
|
+
export type {
|
|
5
|
+
QueryEvent,
|
|
6
|
+
QueryMethod,
|
|
7
|
+
QueryObserver,
|
|
8
|
+
QueryObserverOptions,
|
|
9
|
+
} from './adapters/observe'
|
|
10
|
+
/**
|
|
11
|
+
* Query observability.
|
|
12
|
+
*
|
|
13
|
+
* Re-exported from the root rather than given a `./observe` subpath. The
|
|
14
|
+
* export map is closed and every entry in it is public API from the moment it
|
|
15
|
+
* ships, so a new subpath needs a reason — and there is none here: this is one
|
|
16
|
+
* function and three types, the observer is process-wide, and an app sets it
|
|
17
|
+
* once at boot next to where it already imports `DB`.
|
|
18
|
+
*
|
|
19
|
+
* `./adapters` *is* public now, but for writing an adapter, not for reaching
|
|
20
|
+
* these: an app that only wants an observer should not have to import the
|
|
21
|
+
* module that can open a database connection.
|
|
22
|
+
*/
|
|
23
|
+
export { getQueryObserver, setQueryObserver } from './adapters/observe'
|
|
24
|
+
export type {
|
|
25
|
+
InferOptionals,
|
|
26
|
+
InferSchema,
|
|
27
|
+
InferViews,
|
|
28
|
+
InsertOf,
|
|
29
|
+
RowOf,
|
|
30
|
+
TableColumn,
|
|
31
|
+
TableRef,
|
|
32
|
+
} from './define'
|
|
33
|
+
/**
|
|
34
|
+
* Schema authoring, from one place.
|
|
35
|
+
*
|
|
36
|
+
* `table`/`alias` live in `define.ts` and the column and constraint helpers in
|
|
37
|
+
* `schema-util.ts`, but that split is an implementation detail — someone
|
|
38
|
+
* writing `orm/schema.ts` should import from `@bakery-framework/orm` without having to
|
|
39
|
+
* know which file a helper happens to sit in.
|
|
40
|
+
*/
|
|
41
|
+
export { alias, table, view } from './define'
|
|
42
|
+
export { Field } from './field'
|
|
43
|
+
/**
|
|
44
|
+
* `TableDef` is the **column** descriptor — `TableDef<TYPE, nullable, optional>`
|
|
45
|
+
* — and it now comes from the root barrel, which is where someone writing a
|
|
46
|
+
* schema would look for it.
|
|
47
|
+
*
|
|
48
|
+
* It did not, and that was a defect rather than an omission: the barrel used to
|
|
49
|
+
* export `define.ts`'s same-named type, which describes a *table*. Two public
|
|
50
|
+
* types under one name, and nothing errors at the import site.
|
|
51
|
+
*/
|
|
52
|
+
export type {
|
|
53
|
+
ExtractOptionals,
|
|
54
|
+
ExtractTableTypes,
|
|
55
|
+
ExtractViews,
|
|
56
|
+
TableDef,
|
|
57
|
+
} from './schema-util'
|
|
58
|
+
export {
|
|
59
|
+
col,
|
|
60
|
+
dateNow,
|
|
61
|
+
old,
|
|
62
|
+
} from './schema-util'
|
|
63
|
+
export { DB, Mutation }
|