@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.
@@ -0,0 +1,130 @@
1
+ # Drizzle Relations
2
+
3
+ `@sedrino/db-schema` can emit Drizzle soft relations alongside generated table definitions.
4
+
5
+ ## What is inferred today
6
+
7
+ From a field reference like this:
8
+
9
+ ```ts
10
+ t.reference("accountId", {
11
+ references: {
12
+ table: "account",
13
+ field: "accountId",
14
+ onDelete: "cascade",
15
+ },
16
+ }).required();
17
+ ```
18
+
19
+ the generator emits:
20
+
21
+ - a forward `r.one.account(...)` relation on the source table
22
+ - a reverse relation on the target table
23
+ - `r.many.sourceTable(...)` by default
24
+ - `r.one.sourceTable(...)` when the foreign key field is unique
25
+
26
+ ## One-to-many example
27
+
28
+ ```ts
29
+ import {
30
+ compileSchemaToDrizzle,
31
+ compileSchemaToDrizzleRelations,
32
+ createMigration,
33
+ materializeSchema,
34
+ } from "@sedrino/db-schema";
35
+
36
+ const { schema } = materializeSchema({
37
+ migrations: [
38
+ createMigration(
39
+ {
40
+ id: "2026-04-08-001",
41
+ name: "Create account and contact tables",
42
+ },
43
+ (m) => {
44
+ m.createTable("account", (t) => {
45
+ t.id("accountId", { prefix: "acct" });
46
+ });
47
+
48
+ m.createTable("contact", (t) => {
49
+ t.id("contactId", { prefix: "ct" });
50
+ t.belongsTo("account", { required: true });
51
+ });
52
+ },
53
+ ),
54
+ ],
55
+ });
56
+
57
+ const relationsSource = compileSchemaToDrizzleRelations(schema);
58
+ const fullDrizzleSource = compileSchemaToDrizzle(schema);
59
+ ```
60
+
61
+ ## Duplicate relations
62
+
63
+ If a table has multiple foreign keys to the same target table, the generator emits Drizzle relation aliases automatically.
64
+
65
+ Example:
66
+
67
+ - `post.authorId -> user.userId`
68
+ - `post.reviewerId -> user.userId`
69
+
70
+ becomes:
71
+
72
+ - `post.author`
73
+ - `post.reviewer`
74
+ - `user.authorPosts`
75
+ - `user.reviewerPosts`
76
+
77
+ with matching Drizzle aliases.
78
+
79
+ ## Many-to-many junction tables
80
+
81
+ `createJunctionTable(...)` enables inferred Drizzle `through(...)` relations for simple many-to-many join tables.
82
+
83
+ Example:
84
+
85
+ ```ts
86
+ import {
87
+ compileSchemaToDrizzleRelations,
88
+ createMigration,
89
+ materializeSchema,
90
+ } from "@sedrino/db-schema";
91
+
92
+ const { schema } = materializeSchema({
93
+ migrations: [
94
+ createMigration(
95
+ {
96
+ id: "2026-04-08-001",
97
+ name: "Create users, groups, and memberships",
98
+ },
99
+ (m) => {
100
+ m.createTable("user", (t) => {
101
+ t.id("userId", { prefix: "usr" });
102
+ });
103
+
104
+ m.createTable("group", (t) => {
105
+ t.id("groupId", { prefix: "grp" });
106
+ });
107
+
108
+ m.createJunctionTable("userGroupMembership", {
109
+ left: { table: "user" },
110
+ right: { table: "group" },
111
+ });
112
+ },
113
+ ),
114
+ ],
115
+ });
116
+
117
+ const relationsSource = compileSchemaToDrizzleRelations(schema);
118
+ ```
119
+
120
+ This emits:
121
+
122
+ - direct `r.one.user(...)` / `r.one.group(...)` relations on the junction table
123
+ - a `user.groups` relation using `through(...)`
124
+ - a `group.users` relation using `through(...)`
125
+
126
+ ## Current limits
127
+
128
+ - junction-table inference expects a simple 2-column join table
129
+ - composite primary keys are still not modeled directly in the schema document
130
+ - advanced polymorphic `where` relations are not emitted yet
@@ -18,12 +18,38 @@ type DatabaseSchemaDocument = {
18
18
  };
