@ghom/orm 2.1.0 → 2.1.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.
@@ -17,31 +17,98 @@ export interface TypedMigration<From = {}, To = {}> {
17
17
  */
18
18
  apply: (builder: Knex.AlterTableBuilder) => void;
19
19
  }
20
+ /**
21
+ * Represents a sequence of typed migrations.
22
+ * Used internally by migrate.sequence() to preserve individual migration types.
23
+ *
24
+ * @template Migrations - Tuple of migrations in the sequence
25
+ */
26
+ export interface TypedMigrationSequence<Migrations extends TypedMigration<any, any>[]> {
27
+ /** @internal The individual migrations in the sequence */
28
+ readonly __migrations__: Migrations;
29
+ /** @internal Type marker for columns being removed (computed from sequence) */
30
+ readonly _from: SequenceFromType<Migrations>;
31
+ /** @internal Type marker for columns being added (computed from sequence) */
32
+ readonly _to: SequenceToType<Migrations>;
33
+ /**
34
+ * Apply all migrations in sequence to the table builder.
35
+ */
36
+ apply: (builder: Knex.AlterTableBuilder) => void;
37
+ }
38
+ /**
39
+ * Unwrap a migration array to get the individual migration types.
40
+ * If M is an array, extracts the element type; otherwise returns M as-is.
41
+ */
42
+ type UnwrapMigrationArray<M> = M extends readonly (infer U)[] ? U : M;
43
+ /**
44
+ * Extract "From" keys from a single TypedMigration or TypedMigrationSequence.
45
+ * For sequences, excludes intermediate columns (those that are also in _to).
46
+ */
47
+ type ExtractFromKeysSingle<M> = M extends {
48
+ __migrations__: infer Migrations;
49
+ } ? Migrations extends TypedMigration<any, any>[] ? Exclude<keyof UnionToIntersection<Migrations[number]["_from"]>, keyof UnionToIntersection<Migrations[number]["_to"]>> : never : M extends TypedMigration<infer From, any> ? keyof From : never;
20
50
  /**
21
51
  * Extract all "From" keys from a union of TypedMigration.
22
52
  * These are the columns that will be removed/renamed.
53
+ * Handles both single migrations and arrays of migrations.
54
+ * Uses distributive conditional types to handle unions correctly.
23
55
  */
24
- type ExtractFromKeys<M> = M extends TypedMigration<infer From, any> ? keyof From : never;
56
+ type ExtractFromKeys<M> = M extends any ? UnwrapMigrationArray<M> extends infer U ? U extends any ? ExtractFromKeysSingle<U> : never : never : never;
57
+ /**
58
+ * Extract "To" type from a single TypedMigration or TypedMigrationSequence.
59
+ * For sequences, excludes intermediate columns (those that are also in _from).
60
+ */
61
+ type ExtractToTypesSingle<M> = M extends {
62
+ __migrations__: infer Migrations;
63
+ } ? Migrations extends TypedMigration<any, any>[] ? Omit<UnionToIntersection<Migrations[number]["_to"]>, keyof UnionToIntersection<Migrations[number]["_from"]>> : never : M extends TypedMigration<any, infer To> ? To : never;
25
64
  /**
26
65
  * Extract all "To" types from a union of TypedMigration and intersect them.
27
66
  * These are the columns that will be added.
67
+ * Handles both single migrations and arrays of migrations.
68
+ * Uses distributive conditional types to handle unions correctly.
28
69
  */
29
- type ExtractToTypes<M> = M extends TypedMigration<any, infer To> ? To : never;
70
+ type ExtractToTypes<M> = M extends any ? UnwrapMigrationArray<M> extends infer U ? U extends any ? ExtractToTypesSingle<U> : never : never : never;
30
71
  /**
31
72
  * Convert a union to an intersection.
32
73
  * Used to combine all "To" types from migrations.
33
74
  */
34
75
  type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
