@sedrino/db-schema 0.1.1 → 0.1.2

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/docs/cli.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  The package ships a Bun-first CLI named `sedrino-db`.
4
4
 
5
+ The published CLI expects `bun` to be installed because the generated executable uses a Bun shebang.
6
+
5
7
  ## Default project layout
6
8
 
7
9
  ```text
@@ -14,6 +16,40 @@ db/
14
16
 
15
17
  ## Commands
16
18
 
19
+ ### Create
20
+
21
+ ```bash
22
+ sedrino-db migrate create create-account --dir db
23
+ ```
24
+
25
+ This:
26
+
27
+ - creates `db/migrations` if it does not exist yet
28
+ - assigns the next migration id using the local date plus a zero-padded sequence
29
+ - writes a new TypeScript migration scaffold
30
+
31
+ Example output file:
32
+
33
+ ```text
34
+ db/migrations/2026-04-08-001-create-account.ts
35
+ ```
36
+
37
+ Generated scaffold:
38
+
39
+ ```ts
40
+ import { createMigration } from "@sedrino/db-schema";
41
+
42
+ export default createMigration(
43
+ {
44
+ id: "2026-04-08-001-create-account",
45
+ name: "Create account",
46
+ },
47
+ (m) => {
48
+ // TODO: define migration operations.
49
+ },
50
+ );
51
+ ```
52
+
17
53
  ### Plan
18
54
 
19
55
  ```bash
@@ -29,6 +65,15 @@ This:
29
65
 
30
66
  Add `--sql` to print the SQLite statements per migration.
31
67
 
68
+ You can override the generated artifact paths explicitly:
69
+
70
+ ```bash
71
+ sedrino-db migrate plan \
72
+ --dir db \
73
+ --snapshot db/schema/custom.snapshot.json \
74
+ --drizzle-out db/schema/custom.generated.ts
75
+ ```
76
+
32
77
  ### Apply
33
78
 
34
79
  ```bash
@@ -40,6 +85,7 @@ This:
40
85
  - reads local migrations
41
86
  - compares them with applied migrations recorded in the database
42
87
  - applies pending migrations
88
+ - refuses to run migrations whose plan still contains safety warnings
43
89
  - updates the stored schema metadata tables
44
90
  - writes the local snapshot and generated Drizzle file
45
91
 
@@ -53,6 +99,47 @@ URL resolution order:
53
99
  - `--url`
54
100
  - `LIBSQL_URL`
55
101
 
102
+ Example with environment variables:
103
+
104
+ ```bash
105
+ export LIBSQL_URL=libsql://my-db.turso.io
106
+ export LIBSQL_AUTH_TOKEN=secret
107
+
108
+ sedrino-db migrate apply --dir db
109
+ ```
110
+
111
+ ### Validate
112
+
113
+ ```bash
114
+ sedrino-db migrate validate --dir db
115
+ ```
116
+
117
+ This:
118
+
119
+ - materializes the schema from local migrations
120
+ - reports planner warnings that would block apply
121
+ - checks whether `schema.snapshot.json` is up to date
122
+ - checks whether `schema.generated.ts` is up to date
123
+
124
+ The command exits non-zero if warnings are present or generated artifacts are stale.
125
+
126
+ ### Status
127
+
128
+ ```bash
129
+ sedrino-db migrate status --dir db --url libsql://...
130
+ ```
131
+
132
+ This:
133
+
134
+ - reports the local migration count and local schema hash
135
+ - optionally inspects the connected database
136
+ - reports applied, pending, and unexpected database migrations
137
+ - reports schema drift based on the stored schema hash
138
+
139
+ The command exits non-zero if drift is detected or the database contains migrations that are not present locally.
140
+
141
+ If `--url` or `LIBSQL_URL` is omitted, the command still prints local migration status and local schema hash.
142
+
56
143
  ### Print schema
57
144
 
58
145
  ```bash