19
19
  ```
20
20
 
21
+ ## Important helpers
22
+
23
+ The schema module exports a few small helpers around the document:
24
+
25
+ - `createEmptySchema()`
26
+ - `parseSchemaDocument(...)`
27
+ - `validateSchemaDocument(...)`
28
+ - `assertValidSchemaDocument(...)`
29
+ - `schemaHash(...)`
30
+ - `findTable(...)`
31
+ - `findField(...)`
32
+
33
+ The package also exports the underlying runtime schemas:
34
+
35
+ - `schemaDocumentSchema`
36
+ - `tableSpecSchema`
37
+ - `fieldSpecSchema`
38
+ - `logicalTypeSpecSchema`
39
+ - `storageSpecSchema`
40
+ - `defaultSpecSchema`
41
+ - `fieldReferenceSpecSchema`
42
+ - `indexSpecSchema`
43
+ - `uniqueSpecSchema`
44
+ - `foreignKeyActionSchema`
45
+
21
46
  ## Important v1 ideas
22
47
 
23
48
  - The schema document is the materialized current-state snapshot.
24
49
  - Migrations are still the authored change history.
25
50
  - Logical types are higher-level than physical column types.
26
51
  - Storage strategies make the SQLite representation explicit.
52
+ - Foreign-key references can also drive generated Drizzle soft relations.
27
53
 
28
54
  ## Example
29
55
 
@@ -46,3 +72,39 @@ That compiles to:
46
72
  - runtime semantics: `Temporal.Instant`
47
73
  - physical storage: `INTEGER`
48
74
  - Drizzle helper: `temporalInstantEpochMs("created_at")`
75
+
76
+ ## Validation example
77
+
78
+ ```ts
79
+ import {
80
+ assertValidSchemaDocument,
81
+ schemaDocumentSchema,
82
+ schemaHash,
83
+ validateSchemaDocument,
84
+ } from "@sedrino/db-schema";
85
+
86
+ const result = validateSchemaDocument({
87
+ version: 1,
88
+ dialect: "sqlite",
89
+ schemaId: "crm",
90
+ tables: [],
91
+ });
92
+
93
+ if (result.issues.length > 0) {
94
+ throw new Error(`Schema is invalid: ${JSON.stringify(result.issues, null, 2)}`);
95
+ }
96
+
97
+ const schema = assertValidSchemaDocument(result.schema);
98
+ const hash = schemaHash(schema);
99
+ const parsedAgain = schemaDocumentSchema.parse(schema);
100
+ ```
101
+
102
+ ## Lookup example
103
+
104
+ ```ts
105
+ import { createEmptySchema, findField, findTable } from "@sedrino/db-schema";
106
+
107
+ const schema = createEmptySchema("crm");
108
+ const accountTable = findTable(schema, "account");
109
+ const createdAtField = accountTable ? findField(accountTable, "createdAt") : null;
110
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sedrino/db-schema",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -14,7 +14,7 @@
14
14
  "./package.json": "./package.json"
15
15
  },
16
16
  "bin": {
17
- "sedrino-db": "./src/cli.ts"
17
+ "sedrino-db": "./dist/cli.js"
18
18
  },
19
19
  "files": [
20
20
  "dist",
@@ -26,6 +26,7 @@
26
26
  "clean": "rm -rf dist",
27
27
  "build": "bun run clean && tsup",
28
28
  "test": "bun test",
29
+ "test:coverage": "bun test --coverage",
29
30
  "typecheck": "bunx -p @typescript/native-preview tsc --noEmit",
30
31
  "test:all": "bun test && bunx -p @typescript/native-preview tsc --noEmit"
31
32
  },
package/src/apply.ts CHANGED
@@ -21,6 +21,19 @@ export type ApplyMigrationsResult = {
21
21
  currentSchemaHash: string;
22
22
  };
23
23
 
