@ghom/orm 2.0.0 → 2.1.0

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/biome.json CHANGED
@@ -24,6 +24,9 @@
24
24
  "enabled": true,
25
25
  "rules": {
26
26
  "recommended": true,
27
+ "complexity": {
28
+ "noBannedTypes": "off"
29
+ },
27
30
  "suspicious": {
28
31
  "noExplicitAny": "off",
29
32
  "noThenProperty": "off",
@@ -32,6 +35,10 @@
32
35
  "style": {
33
36
  "noNonNullAssertion": "off",
34
37
  "useNodejsImportProtocol": "off"
38
+ },
39
+ "correctness": {
40
+ "noUnusedVariables": "off",
41
+ "noUnusedImports": "off"
35
42
  }
36
43
  }
37
44
  },
@@ -0,0 +1,171 @@
1
+ import type { Knex } from "knex";
2
+ import { type ColumnDef, type InferColumns, type InferColumnType } from "./column.js";
3
+ /**
4
+ * Represents a typed migration that transforms the table schema.
5
+ * Carries type information about what columns are removed and added.
6
+ *
7
+ * @template From - The type of columns being removed/modified
8
+ * @template To - The type of columns being added/modified
9
+ */
10
+ export interface TypedMigration<From = {}, To = {}> {
11
+ /** @internal Type marker for columns being removed */
12
+ readonly _from: From;
13
+ /** @internal Type marker for columns being added */
14
+ readonly _to: To;
15
+ /**
16
+ * Apply the migration to the table builder.
17
+ */
18
+ apply: (builder: Knex.AlterTableBuilder) => void;
19
+ }
20
+ /**
21
+ * Extract all "From" keys from a union of TypedMigration.
22
+ * These are the columns that will be removed/renamed.
23
+ */
24
+ type ExtractFromKeys<M> = M extends TypedMigration<infer From, any> ? keyof From : never;
25
+ /**
26
+ * Extract all "To" types from a union of TypedMigration and intersect them.
27
+ * These are the columns that will be added.
28
+ */
29
+ type ExtractToTypes<M> = M extends TypedMigration<any, infer To> ? To : never;
30
+ /**
31
+ * Convert a union to an intersection.
32
+ * Used to combine all "To" types from migrations.
33
+ */
34
+ type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
35
+ /**
36
+ * Apply all migrations to compute the final type.
37
+ * 1. Remove all columns specified in migration "From" types
38
+ * 2. Add all columns specified in migration "To" types
39
+ */
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;
41
+ /**
42
+ * Compute the final table type from base columns and migrations.
43
+ */
44
+ export type FinalTableType<Columns extends Record<string, ColumnDef<any, any>>, Migrations extends Record<string, TypedMigration<any, any>> = {}> = ApplyMigrations<InferColumns<Columns>, Migrations>;
45
+ /**
46
+ * Migration helpers for creating typed migrations.
47
+ * Each helper returns a TypedMigration with appropriate type transformations.
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * import { migrate, col } from "@ghom/orm"
52
+ *
53
+ * const userTable = new Table({
54
+ * name: "user",
55
+ * columns: (col) => ({
56
+ * id: col.increments(),
57
+ * name: col.string(),
58
+ * }),
59
+ * migrations: {
60
+ * "001_rename_name": migrate.renameColumn("name", "username"),
61
+ * "002_add_email": migrate.addColumn("email", col.string()),
62
+ * },
63
+ * })
64
+ * ```
65
+ */
66
+ export declare const migrate: {
67
+ /**
68
+ * Add a new column to the table.
69
+ *
70
+ * @param name - The column name
71
+ * @param column - The column definition
72
+ * @returns A typed migration that adds the column
73
+ *
74
+ * @example
75
+ * migrate.addColumn("email", col.string())
76
+ * // Adds: { email: string }
77
+ */
78
+ addColumn<K extends string, C extends ColumnDef<any, any>>(name: K, column: C): TypedMigration<{}, { [P in K]: InferColumnType<C>; }>;
79
+ /**
80
+ * Drop a column from the table.
81
+ *
82
+ * @param name - The column name to drop
83
+ * @returns A typed migration that removes the column
84
+ *
85
+ * @example
86
+ * migrate.dropColumn("oldField")
87
+ * // Removes: { oldField: any }
88
+ */
89
+ dropColumn<K extends string>(name: K): TypedMigration<{ [P in K]: unknown; }, {}>;
90
+ /**
91
+ * Rename a column.
92
+ *
93
+ * @param oldName - The current column name
94
+ * @param newName - The new column name
95
+ * @returns A typed migration that renames the column
96
+ *
97
+ * @example
98
+ * migrate.renameColumn("name", "username")
99
+ * // Removes: { name: any }, Adds: { username: any }
100
+ */
101
+ renameColumn<Old extends string, New extends string>(oldName: Old, newName: New): TypedMigration<{ [P in Old]: unknown; }, { [P in New]: unknown; }>;
102
+ /**
103
+ * Alter a column's type or constraints.
104
+ *
105
+ * @param name - The column name
106
+ * @param column - The new column definition
107
+ * @returns A typed migration that alters the column
108
+ *
109
+ * @example
110
+ * migrate.alterColumn("age", col.integer().nullable())
111
+ * // Changes type: { age: number | null }
112
+ */
113
+ alterColumn<K extends string, C extends ColumnDef<any, any>>(name: K, column: C): TypedMigration<{ [P in K]: unknown; }, { [P in K]: InferColumnType<C>; }>;
114
+ /**
115
+ * Add an index on one or more columns.
116
+ *
117
+ * @param columns - Array of column names to index
118
+ * @param name - Optional index name
119
+ * @returns A typed migration (no type change)
120
+ *
121
+ * @example
122
+ * migrate.addIndex(["email"], "idx_email")
123
+ */
124
+ addIndex(columns: string[], name?: string): TypedMigration<{}, {}>;
125
+ /**
126
+ * Drop an index by name.
127
+ *
128
+ * @param name - The index name to drop
129
+ * @returns A typed migration (no type change)
130
+ *
131
+ * @example
132
+ * migrate.dropIndex("idx_email")
133
+ */
134
+ dropIndex(name: string): TypedMigration<{}, {}>;
135
+ /**
136
+ * Add a unique constraint on one or more columns.
137
+ *
138
+ * @param columns - Array of column names
139
+ * @param name - Optional constraint name
140
+ * @returns A typed migration (no type change)
141
+ *
142
+ * @example
143
+ * migrate.addUnique(["email"], "uniq_email")
144
+ */
145
+ addUnique(columns: string[], name?: string): TypedMigration<{}, {}>;
146
+ /**
147
+ * Drop a unique constraint by name.
148
+ *
149
+ * @param name - The constraint name to drop
150
+ * @returns A typed migration (no type change)
151
+ *
152
+ * @example
153
+ * migrate.dropUnique("uniq_email")
154
+ */
155
+ dropUnique(name: string): TypedMigration<{}, {}>;
156
+ /**
157
+ * Custom migration with a raw callback.
158
+ * Use this when the built-in helpers don't cover your use case.
159
+ *
160
+ * @param fn - The migration callback
161
+ * @returns A typed migration (no type change by default)
162
+ *
163
+ * @example
164
+ * migrate.raw((builder) => {
165
+ * builder.dropColumn("temp")
166
+ * builder.string("new_col")
167
+ * })
168
+ */
169
+ raw<From = {}, To = {}>(fn: (builder: Knex.AlterTableBuilder) => void): TypedMigration<From, To>;
170
+ };
171
+ export {};
@@ -0,0 +1,198 @@
1
+ import { buildColumnsSchema, } from "./column.js";
2
+ /**
3
+ * Migration helpers for creating typed migrations.
4
+ * Each helper returns a TypedMigration with appropriate type transformations.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { migrate, col } from "@ghom/orm"
9
+ *
10
+ * const userTable = new Table({
11
+ * name: "user",
12
+ * columns: (col) => ({
13
+ * id: col.increments(),
14
+ * name: col.string(),
15
+ * }),
16
+ * migrations: {
17
+ * "001_rename_name": migrate.renameColumn("name", "username"),
18
+ * "002_add_email": migrate.addColumn("email", col.string()),
19
+ * },
20
+ * })
21
+ * ```
22
+ */
23
+ export const migrate = {
24
+ /**
25
+ * Add a new column to the table.
26
+ *
27
+ * @param name - The column name
28
+ * @param column - The column definition
29
+ * @returns A typed migration that adds the column
30
+ *
31
+ * @example
32
+ * migrate.addColumn("email", col.string())
33
+ * // Adds: { email: string }
34
+ */
35
+ addColumn(name, column) {
36
+ return {
37
+ _from: {},
38
+ _to: {},
39
+ apply: (builder) => {
40
+ buildColumnsSchema(builder, { [name]: column });
41
+ },
42
+ };
43
+ },
44
+ /**
45
+ * Drop a column from the table.
46
+ *
47
+ * @param name - The column name to drop
48
+ * @returns A typed migration that removes the column
49
+ *
50
+ * @example
51
+ * migrate.dropColumn("oldField")
52
+ * // Removes: { oldField: any }
53
+ */
54
+ dropColumn(name) {
55
+ return {
56
+ _from: {},
57
+ _to: {},
58
+ apply: (builder) => {
59
+ builder.dropColumn(name);
60
+ },
61
+ };
62
+ },
63
+ /**
64
+ * Rename a column.
65
+ *
66
+ * @param oldName - The current column name
67
+ * @param newName - The new column name
68
+ * @returns A typed migration that renames the column
69
+ *
70
+ * @example
71
+ * migrate.renameColumn("name", "username")
72
+ * // Removes: { name: any }, Adds: { username: any }
73
+ */
74
+ renameColumn(oldName, newName) {
75
+ return {
76
+ _from: {},
77
+ _to: {},
78
+ apply: (builder) => {
79
+ builder.renameColumn(oldName, newName);
80
+ },
81
+ };
82
+ },
83
+ /**
84
+ * Alter a column's type or constraints.
85
+ *
86
+ * @param name - The column name
87
+ * @param column - The new column definition
88
+ * @returns A typed migration that alters the column
89
+ *
90
+ * @example
91
+ * migrate.alterColumn("age", col.integer().nullable())
92
+ * // Changes type: { age: number | null }
93
+ */
94
+ alterColumn(name, column) {
95
+ return {
96
+ _from: {},
97
+ _to: {},
98
+ apply: (builder) => {
99
+ builder.dropColumn(name);
100
+ buildColumnsSchema(builder, { [name]: column });
101
+ },
102
+ };
103
+ },
104
+ /**
105
+ * Add an index on one or more columns.
106
+ *
107
+ * @param columns - Array of column names to index
108
+ * @param name - Optional index name
109
+ * @returns A typed migration (no type change)
110
+ *
111
+ * @example
112
+ * migrate.addIndex(["email"], "idx_email")
113
+ */
114
+ addIndex(columns, name) {
115
+ return {
116
+ _from: {},
117
+ _to: {},
118
+ apply: (builder) => {
119
+ builder.index(columns, name);
120
+ },
121
+ };
122
+ },
123
+ /**
124
+ * Drop an index by name.
125
+ *
126
+ * @param name - The index name to drop
127
+ * @returns A typed migration (no type change)
128
+ *
129
+ * @example
130
+ * migrate.dropIndex("idx_email")
131
+ */
132
+ dropIndex(name) {
133
+ return {
134
+ _from: {},
135
+ _to: {},
136
+ apply: (builder) => {
137
+ builder.dropIndex([], name);
138
+ },
139
+ };
140
+ },
141
+ /**
142
+ * Add a unique constraint on one or more columns.
143
+ *
144
+ * @param columns - Array of column names
145
+ * @param name - Optional constraint name
146
+ * @returns A typed migration (no type change)
147
+ *
148
+ * @example
149
+ * migrate.addUnique(["email"], "uniq_email")
150
+ */
151
+ addUnique(columns, name) {
152
+ return {
153
+ _from: {},
154
+ _to: {},
155
+ apply: (builder) => {
156
+ builder.unique(columns, { indexName: name });
157
+ },
158
+ };
159
+ },
160
+ /**
161
+ * Drop a unique constraint by name.
162
+ *
163
+ * @param name - The constraint name to drop
164
+ * @returns A typed migration (no type change)
165
+ *
166
+ * @example
167
+ * migrate.dropUnique("uniq_email")
168
+ */
169
+ dropUnique(name) {
170
+ return {
171
+ _from: {},
172
+ _to: {},
173
+ apply: (builder) => {
174
+ builder.dropUnique([], name);
175
+ },
176
+ };
177
+ },
178
+ /**
179
+ * Custom migration with a raw callback.
180
+ * Use this when the built-in helpers don't cover your use case.
181
+ *
182
+ * @param fn - The migration callback
183
+ * @returns A typed migration (no type change by default)
184
+ *
185
+ * @example
186
+ * migrate.raw((builder) => {
187
+ * builder.dropColumn("temp")
188
+ * builder.string("new_col")
189
+ * })
190
+ */
191
+ raw(fn) {
192
+ return {
193
+ _from: {},
194
+ _to: {},
195
+ apply: fn,
196
+ };
197
+ },
198
+ };
package/dist/app/orm.d.ts CHANGED
@@ -43,6 +43,21 @@ export interface ORMConfig {
43
43
  * Default is `Infinity`.
44
44
  */
45
45
  caching?: number;
46
+ /**
47
+ * Configuration for migration behavior.
48
+ */
49
+ migrations?: {
50
+ /**
51
+ * Force alphabetical sorting for string migration keys instead of insertion order.
52
+ *
53
+ * **NOT RECOMMENDED**: If your keys start with numbers (e.g., "001_init", "002_add_users"),
54
+ * they are automatically sorted by those numbers, not alphabetically.
55
+ * Prefer insertion order or purely numeric keys instead.
56
+ *
57
+ * @default false
58
+ */
59
+ alphabeticalOrder?: boolean;
60
+ };
46
61
  }