@@ -68,3 +155,9 @@ sedrino-db schema drizzle --dir db --out db/schema/schema.generated.ts
68
155
  ```
69
156
 
70
157
  Prints generated Drizzle source to stdout unless `--out` is provided.
158
+
159
+ Stdout example:
160
+
161
+ ```bash
162
+ sedrino-db schema drizzle --dir db
163
+ ```
@@ -0,0 +1,165 @@
1
+ # SQL Expressions And Transforms
2
+
3
+ The package exposes two layers for rebuild-time data movement:
4
+
5
+ - low-level SQL expression helpers
6
+ - higher-level migration intent transforms
7
+
8
+ ## Low-level expression helpers
9
+
10
+ Use these when you want full control over the SQL fragment used in `backfill(...)` or `using(...)`.
11
+
12
+ Exports:
13
+
14
+ - `sqlExpression(...)`
15
+ - `raw(...)`
16
+ - `column(...)`
17
+ - `literal(...)`
18
+ - `lower(...)`
19
+ - `trim(...)`
20
+ - `replace(...)`
21
+ - `cast(...)`
22
+ - `unixepoch(...)`
23
+ - `date(...)`
24
+ - `multiply(...)`
25
+ - `coalesce(...)`
26
+ - `concat(...)`
27
+ - `sqlExpr.*`
28
+
29
+ Example:
30
+
31
+ ```ts
32
+ import { sqlExpr } from "@sedrino/db-schema";
33
+
34
+ const expression = sqlExpr.multiply(
35
+ sqlExpr.cast(sqlExpr.unixepoch(sqlExpr.column("createdAt")), "INTEGER"),
36
+ 1000,
37
+ );
38
+ ```
39
+
40
+ This produces:
41
+
42
+ ```sql
43
+ CAST(unixepoch("created_at") AS INTEGER) * 1000
44
+ ```
45
+
46
+ ## Raw migration examples
47
+
48
+ `backfill(...)` and `using(...)` accept either strings or expression objects:
49
+
50
+ ```ts
51
+ import { createMigration, sqlExpr } from "@sedrino/db-schema";
52
+
53
+ export default createMigration(
54
+ {
55
+ id: "2026-04-08-001",
56
+ name: "Backfill slug and convert createdAt",
57
+ },
58
+ (m) => {
59
+ m.alterTable("account", (t) => {
60
+ t.string("slug")
61
+ .required()
62
+ .backfill(sqlExpr.lower(sqlExpr.replace(sqlExpr.trim(sqlExpr.column("name")), " ", "-")));
63
+
64
+ t.alterField("createdAt", (f) => {
65
+ f.temporalInstant().using(
66
+ sqlExpr.multiply(
67
+ sqlExpr.cast(sqlExpr.unixepoch(sqlExpr.column("createdAt")), "INTEGER"),
68
+ 1000,
69
+ ),
70
+ );
71
+ });
72
+ });
73
+ },
74
+ );
75
+ ```
76
+
77
+ ## Higher-level transforms
78
+
79
+ Use `transforms.*` when you want the migration to read as intent instead of hand-built SQL.
80
+
81
+ Exports:
82
+
83
+ - `copy(...)`
84
+ - `lowercase(...)`
85
+ - `trimmed(...)`
86
+ - `slugFrom(...)`
87
+ - `concatFields(...)`
88
+ - `coalesceFields(...)`
89
+ - `epochMsFromIsoString(...)`
90
+ - `plainDateFromIsoString(...)`
91
+ - `integerFromText(...)`
92
+ - `realFromText(...)`
93
+ - `transforms`
94
+
95
+ Example:
96
+
97
+ ```ts
98
+ import { createMigration, transforms } from "@sedrino/db-schema";
99
+
100
+ export default createMigration(
101
+ {
102
+ id: "2026-04-08-001",
103
+ name: "Normalize account fields",
104
+ },
105
+ (m) => {
106
+ m.alterTable("account", (t) => {
107
+ t.string("slug")
108
+ .required()
109
+ .backfill(transforms.slugFrom("name"));
110
+
111
+ t.string("searchName")
112
+ .required()
113
+ .backfill(transforms.lowercase("displayName"));
114
+
115
+ t.alterField("createdAt", (f) => {
116
+ f.temporalInstant().using(transforms.epochMsFromIsoString("createdAt"));
117
+ });
118
+ });
119
+ },
120
+ );
121
+ ```
122
+
123
+ ## Common patterns
124
+
125
+ ### Copy a field directly
126
+
127
+ ```ts
128
+ transforms.copy("accountId");
129
+ ```
130
+
131
+ ### Join fields together
132
+
133
+ ```ts
134
+ transforms.concatFields(["firstName", "lastName"], { separator: " " });
135
+ ```
136
+
137
+ ### Use the first populated field
138
+
139
+ ```ts
140
+ transforms.coalesceFields(["nickname", "name"], "Unknown");
141
+ ```
142
+
143
+ ### Parse numbers from legacy text columns
144
+
145
+ ```ts
146
+ transforms.integerFromText("priority");
147
+ transforms.realFromText("amount");
148
+ ```
149
+
150
+ ### Convert ISO date/time strings
151
+
152
+ ```ts
153
+ transforms.epochMsFromIsoString("createdAt");
154
+ transforms.plainDateFromIsoString("birthday");
155
+ ```
156
+
157
+ ## When to use which layer
158
+
159
+ Use `transforms.*` first when a helper already matches the migration intent.
160
+
161
+ Drop down to `sqlExpr.*` or raw strings when:
162
+
163
+ - you need a custom SQL function
164
+ - you need a composition the built-in transforms do not cover yet
165
+ - you are prototyping a new reusable transform before promoting it into `transforms.*`
package/docs/index.md CHANGED
@@ -6,6 +6,9 @@ Reference index for the migration-first schema planning package.
6
6
 
7
7
  | Topic | File |
8
8
  | --- | --- |
9
- | Schema document model | [`schema-document.md`](./schema-document.md) |
10
- | Migration DSL and planning flow | [`migrations.md`](./migrations.md) |
9
+ | Schema document model and validation helpers | [`schema-document.md`](./schema-document.md) |
10
+ | Migration DSL and authoring helpers | [`migrations.md`](./migrations.md) |
11
+ | Planning, apply, and project APIs | [`planning-and-apply.md`](./planning-and-apply.md) |
12
+ | SQL expression and transform helpers | [`expressions-and-transforms.md`](./expressions-and-transforms.md) |
13
+ | Drizzle relations generation | [`relations.md`](./relations.md) |
11
14
  | CLI commands | [`cli.md`](./cli.md) |
@@ -36,9 +36,64 @@ export default createMigration(
36
36
  );
37
37
  ```
