@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.
- package/README.md +152 -0
- 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
|
+
}
|