@substrat-run/model-emit 0.0.1

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 (2) hide show
  1. package/README.md +152 -0
  2. package/package.json +33 -0
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # @substrat-run/model-emit
2
+
3
+ Build-time tooling over a Substrat [model](https://substrat.net/concepts/model) — the DDL
4
+ your entities describe, and the reader that holds it honest.
5
+
6
+ ```sh
7
+ pnpm add -D @substrat-run/model-emit
8
+ ```
9
+
10
+ **Full documentation: https://substrat.net/concepts/model**
11
+
12
+ ## Why this exists
13
+
14
+ A vertical declares its entities once:
15
+
16
+ ```ts
17
+ export const entities = defineEntities({
18
+ customer: {
19
+ table: 'acme_customers',
20
+ fields: z.object({ id: z.string(), number: z.string(), name: z.string() }),
21
+ key: ['number'],
22
+ },
23
+ });
24
+ ```
25
+
26
+ …and then writes the same thing again, by hand, as SQL:
27
+
28
+ ```sql
29
+ CREATE TABLE acme_customers (
30
+ id TEXT PRIMARY KEY,
31
+ number TEXT NOT NULL UNIQUE,
32
+ name TEXT NOT NULL
33
+ );
34
+ ```
35
+
36
+ Two descriptions of one schema. Nothing holds them together, so they drift — and the drift
37
+ is invisible until a query returns `undefined` for a column somebody renamed on one side.
38
+
39
+ `emitTables` derives the second from the first.
40
+
41
+ ## Usage
42
+
43
+ ```ts
44
+ import { emitTables } from '@substrat-run/model-emit';
45
+ import { entities } from './spec/model.js';
46
+
47
+ const sql = emitTables(entities);
48
+ ```
49
+
50
+ | declared | emitted |
51
+ |---|---|
52
+ | `id` | `id TEXT PRIMARY KEY NOT NULL` |
53
+ | `z.string()` / `.nullable()` | `TEXT NOT NULL` / `TEXT` |
54
+ | `z.number()` | `INTEGER` |
55
+ | `z.boolean()` | `INTEGER` — SQLite has no boolean |
56
+ | `z.enum(['a','b'])` | `TEXT NOT NULL CHECK (col IN ('a','b'))` |
57
+ | `key: ['number']` | `UNIQUE (number)` |
58
+ | `parents: ['customer']` | `REFERENCES acme_customers(id)` on the matching `customer_id` |
59
+ | `jsonColumn('because…')` | `TEXT` |
60
+
61
+ Pass `{ ifNotExists: true }` to emit `CREATE TABLE IF NOT EXISTS`.
62
+
63
+ ## It is stricter than a hand-written schema, in one way
64
+
65
+ An `id` becomes `TEXT PRIMARY KEY **NOT NULL**`. In SQLite a non-INTEGER primary key does
66
+ *not* imply `NOT NULL`, so `id TEXT PRIMARY KEY` accepts a NULL id:
67
+
68
+ ```
69
+ hand-written id TEXT PRIMARY KEY → ACCEPTED a NULL id
70
+ emitted id TEXT PRIMARY KEY NOT NULL → rejected
71
+ ```
72
+
73
+ Every hand-written `vertical_*` table in the Substrat repo had that hole. The emitter cannot
74
+ produce it.
75
+
76
+ ## It refuses rather than guesses
77
+
78
+ A Zod shape it cannot map to a column throws, naming the field:
79
+
80
+ ```
81
+ emit-sql: cannot map thing.blob (zod kind 'array') to a column —
82
+ map it explicitly, or model the field as one this understands
83
+ ```
84
+
85
+ This is deliberate. A production vertical once shipped 18 events carrying
86
+ `entityId: undefined` because its emitter *defaulted* instead of refusing — applied
87
+ uniformly, silently, eighteen times. For anything reaching a migration, absent has to be
88
+ loud.
89
+
90
+ A column that genuinely holds a document is declared, with a reason:
91
+
92
+ ```ts
93
+ fields: z.object({
94
+ id: z.string(),
95
+ geometry: jsonColumn('a route geometry — modelling its interior says nothing useful'),
96
+ });
97
+ ```
98
+
99
+ `jsonColumn` lives in `@substrat-run/contracts`, because you *write* it in your model. A
100
+ bare `z.unknown()` is still an error — deliberately opaque and not-yet-modelled have to stay
101
+ distinguishable, or the first becomes cover for the second.
102
+
103
+ ## `journalColumns` — the other half
104
+
105
+ ```ts
106
+ import { journalColumns } from '@substrat-run/model-emit';
107
+
108
+ const journal = journalColumns(migrations.map((m) => m.sql).join('\n'));
109
+ journal.get('acme_customers'); // Set { 'id', 'number', 'name' }
110
+ ```
111
+
112
+ Columns per table, replayed from a migration journal: `CREATE TABLE`, `ADD COLUMN`,
113
+ `DROP TABLE`, and `RENAME TO` — append-only journals rebuild a table by creating a `_new`,
114
+ copying, dropping the original and renaming onto its name, and a reader that misses that
115
+ reports the pre-rebuild columns forever.
116
+
117
+ It ships with the emitter because the two are one claim: the emitter says *what the database
118
+ ends up with*, and this is how that gets checked. Until your migrations are derived, use it
119
+ to hold your registry and your journal to each other:
120
+
121
+ ```ts
122
+ it('the registry agrees with the journal', () => {
123
+ const journal = journalColumns(migrations.map((m) => m.sql).join('\n'));
124
+ for (const [name, entity] of Object.entries(entities)) {
125
+ expect(Object.keys(entity.fields.shape).sort())
126
+ .toEqual([...(journal.get(entity.table) ?? [])].sort());
127
+ }
128
+ });
129
+ ```
130
+
131
+ ## It reads the TypeScript, never `model.json`
132
+
133
+ `z.toJSONSchema` keeps the declarative constraints (`.min`, `.regex`, `.enum`, `.nullable`,
134
+ `.default`) and **silently drops the programmatic ones** — `.refine()` and `.brand()` both
135
+ emit as a bare `{"type":"string"}`. An emitter reading the JSON would produce a schema weaker
136
+ than your model declares.
137
+
138
+ `model.json` is for consumers that must not execute your code (a hosted console drawing your
139
+ model) or that want diffability rather than validators (a breaking-change classifier).
140
+
141
+ ## What it is not
142
+
143
+ It emits a **schema, not a migration history**. Version numbers, freezing released entries,
144
+ and expand/contract are a separate problem, and this does not pretend to solve it. Use it for
145
+ a scope that has never run, or to check an existing journal against your registry.
146
+
147
+ ## Licence
148
+
149
+ Apache-2.0, like the rest of the build surface. Substrat's line is whether a package is the
150
+ substrate you *run to serve* (AGPL — kernel, adapters, engines) or something you *build with*
151
+ (Apache — contracts, templates, the CLI). A generator is the second. See
152
+ [LICENSING.md](https://github.com/substrat-run/substrat/blob/main/LICENSING.md).
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@substrat-run/model-emit",
3
+ "version": "0.0.1",
4
+ "description": "Build-time tooling over a Substrat model — DDL emitted from the entity registry, and the journal reader that holds it honest",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/substrat-run/substrat.git",
9
+ "directory": "packages/model-emit"
10
+ },
11
+ "homepage": "https://substrat.net/concepts/model",
12
+ "type": "module",
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "files": ["dist"],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
25
+ "test": "vitest run"
26
+ },
27
+ "dependencies": {
28
+ "@substrat-run/contracts": "workspace:*",
29
+ "zod": "^4.0.0"
30
+ },
31
+ "devDependencies": { "typescript": "^7.0.0", "vitest": "^3.0.0" },
32
+ "publishConfig": { "access": "public" }
33
+ }