38
38
 
39
+ ## Core authoring surface
40
+
41
+ The migration builder supports:
42
+
43
+ - `createTable(...)`
44
+ - `createJunctionTable(...)`
45
+ - `dropTable(...)`
46
+ - `renameTable(...)`
47
+ - `alterTable(...)`
48
+
49
+ Inside `createTable(...)`, the table builder supports:
50
+
51
+ - field builders:
52
+ - `id(...)`
53
+ - `string(...)`
54
+ - `text(...)`
55
+ - `boolean(...)`
56
+ - `integer(...)`
57
+ - `number(...)`
58
+ - `enum(...)`
59
+ - `json(...)`
60
+ - `temporalInstant(...)`
61
+ - `temporalPlainDate(...)`
62
+ - `reference(...)`
63
+ - `belongsTo(...)`
64
+ - table metadata:
65
+ - `description(...)`
66
+ - `index(...)`
67
+ - `unique(...)`
68
+
69
+ Inside `alterTable(...)`, the alter builder supports:
70
+
71
+ - add field:
72
+ - `string(...)`
73
+ - `text(...)`
74
+ - `boolean(...)`
75
+ - `integer(...)`
76
+ - `number(...)`
77
+ - `enum(...)`
78
+ - `json(...)`
79
+ - `temporalInstant(...)`
80
+ - `temporalPlainDate(...)`
81
+ - `reference(...)`
82
+ - `belongsTo(...)`
83
+ - change structure:
84
+ - `dropField(...)`
85
+ - `renameField(...)`
86
+ - `alterField(...)`
87
+ - `addIndex(...)`
88
+ - `dropIndex(...)`
89
+ - `addUnique(...)`
90
+ - `dropUnique(...)`
91
+
39
92
  ## Planning flow
40
93
 
