@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/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
+ }
@@ -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 }