47
62
  /**
48
63
  * The main ORM class that manages database connections, tables, and caching.
@@ -85,7 +100,7 @@ export declare class ORM {
85
100
  * Returns true if the ORM has a database client connected.
86
101
  */
87
102
  get isConnected(): boolean;
88
- get cachedTables(): Table<any>[];
103
+ get cachedTables(): Table<any, {}>[];
89
104
  get cachedTableNames(): string[];
90
105
  hasCachedTable(name: string): boolean;
91
106
  hasTable(name: string): Promise<boolean>;
@@ -109,4 +124,9 @@ export declare class ORM {
109
124
  * @warning This will delete all the data in the tables.
110
125
  */
111
126
  restoreBackup(dirname?: string): Promise<void>;
127
+ /**
128
+ * Upgrade the migration table from integer version to string version.
129
+ * This is needed for projects that were using the old migration system.
130
+ */
131
+ private upgradeMigrationTableIfNeeded;
112
132
  }
package/dist/app/orm.js CHANGED
@@ -5,6 +5,16 @@ import { default as knex } from "knex";
5
5
  import { backupTable, disableForeignKeys, enableForeignKeys, restoreBackup } from "./backup.js";
6
6
  import { Table } from "./table.js";
7
7
  import { isCJS } from "./util.js";