41
94
  ```ts
95
+ import { planMigration } from "@sedrino/db-schema";
96
+
42
97
  const plan = planMigration({
43
98
  currentSchema,
44
99
  migration,
@@ -49,8 +104,14 @@ The result includes:
49
104
 
50
105
  - ordered semantic operations
51
106
  - next schema snapshot JSON
52
- - SQLite migration statements where v1 can emit them safely
53
- - warnings for operations that still need rebuild-aware emitters
107
+ - SQLite migration statements, including rebuild-based statements for supported shape changes
108
+ - warnings for operations that still require explicit data transforms or multi-step backfills
109
+
110
+ The `nextSchema` output is then what you pass to:
111
+
112
+ - `compileSchemaToDrizzle(...)`
113
+ - `compileSchemaToDrizzleRelations(...)`
114
+ - `compileSchemaToSqlite(...)`
54
115
 
55
116
  For project-style usage, the CLI wraps this flow:
56
117
 
@@ -58,8 +119,127 @@ For project-style usage, the CLI wraps this flow:
58
119
  sedrino-db migrate plan --dir db
59
120
  ```
60
121
 
122
+ ## Field alterations
123
+
124
+ The v1 migration DSL now supports conservative field alteration through `alterField(...)`.
125
+
126
+ ```ts
127
+ m.alterTable("account", (t) => {
128
+ t.alterField("nickname", (f) => {
129
+ f.required().default("unknown").description("Display nickname");
130
+ });
131
+ });
132
+ ```
133
+
134
+ Supported `alterField(...)` changes are materialized into the schema snapshot and emitted as a SQLite table rebuild.
135
+
136
+ For rebuilds that need help moving data, the preferred API is higher-level transform helpers:
137
+
138
+ ```ts
139
+ import { transforms } from "@sedrino/db-schema";
140
+
141
+ m.alterTable("account", (t) => {
142
+ t.string("slug")
143
+ .required()
144
+ .backfill(transforms.slugFrom("name"));
145
+
146
+ t.alterField("createdAt", (f) => {
147
+ f.temporalInstant().using(transforms.epochMsFromIsoString("createdAt"));
148
+ });
149
+ });
150
+ ```
151
+
152
+ The lower-level `backfillSql(...)` and `usingSql(...)` methods are still available when a migration needs a custom expression.
153
+
154
+ Built-in helpers currently cover:
155
+
156
+ - `transforms.copy("fieldName")`
157
+ - `transforms.lowercase("fieldName")`
158
+ - `transforms.trimmed("fieldName")`
159
+ - `transforms.slugFrom("fieldName")`
160
+ - `transforms.concatFields(["firstName", "lastName"], { separator: " " })`
161
+ - `transforms.coalesceFields(["nickname", "name"], "Unknown")`
162
+ - `transforms.epochMsFromIsoString("createdAt")`
163
+ - `transforms.plainDateFromIsoString("birthday")`
164
+ - `transforms.integerFromText("priority")`
165
+ - `transforms.realFromText("amount")`
166
+
167
+ The planner intentionally warns and blocks apply for cases that still do not have an explicit safe path, for example:
168
+
169
+ - adding a required field without a default
170
+ - changing the underlying storage strategy without `using(...)`
171
+
172
+ ## Table operations example
173
+
174
+ ```ts
175
+ import { createMigration } from "@sedrino/db-schema";
176
+
177
+ export default createMigration(
178
+ {
179
+ id: "2026-04-08-002-reshape-crm",
180
+ name: "Reshape CRM tables",
181
+ },
182
+ (m) => {
183
+ m.renameTable("account", "organization");
184
+
185
+ m.alterTable("contact", (t) => {
186
+ t.renameField("fullName", "displayName");
187
+ t.dropField("legacyNotes");
188
+ t.string("slug").required().backfill("lower(replace(trim(\"display_name\"), ' ', '-'))");
189
+ t.addIndex(["slug"], { name: "contact_slug_idx" });
190
+ t.addUnique(["organizationId", "email"], {
191
+ name: "contact_org_email_unique",
192
+ });
193
+ });
194
+ },
195
+ );
196
+ ```
197
+
198
+ ## Relationship helpers
199
+
200
+ The DSL now includes higher-level helpers for common relational patterns.
201
+
202
+ ### `belongsTo(...)`
203
+
204
+ Use `belongsTo(...)` when a table owns a foreign key to another table:
205
+
206
+ ```ts
207
+ m.createTable("contact", (t) => {
208
+ t.id("contactId", { prefix: "ct" });
209
+ t.belongsTo("account", {
210
+ required: true,
211
+ onDelete: "cascade",
212
+ });
213
+ });
214
+ ```
215
+
216
+ By default this:
217
+
218
+ - creates a field like `accountId`
219
+ - references `account.accountId`
220
+ - can mark the field required or unique
221
+ - adds a single-column index unless the relation is unique
222
+
223
+ ### `createJunctionTable(...)`
224
+
225
+ Use `createJunctionTable(...)` for many-to-many structures:
226
+
227
+ ```ts
228
+ m.createJunctionTable("userGroupMembership", {
229
+ left: { table: "user" },
230
+ right: { table: "group" },
231
+ });
232
+ ```
233
+
234
+ By default this:
235
+
236
+ - creates required foreign keys like `userId` and `groupId`
237
+ - adds indexes on both foreign key fields
238
+ - adds a composite unique constraint across the pair
239
+ - enables inferred Drizzle `through(...)` many-to-many relations
240
+
61
241
  ## Current v1 limits