76
+ /**
77
+ * Force TypeScript to evaluate a type (expands type aliases).
78
+ */
79
+ type Simplify<T> = {
80
+ [K in keyof T]: T[K];
81
+ } & {};
82
+ /**
83
+ * Combine all "from" types from a tuple of migrations.
84
+ */
85
+ type CombineFromTypes<T extends TypedMigration<any, any>[]> = UnionToIntersection<T[number]["_from"]>;
86
+ /**
87
+ * Combine all "to" types from a tuple of migrations.
88
+ */
89
+ type CombineToTypes<T extends TypedMigration<any, any>[]> = UnionToIntersection<T[number]["_to"]>;
90
+ /**
91
+ * Compute the final "from" type for a sequence of migrations.
92
+ * Excludes columns that are also added (intermediate renames).
93
+ */
94
+ type SequenceFromType<T extends TypedMigration<any, any>[]> = Simplify<Omit<CombineFromTypes<T>, keyof CombineToTypes<T>>>;
95
+ /**
96
+ * Compute the final "to" type for a sequence of migrations.
97
+ * Excludes columns that are also removed (intermediate renames).
98
+ */
99
+ type SequenceToType<T extends TypedMigration<any, any>[]> = Simplify<Omit<CombineToTypes<T>, keyof CombineFromTypes<T>>>;
35
100
  /**
36
101
  * Apply all migrations to compute the final type.
37
102
  * 1. Remove all columns specified in migration "From" types
38
103
  * 2. Add all columns specified in migration "To" types
104
+ * Handles both single migrations and arrays of migrations.
39
105
  */
40
- export type ApplyMigrations<Base, Migrations extends Record<string, TypedMigration<any, any>>> = Migrations[keyof Migrations] extends infer M ? Omit<Base, ExtractFromKeys<M>> & UnionToIntersection<ExtractToTypes<M>> : Base;
106
+ export type ApplyMigrations<Base, Migrations extends Record<string, any>> = Migrations[keyof Migrations] extends infer M ? Omit<Base, ExtractFromKeys<M>> & UnionToIntersection<ExtractToTypes<M>> : Base;
41
107
  /**
42
108
  * Compute the final table type from base columns and migrations.
109
+ * Supports both single migrations and arrays of migrations.
43
110
  */
44
- export type FinalTableType<Columns extends Record<string, ColumnDef<any, any>>, Migrations extends Record<string, TypedMigration<any, any>> = {}> = ApplyMigrations<InferColumns<Columns>, Migrations>;
111
+ export type FinalTableType<Columns extends Record<string, ColumnDef<any, any>>, Migrations extends Record<string, any> = {}> = ApplyMigrations<InferColumns<Columns>, Migrations>;
45
112
  /**
46
113
  * Migration helpers for creating typed migrations.
47
114
  * Each helper returns a TypedMigration with appropriate type transformations.
@@ -167,5 +234,25 @@ export declare const migrate: {
167
234
  * })
168
235
  */
169
236
  raw<From = {}, To = {}>(fn: (builder: Knex.AlterTableBuilder) => void): TypedMigration<From, To>;
237
+ /**
238
+ * Combine multiple migrations into a single migration.
239
+ * All migrations are applied sequentially within the same alter table call.
240
+ * Type information from all migrations is preserved and combined.
241
+ *
242
+ * Intermediate columns (added then removed in the sequence) are excluded from the final type.
243
+ * For example: renameColumn("a", "b") + renameColumn("b", "c") results in only "c" being added.
244
+ *
245
+ * @param migrations - The migrations to combine
246
+ * @returns A typed migration sequence combining all type transformations
247
+ *
248
+ * @example
249
+ * migrate.sequence(
250
+ * migrate.addColumn("phone", col.string()),
251
+ * migrate.addColumn("address", col.string().nullable()),
252
+ * migrate.addIndex(["phone"]),
253
+ * )
254
+ * // Combines: removes nothing, adds { phone: string; address: string | null }
255
+ */
256
+ sequence<T extends TypedMigration<any, any>[]>(...migrations: T): TypedMigrationSequence<T>;
170
257
  };
171
258
  export {};
@@ -195,4 +195,35 @@ export const migrate = {
195
195
  apply: fn,
196
196
  };
197
197
  },
198
+ /**
199
+ * Combine multiple migrations into a single migration.
200
+ * All migrations are applied sequentially within the same alter table call.
201
+ * Type information from all migrations is preserved and combined.
202
+ *
203
+ * Intermediate columns (added then removed in the sequence) are excluded from the final type.
204
+ * For example: renameColumn("a", "b") + renameColumn("b", "c") results in only "c" being added.
205
+ *
206
+ * @param migrations - The migrations to combine
207
+ * @returns A typed migration sequence combining all type transformations
208
+ *
209
+ * @example
210
+ * migrate.sequence(
211
+ * migrate.addColumn("phone", col.string()),
212
+ * migrate.addColumn("address", col.string().nullable()),
213
+ * migrate.addIndex(["phone"]),
214
+ * )
215
+ * // Combines: removes nothing, adds { phone: string; address: string | null }
216
+ */
217
+ sequence(...migrations) {
218
+ return {
219
+ __migrations__: migrations,
220
+ _from: {},
221
+ _to: {},
222
+ apply: (builder) => {
223
+ for (const migration of migrations) {
224
+ migration.apply(builder);
225
+ }
226
+ },
227
+ };
228
+ },
198
229
  };