24
+ export type MigrationStatusResult = {
25
+ localMigrationIds: string[];
26
+ appliedMigrationIds: string[];
27
+ pendingMigrationIds: string[];
28
+ unexpectedDatabaseMigrationIds: string[];
29
+ schemaHash: {
30
+ local: string;
31
+ database: string | null;
32
+ driftDetected: boolean;
33
+ };
34
+ metadataTablesPresent: boolean;
35
+ };
36
+
24
37
  export type LibsqlConnectionOptions = {
25
38
  url: string;
26
39
  authToken?: string;
@@ -126,6 +139,44 @@ export async function listAppliedMigrations(client: Client) {
126
139
  }));
127
140
  }
128
141
 
142
+ export async function inspectMigrationStatus(args: {
143
+ client?: Client;
144
+ connection?: LibsqlConnectionOptions;
145
+ migrations: MigrationDefinition[];
146
+ baseSchema?: DatabaseSchemaDocument;
147
+ }) {
148
+ const client = args.client ?? createLibsqlClient(assertConnection(args.connection));
149
+ const metadataTablesPresent = await hasMetadataTables(client);
150
+ const appliedRows = metadataTablesPresent ? await listAppliedMigrations(client) : [];
151
+ const appliedIds = new Set(appliedRows.map((row) => row.migrationId));
152
+ const localMigrationIds = args.migrations.map((migration) => migration.meta.id);
153
+ const pendingMigrationIds = localMigrationIds.filter((id) => !appliedIds.has(id));
154
+ const unexpectedDatabaseMigrationIds = appliedRows
155
+ .map((row) => row.migrationId)
156
+ .filter((id) => !localMigrationIds.includes(id));
157
+ const appliedLocalMigrations = args.migrations.filter((migration) => appliedIds.has(migration.meta.id));
158
+ const expectedCurrent = materializeSchema({
159
+ baseSchema: args.baseSchema,
160
+ migrations: appliedLocalMigrations,
161
+ }).schema;
162
+ const currentState = metadataTablesPresent ? await getSchemaState(client) : null;
163
+ const localSchemaHash = schemaHash(expectedCurrent);
164
+ const databaseSchemaHash = currentState?.schemaHash ?? null;
165
+
166
+ return {
167
+ localMigrationIds,
168
+ appliedMigrationIds: appliedRows.map((row) => row.migrationId),
169
+ pendingMigrationIds,
170
+ unexpectedDatabaseMigrationIds,
171
+ schemaHash: {
172
+ local: localSchemaHash,
173
+ database: databaseSchemaHash,
174
+ driftDetected: databaseSchemaHash !== null && databaseSchemaHash !== localSchemaHash,
175
+ },
176
+ metadataTablesPresent,
177
+ } satisfies MigrationStatusResult;
178
+ }
179
+
129
180
  export async function getSchemaState(client: Client): Promise<SchemaStateRow | null> {
130
181
  const result = await client.execute(
131
182
  `SELECT schema_hash, schema_json
@@ -172,6 +223,22 @@ async function ensureMetadataTables(client: Client) {
172
223
  );
173
224
  }
174
225
 
226
+ async function hasMetadataTables(client: Client) {
227
+ const result = await client.execute({
228
+ sql: `SELECT name FROM sqlite_master
229
+ WHERE type = 'table' AND name IN (?, ?)`,
230
+ args: [MIGRATIONS_TABLE, STATE_TABLE],
231
+ });
232
+
233
+ const names = new Set(
234
+ (result.rows as Array<Record<string, unknown>>)
235
+ .map((row) => getString(row.name))
236
+ .filter((value): value is string => value !== null),
237
+ );
238
+
239
+ return names.has(MIGRATIONS_TABLE) && names.has(STATE_TABLE);
240
+ }
241
+
175
242
  async function executePlan(client: Client, plan: PlannedMigration) {
176
243
  const appliedAt = Date.now();
177
244
  const statements: Array<string | { sql: string; args: SqlValue[] }> = [
package/src/cli.ts CHANGED
@@ -1,12 +1,12 @@
1
- #!/usr/bin/env bun
2
-
3
1
  import path from "node:path";
4
2
  import { env, exit } from "node:process";
5
3
  import { compileSchemaToDrizzle } from "./drizzle";
6
- import { applyMigrations, createLibsqlClient } from "./apply";
4
+ import { applyMigrations, createLibsqlClient, inspectMigrationStatus } from "./apply";
7
5
  import {
6
+ createMigrationScaffold,
8
7
  materializeProjectMigrations,
9
8
  resolveDbProjectLayout,
9
+ validateDbProject,
10
10
  writeDrizzleSchema,
11
11
  writeSchemaSnapshot,
12
12
  } from "./project";
@@ -29,9 +29,18 @@ async function main() {
29
29
  case "migrate plan":
30
30
  await handleMigratePlan(args);
31
31
  return;
32
+ case "migrate create":
33
+ await handleMigrateCreate(args);
34
+ return;
32
35
  case "migrate apply":
33
36
  await handleMigrateApply(args);
34
37
  return;
38
+ case "migrate validate":
39
+ await handleMigrateValidate(args);
40
+ return;
41
+ case "migrate status":
42
+ await handleMigrateStatus(args);
43
+ return;
35
44
  case "schema print":
36
45
  await handleSchemaPrint(args);
37
46
  return;
@@ -46,9 +55,11 @@ async function main() {
46
55
  async function handleMigratePlan(args: ParsedArgs) {
47
56
  const layout = resolveLayoutFromArgs(args);
48
57
  const { schema, plans } = await materializeProjectMigrations(layout);
58
+ const snapshotPath = path.resolve(getStringOption(args, "snapshot") ?? layout.snapshotPath);
59
+ const drizzleOutputPath = path.resolve(getStringOption(args, "drizzle-out") ?? layout.drizzlePath);
49
60
 
50
- await writeSchemaSnapshot(schema, getStringOption(args, "snapshot") ?? layout.snapshotPath);
51
- await writeDrizzleSchema(schema, getStringOption(args, "drizzle-out") ?? layout.drizzlePath);
61
+ await writeSchemaSnapshot(schema, snapshotPath);
62
+ await writeDrizzleSchema(schema, drizzleOutputPath);
52
63
 
53
64
  const warnings = plans.flatMap((plan) =>
54
65
  plan.sql.warnings.map((warning) => `${plan.migrationId}: ${warning}`),
@@ -56,8 +67,8 @@ async function handleMigratePlan(args: ParsedArgs) {
56
67
  const statementCount = plans.reduce((total, plan) => total + plan.sql.statements.length, 0);
57
68
 
58
69
  console.log(`Planned ${plans.length} migration(s)`);
59
- console.log(`Schema snapshot: ${layout.snapshotPath}`);
60
- console.log(`Drizzle output: ${layout.drizzlePath}`);
70
+ console.log(`Schema snapshot: ${snapshotPath}`);
71
+ console.log(`Drizzle output: ${drizzleOutputPath}`);
61
72
  console.log(`SQL statements: ${statementCount}`);
62
73
  console.log(`Schema hash: ${plans.at(-1)?.toSchemaHash ?? "schema_00000000"}`);
63
74
 
@@ -80,6 +91,21 @@ async function handleMigratePlan(args: ParsedArgs) {
80
91
  }
81
92
  }
82
93
 
94
+ async function handleMigrateCreate(args: ParsedArgs) {
95
+ const layout = resolveLayoutFromArgs(args);
96
+ const rawName = args.positionals.slice(2).join(" ").trim();
97
+
98
+ if (!rawName) {
99
+ throw new Error("Missing migration name. Usage: sedrino-db migrate create <name> [--dir db]");
100
+ }
101
+
102
+ const created = await createMigrationScaffold(layout, rawName);
103
+
104
+ console.log(`Created migration: ${created.filePath}`);
105
+ console.log(`Migration id: ${created.migrationId}`);
106
+ console.log(`Migration name: ${created.migrationName}`);
107
+ }
108
+
83
109
  async function handleMigrateApply(args: ParsedArgs) {
84
110
  const layout = resolveLayoutFromArgs(args);
85
111
  const { migrations } = await materializeProjectMigrations(layout);
@@ -117,6 +143,75 @@ async function handleMigrateApply(args: ParsedArgs) {
117
143
  }
118
144
  }
119
145
 
146
+ async function handleMigrateValidate(args: ParsedArgs) {
147
+ const layout = resolveLayoutFromArgs(args);
148
+ const result = await validateDbProject(layout);
149
+
150
+ console.log(`Validated ${result.migrations.length} migration(s)`);
151
+ console.log(`Schema hash: ${result.plans.at(-1)?.toSchemaHash ?? "schema_00000000"}`);
152
+ console.log(`Snapshot up to date: ${result.artifacts.snapshotUpToDate ? "yes" : "no"}`);
153
+ console.log(`Drizzle output up to date: ${result.artifacts.drizzleUpToDate ? "yes" : "no"}`);
154
+
155
+ if (result.warnings.length > 0) {
156
+ console.log("");
157
+ console.log("Warnings:");
158
+ for (const warning of result.warnings) console.log(`- ${warning}`);
159
+ }
160
+
161
+ const hasIssues =
162
+ result.warnings.length > 0 ||
163
+ !result.artifacts.snapshotUpToDate ||
164
+ !result.artifacts.drizzleUpToDate;
165
+
166
+ if (hasIssues) {
167
+ exit(1);
168
+ }
169
+ }
170
+
171
+ async function handleMigrateStatus(args: ParsedArgs) {
172
+ const layout = resolveLayoutFromArgs(args);
173
+ const { migrations, plans } = await materializeProjectMigrations(layout);
174
+ console.log(`Local migrations: ${migrations.length}`);
175
+ console.log(`Local schema hash: ${plans.at(-1)?.toSchemaHash ?? "schema_00000000"}`);
176
+
177
+ const url = getStringOption(args, "url") ?? env.LIBSQL_URL;
178
+ const authToken = getStringOption(args, "auth-token") ?? env.LIBSQL_AUTH_TOKEN;
179
+ if (!url) {
180
+ return;
181
+ }
182
+
183
+ const status = await inspectMigrationStatus({
184
+ client: createLibsqlClient({ url, authToken }),
185
+ migrations,
186
+ baseSchema: undefined,
187
+ });
188
+
189
+ console.log(`Metadata tables present: ${status.metadataTablesPresent ? "yes" : "no"}`);
190
+ console.log(`Applied in database: ${status.appliedMigrationIds.length}`);
191
+ console.log(`Pending locally: ${status.pendingMigrationIds.length}`);
192
+ console.log(`Unexpected in database: ${status.unexpectedDatabaseMigrationIds.length}`);
193
+ console.log(
194
+ `Database schema hash: ${status.schemaHash.database ?? (status.metadataTablesPresent ? "missing" : "none")}`,
195
+ );
196
+ console.log(`Drift detected: ${status.schemaHash.driftDetected ? "yes" : "no"}`);
197
+
198
+ if (status.pendingMigrationIds.length > 0) {
199
+ console.log("");
200
+ console.log("Pending:");
201
+ for (const migrationId of status.pendingMigrationIds) console.log(`- ${migrationId}`);
202
+ }
203
+
204
+ if (status.unexpectedDatabaseMigrationIds.length > 0) {
205
+ console.log("");
206
+ console.log("Unexpected in database:");
207
+ for (const migrationId of status.unexpectedDatabaseMigrationIds) console.log(`- ${migrationId}`);
208
+ }
209
+
210
+ if (status.schemaHash.driftDetected || status.unexpectedDatabaseMigrationIds.length > 0) {
211
+ exit(1);
212
+ }
213
+ }
214
+
120
215
  async function handleSchemaPrint(args: ParsedArgs) {
121
216
  const layout = resolveLayoutFromArgs(args);
122
217
  const { schema } = await materializeProjectMigrations(layout);
@@ -190,8 +285,11 @@ function printHelp() {
190
285
  console.log(`sedrino-db
191
286
 
192
287
  Usage:
288
+ sedrino-db migrate create <name> [--dir db]
193
289
  sedrino-db migrate plan [--dir db] [--sql] [--snapshot path] [--drizzle-out path]
194
290
  sedrino-db migrate apply --url <libsql-url> [--auth-token token] [--dir db]
291
+ sedrino-db migrate validate [--dir db]
292
+ sedrino-db migrate status [--dir db] [--url <libsql-url>] [--auth-token token]
195
293
  sedrino-db schema print [--dir db]
196
294
  sedrino-db schema drizzle [--dir db] [--out path]
197
295