8
+ /**
9
+ * Verify that the environment supports ES2015+ object key ordering.
10
+ * In ES2015+, integer keys are sorted numerically first, then string keys
11
+ * maintain their insertion order.
12
+ */
13
+ function checkES2015KeyOrder() {
14
+ const test = { "2": "a", "1": "b", c: "d" };
15
+ const keys = Object.keys(test);
16
+ return keys[0] === "1" && keys[1] === "2" && keys[2] === "c";
17
+ }
8
18
  /**
9
19
  * The main ORM class that manages database connections, tables, and caching.
10
20
  *
@@ -41,6 +51,9 @@ export class ORM {
41
51
  */
42
52
  constructor(config) {
43
53
  this.config = config;
54
+ if (!checkES2015KeyOrder()) {
55
+ throw new Error("@ghom/orm requires ES2015+ environment for guaranteed object key ordering");
56
+ }
44
57
  if (config === false)
45
58
  return;
46
59
  this._client = knex(config.database ?? {
@@ -105,9 +118,11 @@ export class ORM {
105
118
  priority: Infinity,
106
119
  columns: (col) => ({
107
120
  table: col.string().unique(),
108
- version: col.integer(),
121
+ version: col.string(),
109
122
  }),
110
123
  }));
124
+ // Auto-migrate version column from integer to string for existing projects
125
+ await this.upgradeMigrationTableIfNeeded();
111
126
  const sortedTables = this.cachedTables.toSorted((a, b) => (b.options.priority ?? 0) - (a.options.priority ?? 0));
112
127
  for (const table of sortedTables) {
113
128
  await table.make(this);
@@ -162,4 +177,39 @@ export class ORM {
162
177
  });
163
178
  console.log("Database restored from backup.");
164
179
  }