@@ -1,12 +1,13 @@
1
1
  import { CachedQuery } from "@ghom/query";
2
2
  import type { Knex } from "knex";
3
3
  import { type ColumnDef, type InferColumns } from "./column.js";
4
- import type { FinalTableType, TypedMigration } from "./migration.js";
4
+ import type { FinalTableType, TypedMigration, TypedMigrationSequence } from "./migration.js";
5
5
  import type { ORM } from "./orm.js";
6
6
  /**
7
- * A migration can be either a callback function or a TypedMigration object.
7
+ * A migration value is a TypedMigration or TypedMigrationSequence.
8
+ * Use migrate.sequence() to combine multiple migrations.
8
9
  */
9
- export type MigrationValue = ((builder: Knex.CreateTableBuilder) => void) | TypedMigration<any, any>;
10
+ export type MigrationValue = TypedMigration<any, any> | TypedMigrationSequence<any>;
10
11
  export interface MigrationData {
11
12
  table: string;
12
13
  version: string;
@@ -28,25 +29,32 @@ export interface TableOptions<Columns extends Record<string, ColumnDef<any, any>
28
29
  */
29
30
  caching?: number;
30
31
  /**
31
- * Database migrations to apply.
32
+ * Database migrations to apply using typed migrations.
32
33
  *
33
34
  * Supports three key patterns:
34
35
  * - **Numeric keys** (`"1"`, `"2"`): Sorted numerically
35
36
  * - **Numeric-prefixed keys** (`"001_init"`, `"002_add"`): Sorted by prefix
36
37
  * - **Pure string keys** (`"init"`, `"add"`): Uses insertion order
37
38
  *
38
- * Can use either callback functions or TypedMigration objects.
39
+ * @example
40
+ * // Single migration
41
+ * migrations: {
42
+ * "001_add_email": migrate.addColumn("email", col.string()),
43
+ * }
39
44
  *
40
45
  * @example
41
- * // Using callbacks
46
+ * // Multiple migrations in sequence
42
47
  * migrations: {
43
- * "1": (builder) => builder.dropColumn("oldField"),
48
+ * "002_add_fields": migrate.sequence(
49
+ * migrate.addColumn("phone", col.string()),
50
+ * migrate.addColumn("address", col.string().nullable()),
51
+ * ),
44
52
  * }
45
53
  *
46
54
  * @example
47
- * // Using typed migrations
55
+ * // Raw migration for advanced use cases
48
56
  * migrations: {
49
- * "001_add_email": migrate.addColumn("email", col.string()),
57
+ * "003_custom": migrate.raw((builder) => builder.dropColumn("oldField")),
50
58
  * }
51
59
  */
52
60
  migrations?: Migrations;
@@ -104,8 +112,9 @@ export declare class Table<Columns extends Record<string, ColumnDef<any, any>> =
104
112
  /**
105
113
  * The inferred TypeScript type for rows of this table.
106
114
  * Includes base columns and all migration type transforms.
115
+ * Supports both single migrations and arrays of migrations.
107
116
  */
108
- readonly $type: Migrations extends Record<string, TypedMigration<any, any>> ? FinalTableType<Columns, Migrations> : InferColumns<Columns>;
117
+ readonly $type: FinalTableType<Columns, Migrations>;
109
118
  private requireOrm;
110
119
  get client(): Knex;
111
120
  get query(): Knex.QueryBuilder<InferColumns<Columns>, {
package/dist/app/table.js CHANGED
@@ -151,8 +151,26 @@ export class Table {
151
151
  const allPureString = keys.every((k) => !/^\d/.test(k));
152
152
  // Validate: no mixing allowed
153
153
  if (!allPureNumeric && !allNumericPrefix && !allPureString) {
154
- throw new Error(`Table "${this.options.name}": Cannot mix migration key patterns. ` +
155
- `Use one of: pure numbers (1, 2), prefixed strings ("001_x", "002_y"), or pure strings ("add_x").`);
154
+ // Categorize keys for helpful error message
155
+ const numericKeys = keys.filter((k) => /^\d+$/.test(k));
156
+ const prefixedKeys = keys.filter((k) => /^\d+[_\-a-zA-Z]/.test(k));
157
+ const stringKeys = keys.filter((k) => !/^\d/.test(k));
158
+ const parts = [];
159
+ if (numericKeys.length > 0) {
160
+ parts.push(`numeric keys: ${numericKeys.map((k) => `"${k}"`).join(", ")}`);
161
+ }
162
+ if (prefixedKeys.length > 0) {
163
+ parts.push(`prefixed keys: ${prefixedKeys.map((k) => `"${k}"`).join(", ")}`);
164
+ }
165
+ if (stringKeys.length > 0) {
166
+ parts.push(`string keys: ${stringKeys.map((k) => `"${k}"`).join(", ")}`);
167
+ }
168
+ throw new Error(`Table "${this.options.name}": Migration keys use mixed patterns which prevents reliable ordering.\n\n` +
169
+ `Found: ${parts.join(" AND ")}\n\n` +
170
+ `Choose ONE pattern for all keys:\n` +
171
+ ` - Pure numbers: "1", "2", "10" (sorted numerically)\n` +
172
+ ` - Prefixed strings: "001_init", "002_add" (sorted by prefix)\n` +
173
+ ` - Pure strings: "init", "add_email" (insertion order)`);
156
174
  }
157
175
  if (allPureNumeric) {
158
176
  // Sort purely numeric keys numerically: 1, 2, 10, 20
@@ -217,14 +235,7 @@ export class Table {
217
235
  }
218
236
  const migration = migrations[key];
219
237
  await this.client.schema.alterTable(this.options.name, (builder) => {
220
- if (typeof migration === "function") {
221
- // Callback function migration
222
- migration(builder);
223
- }
224
- else if (migration && typeof migration === "object" && "apply" in migration) {
225
- // TypedMigration object
226
- migration.apply(builder);
227
- }
238
+ migration.apply(builder);
228
239
  });
229
240
  data.version = key;
230
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghom/orm",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,8 +9,8 @@
9
9
  "homepage": "https://github.com/GhomKrosmonaute/orm",
10
10
  "scripts": {
11
11
  "format": "biome format --write .",
12
- "check": "biome check --write .",
13
- "build": "rimraf dist && tsc",
12
+ "check": "biome check --write . && tsc --noEmit",
13
+ "build": "rimraf dist && tsc -p tsconfig.build.json",
14
14
  "test": "bun test",
15
15
  "prepublishOnly": "npm run check && npm run build && npm test"
16
16
  },
package/tests/orm.test.ts CHANGED
@@ -141,6 +141,94 @@ describe("typed migrations", () => {
141
141
  age: null,
142
142
  }
143
143
  })
144
+
145
+ test("Table accepts migrate.sequence for multiple typed migrations", () => {
146
+ const userTable = new Table({
147
+ name: "test_sequence_migrations",
148
+ columns: (col) => ({
149
+ id: col.increments(),
150
+ name: col.string(),
151
+ }),
152
+ migrations: {
153
+ "001_multiple_changes": migrate.sequence(
154
+ migrate.addColumn("phone", col.string()),
155
+ migrate.addColumn("address", col.string().nullable()),
156
+ migrate.addIndex(["phone"], "idx_phone"),
157
+ migrate.renameColumn("name", "username"),
158
+ migrate.renameColumn("username", "fullname"),
159
+ ),
160
+ },
161
+ })
162
+
163
+ expect(userTable).toBeInstanceOf(Table)
164
+ expect(userTable.options.migrations).toBeDefined()
165
+ expect(Object.keys(userTable.options.migrations!).length).toBe(1)
166
+
167
+ // Type inference check - sequence migrations should infer types correctly
168
+ type ExpectedType = typeof userTable.$type
169
+ const _typeCheck: ExpectedType = {
170
+ id: 1,
171
+ // @ts-expect-error - name is removed by renameColumn
172
+ name: "test",
173
+ // @ts-expect-error - username is removed by renameColumn
174
+ username: "test",
175
+ fullname: "test",
176
+ phone: "123456789",
177
+ address: null,
178
+ }
179
+ })
180
+
181
+ test("Table accepts mixed single and sequence migrations", () => {
182
+ const userTable = new Table({
183
+ name: "test_mixed_migrations",
184
+ columns: (col) => ({
185
+ id: col.increments(),
186
+ name: col.string(),
187
+ }),
188
+ migrations: {
189
+ "001_add_email": migrate.addColumn("email", col.string()),
190
+ "002_multiple_changes": migrate.sequence(
191
+ migrate.addColumn("phone", col.string()),
192
+ migrate.addColumn("age", col.integer().nullable()),
193
+ ),
194
+ "003_add_active": migrate.addColumn("isActive", col.boolean().defaultTo(true)),
195
+ },
196
+ })
197
+
198
+ expect(userTable).toBeInstanceOf(Table)
199
+ expect(userTable.options.migrations).toBeDefined()
200
+ expect(Object.keys(userTable.options.migrations!).length).toBe(3)
201
+
202
+ // Type inference check - mixed migrations should infer all types
203
+ type ExpectedType = typeof userTable.$type
204
+ const _typeCheck: ExpectedType = {
205
+ id: 1,
206
+ name: "test",
207
+ email: "test@example.com",
208
+ phone: "123456789",
209
+ age: null,
210
+ isActive: true,
211
+ }
212
+ })
213
+
214
+ test("Table accepts migrate.sequence with raw migrations", () => {
215
+ const table = new Table({
216
+ name: "test_sequence_raw_migrations",
217
+ columns: (col) => ({
218
+ id: col.increments(),
219
+ }),
220
+ migrations: {
221
+ "001_multiple_raw": migrate.sequence(
222
+ migrate.raw((builder) => builder.string("field1")),
223
+ migrate.raw((builder) => builder.integer("field2")),
224
+ ),
225
+ },
226
+ })
227
+
228
+ expect(table).toBeInstanceOf(Table)
229
+ expect(table.options.migrations).toBeDefined()
230
+ expect(table.options.migrations!["001_multiple_raw"]).toBeDefined()
231
+ })
144
232
  })
145
233
 
146
234
  describe("migration key patterns", () => {
@@ -151,9 +239,9 @@ describe("migration key patterns", () => {
151
239
  id: col.increments(),
152
240
  }),
153
241
  migrations: {
154
- "1": (_builder) => {},
155
- "2": (_builder) => {},
156
- "10": (_builder) => {},
242
+ 1: migrate.raw(() => {}),
243
+ 2: migrate.raw(() => {}),
244
+ 10: migrate.raw(() => {}),
157
245
  },
158
246
  })
159
247
 
@@ -168,9 +256,9 @@ describe("migration key patterns", () => {
168
256
  id: col.increments(),
169
257
  }),
170
258
  migrations: {
171
- "001_init": (_builder) => {},
172
- "002_add_column": (_builder) => {},
173
- "010_fix": (_builder) => {},
259
+ "001_init": migrate.raw(() => {}),
260
+ "002_add_column": migrate.raw(() => {}),
261
+ "010_fix": migrate.raw(() => {}),
174
262
  },
175
263
  })
