@fougere/adapter-sql 0.6.0-alpha.0 → 0.7.0-alpha.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.
Files changed (73) hide show
  1. package/README.md +2 -2
  2. package/dist/adapter.schema.json +16 -0
  3. package/dist/check.d.ts +2 -27
  4. package/dist/check.d.ts.map +1 -1
  5. package/dist/check.js +2 -27
  6. package/dist/check.js.map +1 -1
  7. package/dist/crud.d.ts +15 -81
  8. package/dist/crud.d.ts.map +1 -1
  9. package/dist/crud.js +21 -98
  10. package/dist/crud.js.map +1 -1
  11. package/dist/ddl.d.ts +8 -53
  12. package/dist/ddl.d.ts.map +1 -1
  13. package/dist/ddl.js +16 -55
  14. package/dist/ddl.js.map +1 -1
  15. package/dist/dialect.d.ts +7 -50
  16. package/dist/dialect.d.ts.map +1 -1
  17. package/dist/dialect.js +2 -5
  18. package/dist/dialect.js.map +1 -1
  19. package/dist/diff.d.ts +6 -44
  20. package/dist/diff.d.ts.map +1 -1
  21. package/dist/diff.js +20 -50
  22. package/dist/diff.js.map +1 -1
  23. package/dist/fields.d.ts +10 -9
  24. package/dist/fields.d.ts.map +1 -1
  25. package/dist/fields.js +8 -1
  26. package/dist/fields.js.map +1 -1
  27. package/dist/index.d.ts +4 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/order.d.ts +23 -0
  32. package/dist/order.d.ts.map +1 -0
  33. package/dist/order.js +40 -0
  34. package/dist/order.js.map +1 -0
  35. package/dist/query.d.ts +2 -17
  36. package/dist/query.d.ts.map +1 -1
  37. package/dist/query.js +1 -11
  38. package/dist/query.js.map +1 -1
  39. package/dist/setup.d.ts +6 -36
  40. package/dist/setup.d.ts.map +1 -1
  41. package/dist/setup.js +2 -16
  42. package/dist/setup.js.map +1 -1
  43. package/dist/sqlite.d.ts.map +1 -1
  44. package/dist/sqlite.js +2 -17
  45. package/dist/sqlite.js.map +1 -1
  46. package/dist/step.d.ts +4 -33
  47. package/dist/step.d.ts.map +1 -1
  48. package/dist/step.js +11 -46
  49. package/dist/step.js.map +1 -1
  50. package/dist/table.d.ts +15 -77
  51. package/dist/table.d.ts.map +1 -1
  52. package/dist/table.js +34 -146
  53. package/dist/table.js.map +1 -1
  54. package/dist/values.d.ts +1 -17
  55. package/dist/values.d.ts.map +1 -1
  56. package/dist/values.js +2 -7
  57. package/dist/values.js.map +1 -1
  58. package/package.json +4 -4
  59. package/src/adapter.schema.json +16 -0
  60. package/src/check.ts +2 -27
  61. package/src/crud.ts +22 -99
  62. package/src/ddl.ts +14 -55
  63. package/src/dialect.ts +7 -50
  64. package/src/diff.ts +18 -50
  65. package/src/fields.ts +17 -8
  66. package/src/index.ts +3 -3
  67. package/src/order.ts +63 -0
  68. package/src/query.ts +2 -17
  69. package/src/setup.ts +7 -43
  70. package/src/sqlite.ts +2 -17
  71. package/src/step.ts +12 -54
  72. package/src/table.ts +42 -185
  73. package/src/values.ts +3 -24