180
+ /**
181
+ * Upgrade the migration table from integer version to string version.
182
+ * This is needed for projects that were using the old migration system.
183
+ */
184
+ async upgradeMigrationTableIfNeeded() {
185
+ this.requireClient();
186
+ const hasMigrationTable = await this._client.schema.hasTable("migration");
187
+ if (!hasMigrationTable)
188
+ return;
189
+ const columnInfo = await this._client("migration").columnInfo("version");
190
+ const columnType = columnInfo?.type ?? "";
191
+ // Check if version column is integer type (varies by database)
192
+ const isIntegerType = columnType.includes("int") ||
193
+ columnType.includes("INT") ||
194
+ columnType === "integer" ||
195
+ columnType === "INTEGER";
196
+ if (!isIntegerType)
197
+ return;
198
+ // Migrate: convert integer versions to strings
199
+ // SQLite doesn't support column alterations well, so we use a temp column approach
200
+ await this._client.schema.alterTable("migration", (t) => {
201
+ t.string("version_new");
202
+ });
203
+ await this._client("migration").update({
204
+ version_new: this._client.raw("CAST(version AS TEXT)"),
205
+ });
206
+ await this._client.schema.alterTable("migration", (t) => {
207
+ t.dropColumn("version");
208
+ });
209
+ await this._client.schema.alterTable("migration", (t) => {
210
+ t.renameColumn("version_new", "version");
211
+ });
212
+ this.config !== false &&
213
+ this.config.logger?.log("Upgraded migration table: version column converted to string");
214
+ }
165
215
  }
@@ -1,16 +1,24 @@
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
5
  import type { ORM } from "./orm.js";
6
+ /**
7
+ * A migration can be either a callback function or a TypedMigration object.
8
+ */
9
+ export type MigrationValue = ((builder: Knex.CreateTableBuilder) => void) | TypedMigration<any, any>;
5
10
  export interface MigrationData {
6
11
  table: string;
7
- version: number;
12
+ version: string;
8
13
  }
9
14
  /**
10
- * Table options with typed columns.
11
- * Type is automatically inferred from the column definitions.
15
+ * Table options with typed columns and optional typed migrations.
16
+ * Type is automatically inferred from the column definitions and migrations.
17
+ *
18
+ * @template Columns - Record of column definitions
19
+ * @template Migrations - Record of migration definitions (optional)
12
20
  */