176
264
 
@@ -189,15 +277,36 @@ describe("migration key patterns", () => {
189
277
  id: col.increments(),
190
278
  }),
191
279
  migrations: {
192
- init: (_builder) => {},
193
- add_column: (_builder) => {},
194
- fix: (_builder) => {},
280
+ init: migrate.raw(() => {}),
281
+ add_column: migrate.raw(() => {}),
282
+ fix: migrate.raw(() => {}),
195
283
  },
196
284
  })
197
285
 
198
286
  expect(table.options.migrations).toBeDefined()
199
287
  expect(Object.keys(table.options.migrations!)).toEqual(["init", "add_column", "fix"])
200
288
  })
289
+
290
+ test("Table rejects mixed key patterns at migration time", () => {
291
+ const table = new Table({
292
+ name: "test_mixed_keys",
293
+ columns: (col) => ({
294
+ id: col.increments(),
295
+ }),
296
+ migrations: {
297
+ 1: migrate.raw(() => {}),
298
+ "001_init": migrate.raw(() => {}),
299
+ init: migrate.raw(() => {}),
300
+ },
301
+ })
302
+
303
+ // The error is thrown when getMigrationKeys() is called during migration
304
+ // This happens during make(), not during construction
305
+ expect(() => {
306
+ // Access private method to test key validation
307
+ ;(table as any).getMigrationKeys()
308
+ }).toThrow(/Migration keys use mixed patterns/)
309
+ })
201
310
  })
202
311
 
203
312
  describe("unconnected ORM", () => {
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src"
5
+ },
6
+ "include": ["src/**/*"],
7
+ "exclude": ["tests/**/*"]
8
+ }
package/tsconfig.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "strict": true,
4
- "rootDir": "src",
5
4
  "outDir": "dist",
6
5
  "module": "NodeNext",
7
6
  "target": "ESNext",
@@ -12,5 +11,6 @@
12
11
  "skipLibCheck": true,
13
12
  "typeRoots": ["./node_modules/@types", "./dist/typings"]
14
13
  },
15
- "include": ["src/**/*", "dist/typings/**/*"]
14
+ "include": ["src/**/*", "dist/typings/**/*"],
15
+ "exclude": ["tests/**/*"]
16
16
  }