62
242
 
63
- - no rebuild-aware SQL emission for destructive field changes
64
243
  - no composite primary keys
65
244
  - SQLite-only
245
+ - destructive/shape-changing migrations are expressed as rebuilds rather than native `ALTER` operations
@@ -0,0 +1,200 @@
1
+ # Planning And Apply
2
+
3
+ This guide covers the runtime-facing library APIs beyond the migration DSL itself.
4
+
5
+ ## Plan a single migration
6
+
7
+ Use `planMigration(...)` when you already have a current schema and want:
8
+
9
+ - the semantic operation list
10
+ - the next schema snapshot
11
+ - emitted SQLite statements
12
+ - planner warnings
13
+
14
+ ```ts
15
+ import { createMigration, planMigration } from "@sedrino/db-schema";
16
+
17
+ const migration = createMigration(
18
+ {
19
+ id: "2026-04-08-001-add-slug",
20
+ name: "Add account slug",
21
+ },
22
+ (m) => {
23
+ m.alterTable("account", (t) => {
24
+ t.string("slug").required().backfill(`lower(replace(trim("name"), ' ', '-'))`);
25
+ });
26
+ },
27
+ );
28
+
29
+ const plan = planMigration({
30
+ currentSchema,
31
+ migration,
32
+ });
33
+ ```
34
+
35
+ ## Materialize a whole migration chain
36
+
37
+ Use `materializeSchema(...)` to replay every migration and get the current schema plus every intermediate plan:
38
+
39
+ ```ts
40
+ import { materializeSchema } from "@sedrino/db-schema";
41
+
42
+ const { schema, plans } = materializeSchema({
43
+ migrations,
44
+ });
45
+ ```
46
+
47
+ ## Apply semantic operations directly
48
+
49
+ If you already have a list of `MigrationOperation`s, use `applyOperationsToSchema(...)`:
50
+
51
+ ```ts
52
+ import { applyOperationsToSchema } from "@sedrino/db-schema";
53
+
54
+ const nextSchema = applyOperationsToSchema(currentSchema, operations);
55
+ ```
56
+
57
+ ## Emit SQLite directly
58
+
59
+ The package can emit both full-schema DDL and incremental migration SQL:
60
+
61
+ ```ts
62
+ import {
63
+ compileSchemaToSqlite,
64
+ createMigration,
65
+ materializeSchema,
66
+ renderSqliteMigration,
67
+ } from "@sedrino/db-schema";
68
+
69
+ const { schema } = materializeSchema({ migrations });
70
+ const ddl = compileSchemaToSqlite(schema);
71
+
72
+ const migration = createMigration(
73
+ {
74
+ id: "2026-04-08-002",
75
+ name: "Add slug",
76
+ },
77
+ (m) => {
78
+ m.alterTable("account", (t) => {
79
+ t.string("slug");
80
+ });
81
+ },
82
+ );
83
+
84
+ const sql = renderSqliteMigration(migration.buildOperations(), {
85
+ currentSchema: schema,
86
+ });
87
+ ```
88
+
89
+ ## Emit Drizzle source
90
+
91
+ Use `compileSchemaToDrizzle(...)` for full table + relation source, or `compileSchemaToDrizzleRelations(...)` for relations only:
92
+
93
+ ```ts
94
+ import {
95
+ compileSchemaToDrizzle,
96
+ compileSchemaToDrizzleRelations,
97
+ } from "@sedrino/db-schema";
98
+
99
+ const source = compileSchemaToDrizzle(schema);
100
+ const relationsOnly = compileSchemaToDrizzleRelations(schema);
101
+ ```
102
+
103
+ ## Apply migrations to libSQL or Turso
104
+
105
+ Use `applyMigrations(...)` when you want the library to:
106
+
107
+ - ensure metadata tables exist
108
+ - compare local and database migration history
109
+ - detect drift
110
+ - apply pending migrations
111
+ - persist the updated schema snapshot and schema hash
112
+
113
+ ```ts
114
+ import { applyMigrations, createLibsqlClient } from "@sedrino/db-schema";
115
+
116
+ const client = createLibsqlClient({
117
+ url: "file:./local.db",
118
+ });
119
+
120
+ const result = await applyMigrations({
121
+ client,
122
+ migrations,
123
+ });
124
+ ```
125
+
126
+ You can also pass `connection` instead of a prebuilt client:
127
+
128
+ ```ts
129
+ await applyMigrations({
130
+ connection: {
131
+ url: process.env.LIBSQL_URL!,
132
+ authToken: process.env.LIBSQL_AUTH_TOKEN,
133
+ },
134
+ migrations,
135
+ });
136
+ ```
137
+
138
+ ## Inspect migration status
139
+
140
+ Use `inspectMigrationStatus(...)` for non-mutating status checks:
141
+
142
+ ```ts
143
+ import { inspectMigrationStatus } from "@sedrino/db-schema";
144
+
145
+ const status = await inspectMigrationStatus({
146
+ client,
147
+ migrations,
148
+ });
149
+ ```
150
+
151
+ The result includes:
152
+
153
+ - local migration ids
154
+ - applied migration ids
155
+ - pending migration ids
156
+ - unexpected database migration ids
157
+ - local schema hash
158
+ - database schema hash
159
+ - drift status
160
+
161
+ ## Read metadata tables
162
+
163
+ Lower-level helpers are available if you want the raw metadata state:
164
+
165
+ ```ts
166
+ import { getSchemaState, listAppliedMigrations } from "@sedrino/db-schema";
167
+
168
+ const applied = await listAppliedMigrations(client);
169
+ const state = await getSchemaState(client);
170
+ ```
171
+
172
+ ## Project layout helpers
173
+
174
+ For file-based projects, the package exports helpers around the default `db/` structure:
175
+
176
+ ```ts
177
+ import {
178
+ loadMigrationDefinitionsFromDirectory,
179
+ materializeProjectMigrations,
180
+ resolveDbProjectLayout,
181
+ validateDbProject,
182
+ writeDrizzleSchema,
183
+ writeSchemaSnapshot,
184
+ } from "@sedrino/db-schema";
185
+
186
+ const layout = resolveDbProjectLayout("db");
187
+ const migrations = await loadMigrationDefinitionsFromDirectory(layout.migrationsDir);
188
+ const materialized = await materializeProjectMigrations(layout);
189
+ const validation = await validateDbProject(layout);
190
+
191
+ await writeSchemaSnapshot(materialized.schema, layout.snapshotPath);
192
+ await writeDrizzleSchema(materialized.schema, layout.drizzlePath);
193
+ ```
194
+
195
+ `validateDbProject(...)` is especially useful in CI because it tells you whether:
196
+
197
+ - migrations are internally valid
198
+ - the generated schema snapshot is up to date
199
+ - the generated Drizzle source is up to date
200
+ - planner warnings would block apply