13
- export interface TableOptions<Columns extends Record<string, ColumnDef<any, any>>> {
21
+ export interface TableOptions<Columns extends Record<string, ColumnDef<any, any>>, Migrations extends Record<string, MigrationValue> = {}> {
14
22
  name: string;
15
23
  description?: string;
16
24
  priority?: number;
@@ -19,10 +27,30 @@ export interface TableOptions<Columns extends Record<string, ColumnDef<any, any>
19
27
  * Default is `Infinity`.
20
28
  */
21
29
  caching?: number;
22
- migrations?: {
23
- [version: number]: (builder: Knex.CreateTableBuilder) => void;
24
- };
25
- then?: (this: Table<Columns>, table: Table<Columns>) => unknown;
30
+ /**
31
+ * Database migrations to apply.
32
+ *
33
+ * Supports three key patterns:
34
+ * - **Numeric keys** (`"1"`, `"2"`): Sorted numerically
35
+ * - **Numeric-prefixed keys** (`"001_init"`, `"002_add"`): Sorted by prefix
36
+ * - **Pure string keys** (`"init"`, `"add"`): Uses insertion order
37
+ *
38
+ * Can use either callback functions or TypedMigration objects.
39
+ *
40
+ * @example
41
+ * // Using callbacks
42
+ * migrations: {
43
+ * "1": (builder) => builder.dropColumn("oldField"),
44
+ * }
45
+ *
46
+ * @example
47
+ * // Using typed migrations
48
+ * migrations: {
49
+ * "001_add_email": migrate.addColumn("email", col.string()),
50
+ * }
51
+ */
52
+ migrations?: Migrations;
53
+ then?: (this: Table<Columns, Migrations>, table: Table<Columns, Migrations>) => unknown;
26
54
  /**
27
55
  * Typed columns definition with automatic type inference.
28
56
  *
@@ -37,7 +65,7 @@ export interface TableOptions<Columns extends Record<string, ColumnDef<any, any>
37
65
  columns: (col: typeof import("./column.js").col) => Columns;
38
66
  }
39
67
  /**
40
- * Represents a database table with typed columns.
68
+ * Represents a database table with typed columns and migrations.
41
69
  *
42
70
  * @example
43
71
  * const userTable = new Table({
@@ -49,15 +77,35 @@ export interface TableOptions<Columns extends Record<string, ColumnDef<any, any>
49
77
  * }),
50
78
  * })
51
79
  * // Type is automatically inferred as { id: number; username: string; age: number | null }
80
+ *
81
+ * @example
82
+ * // With typed migrations
83
+ * const userTable = new Table({
84
+ * name: "user",
85
+ * columns: (col) => ({
86
+ * id: col.increments(),
87
+ * name: col.string(),
88
+ * }),
89
+ * migrations: {
90
+ * "001_rename": migrate.renameColumn("name", "username"),
91
+ * "002_add_email": migrate.addColumn("email", col.string()),
92
+ * },
93
+ * })
94
+ * // Type includes migration transforms: { id: number; username: string; email: string }
52
95
  */
53
- export declare class Table<Columns extends Record<string, ColumnDef<any, any>> = Record<string, ColumnDef<any, any>>> {
54
- readonly options: TableOptions<Columns>;
96
+ export declare class Table<Columns extends Record<string, ColumnDef<any, any>> = Record<string, ColumnDef<any, any>>, Migrations extends Record<string, MigrationValue> = {}> {
97
+ readonly options: TableOptions<Columns, Migrations>;
55
98
  orm?: ORM;
56
- _whereCache?: CachedQuery<[cb: (query: Table<Columns>["query"]) => unknown], unknown>;
99
+ _whereCache?: CachedQuery<[
100
+ cb: (query: Table<Columns, Migrations>["query"]) => unknown
101
+ ], unknown>;
57
102
  _countCache?: CachedQuery<[where: string | null], number>;
58
- constructor(options: TableOptions<Columns>);
59
- /** The inferred TypeScript type for rows of this table */
60
- readonly $type: InferColumns<Columns>;
103
+ constructor(options: TableOptions<Columns, Migrations>);
104
+ /**
105
+ * The inferred TypeScript type for rows of this table.
106
+ * Includes base columns and all migration type transforms.
107
+ */
108
+ readonly $type: Migrations extends Record<string, TypedMigration<any, any>> ? FinalTableType<Columns, Migrations> : InferColumns<Columns>;
61
109
  private requireOrm;
62
110
  get client(): Knex;
63
111
  get query(): Knex.QueryBuilder<InferColumns<Columns>, {
@@ -82,5 +130,17 @@ export declare class Table<Columns extends Record<string, ColumnDef<any, any>> =
82
130
  getColumnNames(): Promise<Array<keyof InferColumns<Columns> & string>>;
83
131
  isEmpty(): Promise<boolean>;
84
132
  make(orm: ORM): Promise<this>;
133
+ /**
134
+ * Get sorted migration keys based on their pattern.
135
+ * - Pure numeric keys ("1", "2", "10") are sorted numerically
136
+ * - Numeric-prefixed keys ("001_add", "010_rename") are sorted by prefix
137
+ * - Pure string keys ("add_email", "rename") use insertion order or alphabetical
138
+ */
139
+ private getMigrationKeys;
140
+ /**
141
+ * Compare migration keys for determining if one is greater than another.
142
+ * Handles both numeric and string comparisons appropriately.
143
+ */
144
+ private compareMigrationKeys;
85
145
  private migrate;
86
146
  }
package/dist/app/table.js CHANGED
@@ -2,7 +2,7 @@ import { CachedQuery } from "@ghom/query";
2
2
  import { buildColumnsSchema, col } from "./column.js";
3
3
  import { styled } from "./util.js";
4
4
  /**
5
- * Represents a database table with typed columns.
5
+ * Represents a database table with typed columns and migrations.
6
6
  *
7
7
  * @example
8
8
  * const userTable = new Table({
@@ -14,6 +14,21 @@ import { styled } from "./util.js";
14
14
  * }),
15
15
  * })
16
16
  * // Type is automatically inferred as { id: number; username: string; age: number | null }
17
+ *
18
+ * @example
19
+ * // With typed migrations
20
+ * const userTable = new Table({
21
+ * name: "user",
22
+ * columns: (col) => ({
23
+ * id: col.increments(),
24
+ * name: col.string(),
25
+ * }),
26
+ * migrations: {
27
+ * "001_rename": migrate.renameColumn("name", "username"),
28
+ * "002_add_email": migrate.addColumn("email", col.string()),
29
+ * },
30
+ * })
31
+ * // Type includes migration transforms: { id: number; username: string; email: string }
17
32
  */
18
33
  export class Table {
19
34
  options;
@@ -120,27 +135,98 @@ export class Table {
120
135
  }
121
136
  return this;
122
137
  }
138
+ /**
139
+ * Get sorted migration keys based on their pattern.
140
+ * - Pure numeric keys ("1", "2", "10") are sorted numerically
141
+ * - Numeric-prefixed keys ("001_add", "010_rename") are sorted by prefix
142
+ * - Pure string keys ("add_email", "rename") use insertion order or alphabetical
143
+ */
144
+ getMigrationKeys() {
145
+ const keys = Object.keys(this.options.migrations ?? {});
146
+ if (keys.length === 0)
147
+ return [];
148
+ // Detect key type patterns
149
+ const allPureNumeric = keys.every((k) => /^\d+$/.test(k));
150
+ const allNumericPrefix = keys.every((k) => /^\d+/.test(k));
151
+ const allPureString = keys.every((k) => !/^\d/.test(k));
152
+ // Validate: no mixing allowed
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").`);
156
+ }
157
+ if (allPureNumeric) {
158
+ // Sort purely numeric keys numerically: 1, 2, 10, 20
159
+ return keys.sort((a, b) => Number(a) - Number(b));
160
+ }
161
+ if (allNumericPrefix) {
162
+ // Sort by numeric prefix: "001_x" < "002_y" < "010_z"
163
+ const getNumericPrefix = (key) => parseInt(key.match(/^(\d+)/)?.[1] ?? "0", 10);
164
+ return keys.sort((a, b) => getNumericPrefix(a) - getNumericPrefix(b));
165
+ }
166
+ // Pure strings: alphabetical order OR insertion order
167
+ // Get ORM config if available (and not false for unconnected ORM)
168
+ const ormConfig = this.orm?.config === false ? undefined : this.orm?.config;
169
+ if (ormConfig?.migrations?.alphabeticalOrder) {
170
+ return keys.sort((a, b) => a.localeCompare(b));
171
+ }
172
+ // Warning for insertion order (Git merge risks)
173
+ if (keys.length > 1 && ormConfig) {
174
+ ormConfig.logger?.warn?.(`Table "${this.options.name}": Using insertion order for string migration keys. ` +
175
+ `This may cause issues with Git merges. Consider using numeric prefixes (e.g., "001_init").`);
176
+ }
177
+ return keys; // Insertion order (ES2015+)
178
+ }
179
+ /**
180
+ * Compare migration keys for determining if one is greater than another.
181
+ * Handles both numeric and string comparisons appropriately.
182
+ */
183
+ compareMigrationKeys(a, b) {
184
+ const aIsNumeric = /^\d+$/.test(a);
185
+ const bIsNumeric = /^\d+$/.test(b);
186
+ if (aIsNumeric && bIsNumeric) {
187
+ return Number(a) - Number(b);
188
+ }
189
+ const aHasPrefix = /^\d+/.test(a);
190
+ const bHasPrefix = /^\d+/.test(b);
191
+ if (aHasPrefix && bHasPrefix) {
192
+ const aPrefix = parseInt(a.match(/^(\d+)/)?.[1] ?? "0", 10);
193
+ const bPrefix = parseInt(b.match(/^(\d+)/)?.[1] ?? "0", 10);
194
+ return aPrefix - bPrefix;
195
+ }
196
+ return a.localeCompare(b);
197
+ }
123
198
  async migrate() {
124
199
  if (!this.options.migrations)
125
200
  return false;
126
- const migrations = new Map(Object.entries(this.options.migrations)
127
- .sort((a, b) => Number(a[0]) - Number(b[0]))
128
- .map((entry) => [Number(entry[0]), entry[1]]));
201
+ const sortedKeys = this.getMigrationKeys();
202
+ if (sortedKeys.length === 0)
203
+ return false;
204
+ const migrations = this.options.migrations;
129
205
  const fromDatabase = await this.client("migration")
130
206
  .where("table", this.options.name)
131
207
  .first();
132
208
  const data = fromDatabase || {
133
209
  table: this.options.name,
134
- version: -Infinity,
210
+ version: "",
135
211
  };
136
212
  const baseVersion = data.version;
137
- for (const [version, migration] of migrations) {
213
+ for (const key of sortedKeys) {
214
+ // Skip migrations that have already been applied
215
+ if (data.version !== "" && this.compareMigrationKeys(key, data.version) <= 0) {
216
+ continue;
217
+ }
218
+ const migration = migrations[key];
138
219
  await this.client.schema.alterTable(this.options.name, (builder) => {
139
- if (version <= data.version)
140
- return;
141
- migration(builder);
142
- data.version = version;
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
+ }
143
228
  });
229
+ data.version = key;
144
230
  }
145
231
  await this.client("migration").insert(data).onConflict("table").merge();
146
232
  return baseVersion === data.version ? false : data.version;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./app/backup.js";
2
2
  export * from "./app/column.js";
3
+ export * from "./app/migration.js";
3
4
  export * from "./app/orm.js";
4
5
  export * from "./app/table.js";
5
6
  export * from "./app/util.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./app/backup.js";
2
2
  export * from "./app/column.js";
3
+ export * from "./app/migration.js";
3
4
  export * from "./app/orm.js";
4
5
  export * from "./app/table.js";
5
6
  export * from "./app/util.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghom/orm",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -8,8 +8,7 @@
8
8
  "description": "TypeScript KnexJS ORM & handler",
9
9
  "homepage": "https://github.com/GhomKrosmonaute/orm",
10
10
  "scripts": {
11
- "format": "biome format --write src",
12
- "lint": "biome lint .",
11
+ "format": "biome format --write .",
13
12
  "check": "biome check --write .",
14
13
  "build": "rimraf dist && tsc",
15
14
  "test": "bun test",
package/readme.md CHANGED
@@ -62,46 +62,117 @@ orm.raw("SELECT 1") // throws Error
62
62
 
63
63
  ## Add tables
64
64
 
65
- The tables are automatically loaded from the `location` directory.
65
+ The tables are automatically loaded from the `tableLocation` directory. Types are automatically inferred from the column definitions.
66
66
 
67
67
  ```typescript
68
68
  // tables/user.ts
69
69
 
70
- import { Table } from "@ghom/orm"
70
+ import { Table, col } from "@ghom/orm"
71
71
 
72
- interface User {
73
- username: string
74
- password: string
75
- }
76
-
77
- export default new Table<User>({
72
+ export default new Table({
78
73
  name: "user",
79
74
 
80
75
  // the higher the priority, the earlier the table is compiled
81
76
  priority: 0,
82
77
 
83
- // the migration are executed in order of version number
78
+ // typed columns definition with automatic type inference
79
+ columns: (col) => ({
80
+ id: col.increments(),
81
+ username: col.string().unique(),
82
+ password: col.string(),
83
+ age: col.integer().nullable(),
84
+ role: col.enum(["admin", "user"]).defaultTo("user"),
85
+ }),
86
+
87
+ // migrations are executed in order based on key pattern (see Migration Keys section)
84
88
  migrations: {
85
- 1: (table) => {
89
+ "1": (table) => {
86
90
  table.renameColumn("name", "username")
87
91
  }
88
92
  },
89
93
 
90
- // the setup is executed only once for table creation
91
- setup: (table) => {
92
- table.string("name").notNullable()
93
- table.string("password").notNullable()
94
- },
95
-
96
- // the then is executed after the table is created and the migrations are runned
94
+ // then is executed after the table is created and the migrations are run (only if table is empty)
97
95
  then: ({ query }) => {
98
- query.insert({ username: "admin", password: "admin" })
96
+ query.insert({ username: "admin", password: "admin", role: "admin" })
99
97
  },
100
98
 
101
99
  caching: 10 * 60 * 1000 // The table cache. Default to the ORM cache or Infinity
102
100
  })
101
+
102
+ // Type is automatically inferred:
103
+ // { id: number; username: string; password: string; age: number | null; role: "admin" | "user" }
104
+ type User = typeof userTable.$type
105
+ ```
106
+
107
+ ### Typed Migrations
108
+
109
+ You can also use typed migrations that automatically update the TypeScript type:
110
+
111
+ ```typescript
112
+ import { Table, col, migrate } from "@ghom/orm"
113
+
114
+ export default new Table({
115
+ name: "user",
116
+ columns: (col) => ({
117
+ id: col.increments(),
118
+ name: col.string(), // will be renamed to username
119
+ }),
120
+ migrations: {
121
+ "001_rename_name": migrate.renameColumn("name", "username"),
122
+ "002_add_email": migrate.addColumn("email", col.string()),
123
+ "003_add_age": migrate.addColumn("age", col.integer().nullable()),
124
+ },
125
+ })
126
+
127
+ // Final type: { id: number; username: string; email: string; age: number | null }
128
+ ```
129
+
130
+ Available migration helpers:
131
+ - `migrate.addColumn(name, columnDef)` - Add a new column
132
+ - `migrate.dropColumn(name)` - Remove a column
133
+ - `migrate.renameColumn(oldName, newName)` - Rename a column
134
+ - `migrate.alterColumn(name, newColumnDef)` - Change column type/constraints
135
+ - `migrate.addIndex(columns, name?)` - Add an index
136
+ - `migrate.dropIndex(name)` - Remove an index
137
+ - `migrate.addUnique(columns, name?)` - Add a unique constraint
138
+ - `migrate.dropUnique(name)` - Remove a unique constraint
139
+ - `migrate.raw(callback)` - Custom migration callback
140
+
141
+ ## Migration Keys
142
+
143
+ The ORM supports three patterns for migration keys:
144
+
145
+ 1. **Numeric keys** (`"1"`, `"2"`, `"10"`): Sorted numerically
146
+ 2. **Numeric-prefixed keys** (`"001_init"`, `"002_add_users"`, `"010_fix"`): Sorted by numeric prefix
147
+ 3. **Pure string keys** (`"init"`, `"add_users"`): Uses insertion order (ES2015+)
148
+
149
+ > **Warning**: Mixing key patterns is not allowed and will throw an error at runtime.
150
+
151
+ ### Migration Configuration
152
+
153
+ ```typescript
154
+ const orm = new ORM({
155
+ tableLocation: "./tables",
156
+ migrations: {
157
+ /**
158
+ * NOT RECOMMENDED
159
+ * Force alphabetical sorting for string migration keys.
160
+ *
161
+ * If your keys start with numbers (e.g., "001_init"),
162
+ * they are automatically sorted by those numbers,
163
+ * not alphabetically.
164
+ */
165
+ alphabeticalOrder: false // default
166
+ }
167
+ })
103
168
  ```
104
169
 
170
+ ### ES2015+ Requirement
171
+
172
+ This ORM requires ES2015+ for guaranteed object key insertion order. Node.js 6+ and all modern browsers are supported.
173
+
174
+ The ORM performs a runtime check on initialization and will throw an error if the environment doesn't support ES2015+ key ordering.
175
+
105
176
  ## Launch a query
106
177
 
107
178
  For more information about the query builder, see [knexjs.org](https://knexjs.org/).
@@ -192,8 +263,9 @@ The cache of the `<ORM>.cache.raw` method is automatically invalidated when the
192
263
 
193
264
  - [x] Add timed caching system
194
265
  - [x] Add backup option
266
+ - [x] Auto typings for tables from the column definitions
267
+ - [x] Typed migrations with automatic type inference
195
268
  - [ ] Dependency management between tables
196
- - [ ] Auto typings for tables from the column definitions
197
269
  - [ ] Add specific methods for relations and joins
198
270
  - [ ] Add admin panel
199
271
  - [ ] Make possible to switch the data between all possible clients (pg, mysql, sqlite3)
package/tests/orm.test.ts CHANGED
@@ -3,7 +3,7 @@ import fs from "fs"
3
3
  import path from "path"
4
4
  import { rimraf } from "rimraf"
5
5
 
6
- import { col, ORM, type ORMConfig, Table } from "../src"
6
+ import { col, migrate, ORM, type ORMConfig, Table } from "../src"
7
7
 
8
8
  import a from "./tables/a"
9
9
  import b from "./tables/b"
@@ -57,6 +57,149 @@ describe("typed columns", () => {
57
57
  })
58
58
  })
59
59
 
60
+ describe("typed migrations", () => {
61
+ test("migrate.addColumn creates TypedMigration", () => {
62
+ const migration = migrate.addColumn("email", col.string())
63
+
64
+ expect(migration).toBeDefined()
65
+ expect(migration.apply).toBeInstanceOf(Function)
66
+ expect("_from" in migration).toBe(true)
67
+ expect("_to" in migration).toBe(true)
68
+ })
69
+
70
+ test("migrate.dropColumn creates TypedMigration", () => {
71
+ const migration = migrate.dropColumn("oldField")
72
+
73
+ expect(migration).toBeDefined()
74
+ expect(migration.apply).toBeInstanceOf(Function)
75
+ })
76
+
77
+ test("migrate.renameColumn creates TypedMigration", () => {
78
+ const migration = migrate.renameColumn("name", "username")
79
+
80
+ expect(migration).toBeDefined()
81
+ expect(migration.apply).toBeInstanceOf(Function)
82
+ })
83
+
84
+ test("migrate.alterColumn creates TypedMigration", () => {
85
+ const migration = migrate.alterColumn("age", col.integer().nullable())
86
+
87
+ expect(migration).toBeDefined()
88
+ expect(migration.apply).toBeInstanceOf(Function)
89
+ })
90
+
91
+ test("migrate.addIndex creates TypedMigration", () => {
92
+ const migration = migrate.addIndex(["email"], "idx_email")
93
+
94
+ expect(migration).toBeDefined()
95
+ expect(migration.apply).toBeInstanceOf(Function)
96
+ })
97
+
98
+ test("migrate.addUnique creates TypedMigration", () => {
99
+ const migration = migrate.addUnique(["email"], "uniq_email")
100
+
101
+ expect(migration).toBeDefined()
102
+ expect(migration.apply).toBeInstanceOf(Function)
103
+ })
104
+
105
+ test("migrate.raw creates TypedMigration", () => {
106
+ const migration = migrate.raw((builder) => {
107
+ builder.dropColumn("temp")
108
+ })
109
+
110
+ expect(migration).toBeDefined()
111
+ expect(migration.apply).toBeInstanceOf(Function)
112
+ })
113
+
114
+ test("Table with typed migrations has correct options", () => {
115
+ const userTable = new Table({
116
+ name: "test_typed_migrations",
117
+ columns: (col) => ({
118
+ id: col.increments(),
119
+ name: col.string(),
120
+ }),
121
+ migrations: {
122
+ "001_add_email": migrate.addColumn("email", col.string()),
123
+ "002_add_age": migrate.addColumn("age", col.integer().nullable()),
124
+ "003_rename_name": migrate.renameColumn("name", "username"),
125
+ },
126
+ })
127
+
128
+ expect(userTable).toBeInstanceOf(Table)
129
+ expect(userTable.options.migrations).toBeDefined()
130
+ expect(Object.keys(userTable.options.migrations!).length).toBe(3)
131
+
132
+ // Type inference check - final type includes base columns + migrations
133
+ // "name" is removed by renameColumn, "username" is added
134
+ type ExpectedType = typeof userTable.$type
135
+ const _typeCheck: ExpectedType = {
136
+ id: 1,
137
+ username: "test", // renamed from "name"
138
+ // @ts-expect-error - name is removed by renameColumn
139
+ name: "test",
140
+ email: "test@example.com",
141
+ age: null,
142
+ }
143
+ })
144
+ })
145
+
146
+ describe("migration key patterns", () => {
147
+ test("Table accepts pure numeric keys", () => {
148
+ const table = new Table({
149
+ name: "test_numeric_keys",
150
+ columns: (col) => ({
151
+ id: col.increments(),
152
+ }),
153
+ migrations: {
154
+ "1": (_builder) => {},
155
+ "2": (_builder) => {},
156
+ "10": (_builder) => {},
157
+ },
158
+ })
159
+
160
+ expect(table.options.migrations).toBeDefined()
161
+ expect(Object.keys(table.options.migrations!)).toEqual(["1", "2", "10"])
162
+ })
163
+
164
+ test("Table accepts numeric-prefixed keys", () => {
165
+ const table = new Table({
166
+ name: "test_prefixed_keys",
167
+ columns: (col) => ({
168
+ id: col.increments(),
169
+ }),
170
+ migrations: {
171
+ "001_init": (_builder) => {},
172
+ "002_add_column": (_builder) => {},
173
+ "010_fix": (_builder) => {},
174
+ },
175
+ })
176
+
177
+ expect(table.options.migrations).toBeDefined()
178
+ expect(Object.keys(table.options.migrations!)).toEqual([
179
+ "001_init",
180
+ "002_add_column",
181
+ "010_fix",
182
+ ])
183
+ })
184
+
185
+ test("Table accepts pure string keys", () => {
186
+ const table = new Table({
187
+ name: "test_string_keys",
188
+ columns: (col) => ({
189
+ id: col.increments(),
190
+ }),
191
+ migrations: {
192
+ init: (_builder) => {},
193
+ add_column: (_builder) => {},
194
+ fix: (_builder) => {},
195
+ },
196
+ })
197
+
198
+ expect(table.options.migrations).toBeDefined()
199
+ expect(Object.keys(table.options.migrations!)).toEqual(["init", "add_column", "fix"])
200
+ })
201
+ })
202
+
60
203
  describe("unconnected ORM", () => {
61
204
  test("can be initialized with false", () => {
62
205
  const unconnectedOrm = new ORM(false)
package/tests/tables/d.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  import { Table } from "../../src"
2
2
 
3
- /**
4
- * Table using the new typed columns system.
5
- * Type is automatically inferred from the column definitions.
6
- */
7
3
  export default new Table({
8
4
  name: "d",
9
5
  priority: 0,