package/src/order.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * In what order a batch of tables is created — the one question that reads a `TableDef` and
3
+ * knows nothing above it: no entity, no axis, no schema.
4
+ */
5
+ import type { ColumnDef, TableDef } from './table.js';
6
+
7
+ export interface FkEdge {
8
+ table: TableDef;
9
+ column: ColumnDef;
10
+ }
11
+
12
+ export interface TableOrder {
13
+ /** Tables in dependency order — a `ref()` target always precedes its referrer. */
14
+ ordered: TableDef[];
15
+ /** FK columns whose target could not be ordered first — a cycle. Constrain after creation. */
16
+ deferred: FkEdge[];
17
+ }
18
+
19
+ /**
20
+ * Order a table set so a `ref()`'s target always exists before the table that points at it —
21
+ * required by every engine except SQLite, which resolves FK targets lazily and accepts any order
22
+ * (and has no `ALTER TABLE ADD CONSTRAINT` to close a cycle with — a caller on that dialect skips
23
+ * this function entirely).
24
+ */
25
+ export function orderTables(tables: TableDef[]): TableOrder {
26
+ const byName = new Map(tables.map((table) => [table.name, table]));
27
+ const needs = new Map(
28
+ tables.map((table) => [
29
+ table.name,
30
+ new Set(
31
+ table.columns
32
+ .filter((c) => c.references && c.references.table !== table.name && byName.has(c.references.table))
33
+ .map((c) => c.references!.table),
34
+ ),
35
+ ]),
36
+ );
37
+
38
+ const ordered: TableDef[] = [];
39
+ const deferred: FkEdge[] = [];
40
+ const done = new Set<string>();
41
+
42
+ while (needs.size > 0) {
43
+ const ready = [...needs.keys()].find((name) => [...needs.get(name)!].every((dep) => done.has(dep)));
44
+ if (ready) {
45
+ ordered.push(byName.get(ready)!);
46
+ done.add(ready);
47
+ needs.delete(ready);
48
+ continue;
49
+ }
50
+ // Every table left waits on another table left — a cycle. Break it by
51
+ // deferring one edge: every column of the first remaining table that points
52
+ // at its first unmet dependency.
53
+ const [name, deps] = [...needs.entries()][0];
54
+ const dep = [...deps].find((d) => !done.has(d))!;
55
+ const table = byName.get(name)!;
56
+ for (const column of table.columns) {
57
+ if (column.references?.table === dep) deferred.push({ table, column });
58
+ }
59
+ deps.delete(dep);
60
+ }
61
+
62
+ return { ordered, deferred };
63
+ }
package/src/query.ts CHANGED
@@ -5,12 +5,7 @@ export interface QueryEvent {
5
5
  /** Which storage ran it — a process may open several (`sources:`). */
6
6
  storage: string;
7
7
  sql: string;
8
- /**
9
- * How MANY parameters, never their values.
10
- *
11
- * They are user data nobody chose to expose — the rule the call log states for a body.
12
- * The count is what a reader needs anyway: it is how you see an `IN (?, ?, ?)` grow.
13
- */
8
+ /** How MANY parameters, never their values. */
14
9
  parameters: number;
15
10
  /** Rounded to three decimals: a sub-microsecond statement reports 17 digits otherwise. */
16
11
  ms: number;
@@ -22,17 +17,7 @@ export type QuerySink = (event: QueryEvent) => void;
22
17
 
23
18
  const sinks: QuerySink[] = [];
24
19
 
25
- /**
26
- * Be told about every statement this process runs, and get the unsubscription back.
27
- *
28
- * A subscription and not an option, for one reason that decides it: Kysely takes its `log`
29
- * at CONSTRUCTION, and the storage is built during the boot — before any extension's
30
- * `up(app)` runs. So the only way to subscribe afterwards is for the log to be installed
31
- * from the start and route to whoever is listening.
32
- *
33
- * The same shape core states for `onLog` and observability for `onSpan`. Costs nothing when
34
- * nobody listens: `logQueries` returns on an empty list before touching the event.
35
- */
20
+ /** Be told about every statement this process runs, and get the unsubscription back. */
36
21
  export function onQuery(sink: QuerySink): () => void {
37
22
  sinks.push(sink);
38
23
 
package/src/setup.ts CHANGED
@@ -1,12 +1,4 @@
1
- /**
2
- * Storage setup — the shape every engine answers, and no driver at all.
3
- *
4
- * Fougere has no business choosing a driver (`pg`, `mysql2`, `tedious`, `better-sqlite3`,
5
- * a D1 binding), so the caller builds the Kysely dialect and hands it over — no dynamic
6
- * import, no optional dependency. This file therefore reaches for nothing: it is what a
7
- * runtime with no filesystem imports. The one driver this package does own lives behind
8
- * `@fougere/adapter-sql/sqlite`.
9
- */
1
+ /** Storage setup — the shape every engine answers, and no driver at all. */
10
2
  import { Kysely, sql, type Dialect as KyselyDialect } from 'kysely';
11
3
  import type { Source, SourceView } from '@fougere/core';
12
4
  import { createStorageFactory, type StorageFactoryOptions } from './crud.js';
@@ -19,57 +11,29 @@ import type { SqlSink } from './ddl.js';
19
11
  export interface SetupOptions {
20
12
  /** Override naming for specific entities (e.g. better-auth wants singular table names). */
21
13
  storageFactoryOptions?: StorageFactoryOptions;
22
- /**
23
- * What to call this storage when a query is reported.
24
- *
25
- * A process may open several (`sources:`), and a module-level sink sees them all — without
26
- * a name, two statements from two databases read as one stream. Defaults to what
27
- * distinguishes them anyway: the engine here, the file path for SQLite.
28
- */
14
+ /** What to call this storage when a query is reported. */
29
15
  name?: string;
30
16
  }
31
17
 
32
- /**
33
- * A `Source` realized by SQL — and the three members that are SQL's, not the routing's.
34
- *
35
- * `dialect`, `db` and `sink` are to a source what `Kysely` is to `SqlStorage.client`: what
36
- * this adapter is MADE OF, reached by narrowing. Nothing that routes entities across
37
- * sources reads them.
38
- */
18
+ /** A `Source` realized by SQL — and the three members that are SQL's, not the routing's. */
39
19
  export interface SqlSource extends Source {
40
20
  dialect: DialectName;
41
21
  storageFactory: ReturnType<typeof createStorageFactory>;
42
22
  /** Runs raw statements — what `autoMigrate` writes through. */
43
23
  sink: SqlSink;
44
24
  /**
45
- * The Kysely instance, for what precedes any entity: `migrate(app, setup)` writes the
46
- * schema through it, and a script may need it before a container exists.
47
- *
48
- * It is not the way to reach data from inside an app — that is the injected `Storage`,
49
- * whose `client` gives the same handle while keeping the scope of its entity.
25
+ * The Kysely instance, for what precedes any entity: `migrate(app, setup)` writes the schema
26
+ * through it, and a script may need it before a container exists.
50
27
  */
51
28
  db: Kysely<any>;
52
- /**
53
- * Run `fn` inside one transaction of this engine, with a storage factory bound to it.
54
- *
55
- * The transaction belongs to the engine, so obtaining one is a gesture on the engine and
56
- * nowhere else. Nothing new is handed back: `Transaction<DB> extends Kysely<DB>` and
57
- * `SqlStorage` takes a `Kysely<any>`, so the SAME storage is rebuilt over the substituted
58
- * connection — which is why a frame needs no support in this package.
59
- */
29
+ /** Run `fn` inside one transaction of this engine, with a storage factory bound to it. */
60
30
  transacted<R>(fn: (storageFactory: ReturnType<typeof createStorageFactory>) => Promise<R>): Promise<R>;
61
31
  }
62
32
 
63
33
  /** The name this shape answered to before it was one realization among several. */
64
34
  export type Setup = SqlSource;
65
35
 
66
- /**
67
- * The migration of what lives in ONE sql source, carrying its own dialect.
68
- *
69
- * It was the router's gesture, which had to know how SQL migrates — and did not pass the
70
- * dialect, so every source was migrated as sqlite, the documented Postgres case included.
71
- * A source knows its own engine, so the gesture is its own.
72
- */
36
+ /** The migration of what lives in ONE sql source, carrying its own dialect. */
73
37
  function migrating(db: Kysely<any>, dialect: DialectName, opts: SetupOptions) {
74
38
  return async (view: SourceView): Promise<void> => {
75
39
  await migrate(view as never, db, {
package/src/sqlite.ts CHANGED
@@ -1,12 +1,4 @@
1
- /**
2
- * SQLite on a file — the convention a first run meets, and the only driver this package owns.
3
- *
4
- * It sits behind its own subpath because `better-sqlite3` is a NATIVE module and `node:fs`
5
- * is a builtin: a bundler cannot prune a module that imports them, so re-exporting this
6
- * from the index made the whole adapter unreachable from a runtime that has neither. Same
7
- * cut as `@fougere/transport-http/receive`, and for the same reason — share the projection,
8
- * never the plumbing.
9
- */
1
+ /** SQLite on a file — the convention a first run meets, and the only driver this package owns. */
10
2
  import { mkdirSync } from 'node:fs';
11
3
  import { dirname } from 'node:path';
12
4
  import { Kysely, SqliteDialect } from 'kysely';
@@ -51,14 +43,7 @@ export function setupSqlite(opts: SqliteSetupOptions = {}): SqliteSetup {
51
43
  };
52
44
  }
53
45
 
54
- /**
55
- * `source: 'sql'` — answered HERE and not in the driver-free index, because answering a
56
- * name means building a driver, and this is the one this package owns.
57
- *
58
- * The refusal is `refuseUnresolvable` moved: it lived in `@fougere/defaults`, which happened
59
- * to import the driver, so a second adapter had nowhere to say it exists. A dialect this
60
- * package cannot build from a name is refused here, where the reason is true.
61
- */
46
+ /** `source. */
62
47
  Sources.register('sql', (conf: SourceConfig): Source => {
63
48
  const dialect = conf.dialect as string | undefined;
64
49
  if (dialect !== undefined && dialect !== 'sqlite') {
package/src/step.ts CHANGED
@@ -1,15 +1,4 @@
1
- /**
2
- * The half `delta()` refuses — realised from an intention that was written down.
3
- *
4
- * `diff.ts` states its own guarantee: additive only, because "a rename is not even
5
- * detectable from a diff (it reads as a drop plus an add)". That still holds, and this
6
- * file does not weaken it. What changed is upstream: a frozen step (`fougere freeze`)
7
- * carries a rename because a human declared it at the moment they made it. The intention
8
- * exists now, so it can be realised — and only what the step actually says.
9
- *
10
- * A drop and a rename both touch live data, so this is the only place either can come
11
- * from: never introspection, never a guess.
12
- */
1
+ /** The half `delta()` refuses — realised from an intention that was written down. */
13
2
  import { sql, type Kysely } from 'kysely';
14
3
  import type { Change as ShapeChange, SetDiff } from '@fougere/schema';
15
4
  import { dequal } from 'dequal';
@@ -42,25 +31,11 @@ export interface Plan {
42
31
  export interface PlanOptions {
43
32
  /** Entity key → table name. Same resolver `desiredTables` takes. */
44
33
  tableName?: (name: string) => string;
45
- /**
46
- * What the database actually holds, from `actualState`. Given, a change already
47
- * realised is skipped.
48
- *
49
- * Idempotence by OBSERVATION and not by bookkeeping — the same choice `delta` makes.
50
- * A ledger of applied steps would be a second record of a fact the columns already
51
- * carry, and the two would disagree the day someone renamed a column by hand.
52
- */
34
+ /** What the database actually holds, from `actualState`. */
53
35
  actual?: SchemaState;
54
36
  }
55
37
 
56
- /**
57
- * Collapse a chain of steps into one, following each field through its renames.
58
- *
59
- * A step judges itself on two names — old gone, new here — so an intermediate rename is
60
- * unrecognisable once its target has been renamed again: both names are absent and it is
61
- * proposed forever. Composing the chain first asks the question about the name the field
62
- * ENDS on, which is the only one the tables can answer.
63
- */
38
+ /** Collapse a chain of steps into one, following each field through its renames. */
64
39
  export function collapseChain(steps: readonly SetDiff[]): SetDiff {
65
40
  const entities: SetDiff['entities'] = {};
66
41
  const added: string[] = [];
@@ -70,9 +45,9 @@ export function collapseChain(steps: readonly SetDiff[]): SetDiff {
70
45
  added.push(...step.entitiesAdded);
71
46
  removed.push(...step.entitiesRemoved);
72
47
  for (const [entity, answer] of Object.entries(step.entities)) {
73
- const held = (entities[entity] ??= { changes: [], ambiguous: [] });
74
- held.ambiguous.push(...answer.ambiguous);
75
- for (const change of answer.changes) compose(held.changes, change);
48
+ const entry = (entities[entity] ??= { changes: [], ambiguous: [] });
49
+ entry.ambiguous.push(...answer.ambiguous);
50
+ for (const change of answer.changes) compose(entry.changes, change);
76
51
  }
77
52
  }
78
53
 
@@ -80,9 +55,8 @@ export function collapseChain(steps: readonly SetDiff[]): SetDiff {
80
55
  }
81
56
 
82
57
  /**
83
- * Add one change to what the chain has said so far, rewriting rather than appending when
84
- * it continues a field already moved. A rename back to its origin cancels: the tables
85
- * never held the name in between, so there is nothing for them to do.
58
+ * Add one change to what the chain has said so far, rewriting rather than appending when it
59
+ * continues a field already moved.
86
60
  */
87
61
  function compose(held: ShapeChange[], change: ShapeChange): void {
88
62
  if (change.kind === 'renamed') {
@@ -113,11 +87,7 @@ function compose(held: ShapeChange[], change: ShapeChange): void {
113
87
  held.push({ ...change, field: origin.from });
114
88
  }
115
89
 
116
- /**
117
- * Turn a frozen step into what the tables must do, and what nobody may decide for you.
118
- *
119
- * Pure, like `delta` — the comparison is one thing, running it is another.
120
- */
90
+ /** Turn a frozen step into what the tables must do, and what nobody may decide for you. */
121
91
  export function planStep(step: SetDiff, tables: TableDef[], options: PlanOptions = {}): Plan {
122
92
  const resolve = options.tableName ?? toTableName;
123
93
  const actual = options.actual;
@@ -191,7 +161,7 @@ function realise(
191
161
 
192
162
  case 'reshaped':
193
163
  // A tightened bound is a CHECK, and altering one on a live table is engine-specific
194
- // AND may be refused by rows already stored. The judge still enforces it at the door.
164
+ // AND may be refused by rows already stored. The validator still enforces it at the door.
195
165
  return {
196
166
  entity,
197
167
  field: change.field,
@@ -203,13 +173,7 @@ function realise(
203
173
  }
204
174
  }
205
175
 
206
- /**
207
- * An axis other than shape moved. Only some of it is a fact about the table.
208
- *
209
- * `boundary` never is — it says who may read or write, which no column holds. `lifecycle`
210
- * is one only through the DEFAULT clause. `role` is one three times over, and two of those
211
- * are constraints a live table may already contradict.
212
- */
176
+ /** An axis other than shape moved. */
213
177
  function restated(entity: string, change: Extract<ShapeChange, { kind: 'restated' }>): { change?: StepChange } | Refusal {
214
178
  const refuse = (reason: string): Refusal => ({ entity, field: change.field, reason });
215
179
 
@@ -246,13 +210,7 @@ function literalOf(rules: { create?: unknown } | undefined): unknown {
246
210
 
247
211
  const show = (value: unknown): string => (value === undefined ? 'none' : JSON.stringify(value));
248
212
 
249
- /**
250
- * Has the table already moved? Read off the columns themselves.
251
- *
252
- * A rename whose old name is gone and whose new one is there has happened; a drop whose
253
- * column is absent has happened. Unknown state (no introspection given) answers no, so a
254
- * plan built without it proposes everything the step says.
255
- */
213
+ /** Has the table already moved? Read off the columns themselves. */
256
214
  function done(change: StepChange, columns: Set<string> | undefined): boolean {
257
215
  if (!columns) return false;
258
216
  return change.kind === 'renameColumn'
package/src/table.ts CHANGED
@@ -1,18 +1,8 @@
1
1
  import { Lifecycle, Role } from '@fougere/schema';
2
- /**
3
- * Entity → table description, with no SQL in sight.
4
- *
5
- * This is the neutral middle term: one projection reads the entity's axes and
6
- * produces a `TableDef`; a `Dialect` turns that into SQL. Neither half knows the
7
- * other — the dialect never mentions a field. Adding a dialect touches only the
8
- * second half.
9
- *
10
- * `ColumnDef.stated` is the one member the axes did not produce. It names one engine's
11
- * column type, so dropping it leaves every column describable.
12
- */
13
- import { Anatomy, FieldGroup, Unique, fieldsOf, lowerFirst, schemaOf, type Field, type SchemaView, type SchemaOrCard } from '@fougere/schema';
2
+ /** Entity → table description, with no SQL in sight. */
3
+ import { Shapes, lowerFirst, type Field, type SchemaView } from '@fougere/schema';
14
4
  import { boundsOf, type ShapeBounds } from './check.js';
15
- import type { SqlField } from './fields.js';
5
+ import { sqlEntries, type SqlField } from './fields.js';
16
6
 
17
7
  /** The shape keywords a dialect needs to choose a column type. */
18
8
  export interface ColumnShape {
@@ -62,11 +52,7 @@ export interface TableDef {
62
52
  columns: ColumnDef[];
63
53
  /** PK column names when the key is composite — empty for a simple key. */
64
54
  compositePrimary: string[];
65
- /**
66
- * Column groups unique together, from `entity(fields, { unique: [...] })`.
67
- * A single-field group is left to the column's own `unique` — this is the
68
- * table-level form, for facts no column can hold alone.
69
- */
55
+ /** Column groups unique together, from `entity(fields, { unique: [...] })`. */
70
56
  uniqueGroups: string[][];
71
57
  }
72
58
 
@@ -83,11 +69,7 @@ function isStored(field: Field): boolean {
83
69
  return !Role.of(field).isCollection;
84
70
  }
85
71
 
86
- /**
87
- * The target's primary key column. A live thunk (an in-process entity) answers
88
- * for real; a relation reconstructed from a lone `Card` (without a `Bundle`) has lost it to a
89
- * name stand-in with no `getFields` — the convention there is to assume `id`.
90
- */
72
+ /** The target's primary key column. */
91
73
  function primaryColumnOf(target: Partial<SchemaView>): string {
92
74
  if (typeof target.getFields !== 'function') return 'id';
93
75
  for (const [name, field] of Object.entries(target.getFields())) {
@@ -96,28 +78,11 @@ function primaryColumnOf(target: Partial<SchemaView>): string {
96
78
  return 'id'; // declared no primary() field — defensive, shouldn't happen
97
79
  }
98
80
 
99
- /**
100
- * The FK target for a `ref()` field: the table it points at plus its PK column.
101
- *
102
- * `tableNameOf` is an identity map — built once per app generation pass, see
103
- * {@link toTables} — from a LIVE entity class to the table name already resolved
104
- * for it. Reusing that name (instead of re-deriving one from the class name) is
105
- * what keeps a custom `tableName` resolver honest: `demos/schema-ecommerce`
106
- * names `Category`'s table `"categories"` (an irregular plural its resolver
107
- * special-cases) — re-deriving from `Category.name` through the DEFAULT
108
- * convention would silently produce `"categorys"` instead.
109
- *
110
- * A miss (the target isn't part of this batch — a cross-frond target, or a live
111
- * class the app substituted for one a package hardcoded — e.g.
112
- * `@fougere/auth-better`'s `AuthSession` always points at its own default
113
- * `AuthUser`, never at whatever `opts.user` the app actually registered) falls
114
- * back to deriving the name from the class — correct when that class follows
115
- * the default convention, wrong if the app ALSO overrides `tableName` for it.
116
- */
81
+ /** The FK target for a `ref()` field. */
117
82
  function referenceFor(
118
83
  field: Field,
119
84
  resolve: (name: string) => string,
120
- tableNameOf?: Map<SchemaOrCard, string>,
85
+ tableNameOf?: Map<SchemaView, string>,
121
86
  hosted?: HostedNames,
122
87
  ): ColumnReference | undefined {
123
88
  const relation = Role.of(field).relation;
@@ -156,14 +121,14 @@ function toColumn(
156
121
  fieldName: string,
157
122
  field: Field,
158
123
  resolve: (name: string) => string,
159
- tableNameOf?: Map<SchemaOrCard, string>,
124
+ tableNameOf?: Map<SchemaView, string>,
160
125
  hosted?: HostedNames,
161
126
  stated?: SqlField,
162
127
  ): ColumnDef {
163
128
  // The column type comes from the `shape` axis alone. `anatomy` strips the
164
129
  // nullable union so a nullable integer stays an integer instead of falling
165
130
  // through to text.
166
- const { base, nullable } = Anatomy.of(field.shape);
131
+ const { base, nullable } = Shapes.of(field.shape);
167
132
  const lifecycle = Lifecycle.of(field);
168
133
  const column: ColumnDef = {
169
134
  field: fieldName,
@@ -180,8 +145,7 @@ function toColumn(
180
145
  // redundant constraint on every engine. So would indexing what `unique` constrains.
181
146
  // Only a constraint of ONE becomes a column constraint; a group of several is a table
182
147
  // constraint, emitted once from `uniqueGroups` rather than once per member column.
183
- const soleUnique = FieldGroup.on(field, Unique).some((group) => group.members.length <= 1);
184
- if (soleUnique && !column.primary) column.unique = true;
148
+ if (Role.of(field).isUnique && !column.primary) column.unique = true;
185
149
  if (Role.of(field).isIndexed && !column.primary && !column.unique) column.index = true;
186
150
  const references = referenceFor(field, resolve, tableNameOf, hosted);
187
151
  if (references) column.references = references;
@@ -194,7 +158,7 @@ export interface RelationResolve {
194
158
  /** Same resolver used for every entity's own table (default or a custom `tableName`). */
195
159
  resolve: (name: string) => string;
196
160
  /** Live entity class → its already-resolved table name, reused instead of re-derived. */
197
- tableNameOf?: Map<SchemaOrCard, string>;
161
+ tableNameOf?: Map<SchemaView, string>;
198
162
  /** Which entities this batch holds and which live in another source — decided by NAME. */
199
163
  hosted?: HostedNames;
200
164
  }
@@ -207,46 +171,29 @@ export interface HostedNames {
207
171
  elsewhere: ReadonlySet<string>;
208
172
  }
209
173
 
210
- /**
211
- * Describe one entity as a table — the single reader of the axes.
212
- *
213
- * Takes the entity as a live class or as a card. Read ONCE into `fields`: `fieldsOf`
214
- * reconstructs a descriptor on each call, so re-reading per loop would rebuild the schema
215
- * as many times as this function iterates.
216
- *
217
- * A lone card has no live relation targets, so a `ref()` falls back to the conventions
218
- * `referenceFor`/`primaryColumnOf` already document (name-derived table, `id` as the key).
219
- * Pass a descriptor through `Bundle.toSchemas` first when the FKs matter — it resolves the
220
- * targets, and its output is the live-class case again.
221
- */
222
- export function toTable(tableName: string, entity: SchemaOrCard, relations?: RelationResolve): TableDef {
174
+ /** Describe one entity as a table — the single reader of the axes. */
175
+ export function toTable(tableName: string, schema: SchemaView, relations?: RelationResolve): TableDef {
223
176
  const resolve = relations?.resolve ?? toTableName;
224
- const fields = fieldsOf(entity);
177
+ const fields = schema.getFields();
225
178
  // Read off the entity, since that is where it is declared and addressed by field key.
226
- const stated = schemaOf(entity).getAdapters()?.sql;
179
+ const configuration = schema.getAdapters().sql;
180
+ // Judged HERE and not at `entity()`: this runs at boot, after every import, so the
181
+ // format is always loaded. A validator registered with `schema` would depend on which
182
+ // module was imported first.
183
+ sqlEntries.assert(configuration, `${schema.name}.adapters.sql`);
227
184
  const columns: ColumnDef[] = [];
228
185
  for (const [fieldName, field] of Object.entries(fields)) {
229
186
  if (!isStored(field)) continue;
230
- columns.push(toColumn(fieldName, field, resolve, relations?.tableNameOf, relations?.hosted, stated?.[fieldName]));
187
+ columns.push(toColumn(fieldName, field, resolve, relations?.tableNameOf, relations?.hosted, configuration?.[fieldName]));
231
188
  }
232
189
  const primaries = columns.filter((column) => column.primary).map((column) => column.name);
233
190
  const stored = new Set(columns.map((column) => column.name));
234
- // Read off the fields, not off `getUnique()`: a card has no entity-level declaration to
235
- // offer, and the members carry the same fact either way. One reader for both forms.
236
- //
237
- // Declared in field names, realized in column names — and a group that names a field the
238
- // storage does not keep is not enforceable, so it is dropped here rather than emitted
239
- // against a column that will not exist.
240
- const groups = new Map<string, string[]>();
241
- for (const [fieldName, field] of Object.entries(fields)) {
242
- for (const group of FieldGroup.on(field, Unique)) {
243
- const members = group.resolvedOn(fieldName).members.map(toSnakeCase);
244
- if (members.length > 1 && members.every((column) => stored.has(column))) {
245
- groups.set(members.join(' '), members);
246
- }
247
- }
248
- }
249
- const uniqueGroups = [...groups.values()];
191
+ // Declared in field names, realized in column names — and a group naming a field the
192
+ // storage does not keep is not enforceable, so it is dropped rather than emitted against
193
+ // a column that will not exist.
194
+ const uniqueGroups = (schema.getUnique() ?? [])
195
+ .map((group) => group.map(toSnakeCase))
196
+ .filter((members) => members.every((column) => stored.has(column)));
250
197
 
251
198
  return {
252
199
  name: tableName,
@@ -257,12 +204,8 @@ export function toTable(tableName: string, entity: SchemaOrCard, relations?: Rel
257
204
  }
258
205
 
259
206
  /**
260
- * Is this column part of a key? MySQL and SQL Server refuse an unbounded text
261
- * column in a primary key or an index, so the dialect needs to know.
262
- *
263
- * `unique` and `index` count, and until they could be declared nothing did but the
264
- * primary key — the comment above already said "or an index" while the code answered
265
- * for the key alone, because no vocabulary word produced one to answer for.
207
+ * Is this column part of a key? MySQL and SQL Server refuse an unbounded text column in a primary
208
+ * key or an index, so the dialect needs to know.
266
209
  */
267
210
  export function isKeyed(table: TableDef, column: ColumnDef): boolean {
268
211
  return column.primary
@@ -280,8 +223,8 @@ export function toTableName(name: string): string {
280
223
 
281
224
  export interface EntityEntry {
282
225
  name: string;
283
- /** A live class in-process, a card from a frond whose class never crossed. */
284
- entityClass: SchemaOrCard;
226
+ /** A live class in-process, or one rebuilt from the card of a frond that never crossed. */
227
+ entityClass: SchemaView;
285
228
  }
286
229
 
287
230
  export interface FrondLike {
@@ -292,43 +235,25 @@ export interface FrondLike {
292
235
  export interface AppLike {
293
236
  fronds: FrondLike[];
294
237
  /** Auth runtime entities are migrated alongside scanned fronds when present. */
295
- auth?: { entities: Record<string, SchemaOrCard> };
296
- /**
297
- * Entities this app hosts in ANOTHER source — named so a miss can be read.
298
- *
299
- * Without it every miss looked alike, so a `ref()` fell back to a derived table name
300
- * and the constraint was emitted against a table that might not exist. Absent means
301
- * one source, where a miss can only be a mistake.
302
- */
238
+ auth?: { entities: Record<string, SchemaView> };
239
+ /** Entities this app hosts in ANOTHER source — named so a miss can be read. */
303
240
  elsewhere?: string[];
304
241
  }
305
242
 
306
- /**
307
- * A schema says whether it holds rows; this adapter decides what to emit for it.
308
- *
309
- * A root always did. A derivation describes ANOTHER's rows — `Post.pick('id','title')` is
310
- * a shape, and one dropped under `entities/` used to get a table of its own: measured on a
311
- * real app, where a projection of an archived entity created a duplicate in the OTHER
312
- * database. `.anchor()` is what says otherwise, and it is the entity's own word, never a
313
- * key of the app's config: a frond stays mountable without its host knowing.
314
- */
243
+ /** A schema says whether it holds rows; this adapter decides what to emit for it. */
315
244
  function verdictOn(entry: EntityEntry): 'table' | 'answer' {
316
- const schema = schemaOf(entry.entityClass);
245
+ const { entityClass } = entry;
317
246
 
318
- return !schema.derivation || schema.anchored ? 'table' : 'answer';
247
+ return !entityClass.derivation || entityClass.anchored ? 'table' : 'answer';
319
248
  }
320
249
 
321
- /**
322
- * Every entity this app hosts, once each. The same class reaches here under one
323
- * registration name from two lists — a frond's `entities/` and the auth runtime's map —
324
- * and two entries for one name are two CREATE TABLE for one table.
325
- */
250
+ /** Every entity this app hosts, once each. */
326
251
  function collectEntities(app: AppLike): EntityEntry[] {
327
252
  const entries: EntityEntry[] = [];
328
- const held = new Set<string>();
253
+ const seen = new Set<string>();
329
254
  const hold = (entry: EntityEntry) => {
330
- if (held.has(lowerFirst(entry.name))) return;
331
- held.add(lowerFirst(entry.name));
255
+ if (seen.has(lowerFirst(entry.name))) return;
256
+ seen.add(lowerFirst(entry.name));
332
257
  entries.push(entry);
333
258
  };
334
259
 
@@ -345,14 +270,12 @@ function collectEntities(app: AppLike): EntityEntry[] {
345
270
  }
346
271
 
347
272
  /**
348
- * Every entity an app hosts, as FK-aware tables — the shared middle step behind
349
- * `generateSQL` (a from-scratch create pass) and `desiredTables` (the diff's
350
- * target state). Builds the identity map once (see `referenceFor`'s doc) so a
351
- * `ref()` target reuses the SAME resolved name as the entity's own table.
273
+ * Every entity an app hosts, as FK-aware tables — the shared middle step behind `generateSQL` (a
274
+ * from-scratch create pass) and `desiredTables` (the diff's target state).
352
275
  */
353
276
  export function toTables(app: AppLike, resolve: (name: string) => string): TableDef[] {
354
277
  const entries = collectEntities(app);
355
- const tableNameOf = new Map<SchemaOrCard, string>(entries.map((entry) => [entry.entityClass, resolve(entry.name)]));
278
+ const tableNameOf = new Map<SchemaView, string>(entries.map((entry) => [entry.entityClass, resolve(entry.name)]));
356
279
  const hosted = app.elsewhere
357
280
  ? { here: new Set(entries.map((entry) => lowerFirst(entry.name))), elsewhere: new Set(app.elsewhere.map(lowerFirst)) }
358
281
  : undefined;
@@ -360,69 +283,3 @@ export function toTables(app: AppLike, resolve: (name: string) => string): Table
360
283
  }
361
284
 
362
285
  // ─── Ordering — a referenced table before its referrer ─────────────────────
363
-
364
- export interface FkEdge {
365
- table: TableDef;
366
- column: ColumnDef;
367
- }
368
-
369
- export interface TableOrder {
370
- /** Tables in dependency order — a `ref()` target always precedes its referrer. */
371
- ordered: TableDef[];
372
- /** FK columns whose target could not be ordered first — a cycle. Constrain after creation. */
373
- deferred: FkEdge[];
374
- }
375
-
376
- /**
377
- * Order a table set so a `ref()`'s target always exists before the table that
378
- * points at it — required by every engine except SQLite, which resolves FK
379
- * targets lazily and accepts any order (and has no `ALTER TABLE ADD CONSTRAINT`
380
- * to close a cycle with — a caller on that dialect skips this function entirely).
381
- *
382
- * A cycle (`Post → Author → Post`, legal in the model — role.ts's relation
383
- * thunk exists precisely so two entities can reference each other) has no such
384
- * order: the loop is broken by deferring ONE of its edges per remaining cycle —
385
- * that FK is added after every table exists, instead of inline. A self-reference
386
- * (`parentId: ref(() => Category)`) is not a cycle here: a table may always
387
- * reference its own not-yet-populated rows inline, standard support across
388
- * every engine — so it's excluded from the dependency graph entirely.
389
- */
390
- export function orderTables(tables: TableDef[]): TableOrder {
391
- const byName = new Map(tables.map((table) => [table.name, table]));
392
- const needs = new Map(
393
- tables.map((table) => [
394
- table.name,
395
- new Set(
396
- table.columns
397
- .filter((c) => c.references && c.references.table !== table.name && byName.has(c.references.table))
398
- .map((c) => c.references!.table),
399
- ),
400
- ]),
401
- );
402
-
403
- const ordered: TableDef[] = [];
404
- const deferred: FkEdge[] = [];
405
- const done = new Set<string>();
406
-
407
- while (needs.size > 0) {
408
- const ready = [...needs.keys()].find((name) => [...needs.get(name)!].every((dep) => done.has(dep)));
409
- if (ready) {
410
- ordered.push(byName.get(ready)!);
411
- done.add(ready);
412
- needs.delete(ready);
413
- continue;
414
- }
415
- // Every table left waits on another table left — a cycle. Break it by
416
- // deferring one edge: every column of the first remaining table that points
417
- // at its first unmet dependency.
418
- const [name, deps] = [...needs.entries()][0];
419
- const dep = [...deps].find((d) => !done.has(d))!;
420
- const table = byName.get(name)!;
421
- for (const column of table.columns) {
422
- if (column.references?.table === dep) deferred.push({ table, column });
423
- }
424
- deps.delete(dep);
425
- }
426
-
427
- return { ordered, deferred };
428
- }