@ghom/orm 2.0.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.
@@ -1,16 +1,25 @@
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, TypedMigrationSequence } from "./migration.js";
4
5
  import type { ORM } from "./orm.js";
6
+ /**
7
+ * A migration value is a TypedMigration or TypedMigrationSequence.
8
+ * Use migrate.sequence() to combine multiple migrations.
9
+ */
10
+ export type MigrationValue = TypedMigration<any, any> | TypedMigrationSequence<any>;
5
11
  export interface MigrationData {
6
12
  table: string;
7
- version: number;
13
+ version: string;
8
14
  }
9
15
  /**
10
- * Table options with typed columns.
11
- * Type is automatically inferred from the column definitions.
16
+ * Table options with typed columns and optional typed migrations.
17
+ * Type is automatically inferred from the column definitions and migrations.
18
+ *
19
+ * @template Columns - Record of column definitions
20
+ * @template Migrations - Record of migration definitions (optional)
12
21
  */
13
- export interface TableOptions<Columns extends Record<string, ColumnDef<any, any>>> {
22
+ export interface TableOptions<Columns extends Record<string, ColumnDef<any, any>>, Migrations extends Record<string, MigrationValue> = {}> {
14
23
  name: string;
15
24
  description?: string;
16
25
  priority?: number;
@@ -19,10 +28,37 @@ export interface TableOptions<Columns extends Record<string, ColumnDef<any, any>
19
28
  * Default is `Infinity`.
20
29
  */
21
30
  caching?: number;
22
- migrations?: {
23
- [version: number]: (builder: Knex.CreateTableBuilder) => void;
24
- };
25
- then?: (this: Table<Columns>, table: Table<Columns>) => unknown;
31
+ /**
32
+ * Database migrations to apply using typed migrations.
33
+ *
34
+ * Supports three key patterns:
35
+ * - **Numeric keys** (`"1"`, `"2"`): Sorted numerically
36
+ * - **Numeric-prefixed keys** (`"001_init"`, `"002_add"`): Sorted by prefix
37
+ * - **Pure string keys** (`"init"`, `"add"`): Uses insertion order
38
+ *
39
+ * @example
40
+ * // Single migration
41
+ * migrations: {
42
+ * "001_add_email": migrate.addColumn("email", col.string()),
43
+ * }
44
+ *
45
+ * @example
46
+ * // Multiple migrations in sequence
47
+ * migrations: {
48
+ * "002_add_fields": migrate.sequence(
49
+ * migrate.addColumn("phone", col.string()),
50
+ * migrate.addColumn("address", col.string().nullable()),
51
+ * ),
52
+ * }
53
+ *
54
+ * @example
55
+ * // Raw migration for advanced use cases
56
+ * migrations: {
57
+ * "003_custom": migrate.raw((builder) => builder.dropColumn("oldField")),
58
+ * }
59
+ */
60
+ migrations?: Migrations;
61
+ then?: (this: Table<Columns, Migrations>, table: Table<Columns, Migrations>) => unknown;
26
62
  /**
27
63
  * Typed columns definition with automatic type inference.
28
64
  *
@@ -37,7 +73,7 @@ export interface TableOptions<Columns extends Record<string, ColumnDef<any, any>
37
73
  columns: (col: typeof import("./column.js").col) => Columns;
38
74
  }
39
75
  /**
40
- * Represents a database table with typed columns.
76
+ * Represents a database table with typed columns and migrations.
41
77
  *
42
78
  * @example
43
79
  * const userTable = new Table({
@@ -49,15 +85,36 @@ export interface TableOptions<Columns extends Record<string, ColumnDef<any, any>
49
85
  * }),
50
86
  * })
51
87
  * // Type is automatically inferred as { id: number; username: string; age: number | null }
88
+ *
89
+ * @example
90
+ * // With typed migrations
91
+ * const userTable = new Table({
92
+ * name: "user",
93
+ * columns: (col) => ({
94
+ * id: col.increments(),
95
+ * name: col.string(),
96
+ * }),
97
+ * migrations: {
98
+ * "001_rename": migrate.renameColumn("name", "username"),
99
+ * "002_add_email": migrate.addColumn("email", col.string()),
100
+ * },
101
+ * })
102
+ * // Type includes migration transforms: { id: number; username: string; email: string }
52
103
  */
53
- export declare class Table<Columns extends Record<string, ColumnDef<any, any>> = Record<string, ColumnDef<any, any>>> {
54
- readonly options: TableOptions<Columns>;
104
+ export declare class Table<Columns extends Record<string, ColumnDef<any, any>> = Record<string, ColumnDef<any, any>>, Migrations extends Record<string, MigrationValue> = {}> {
105
+ readonly options: TableOptions<Columns, Migrations>;
55
106
  orm?: ORM;
56
- _whereCache?: CachedQuery<[cb: (query: Table<Columns>["query"]) => unknown], unknown>;
107
+ _whereCache?: CachedQuery<[
108
+ cb: (query: Table<Columns, Migrations>["query"]) => unknown
109
+ ], unknown>;
57
110
  _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>;
111
+ constructor(options: TableOptions<Columns, Migrations>);
112
+ /**
113
+ * The inferred TypeScript type for rows of this table.
114
+ * Includes base columns and all migration type transforms.
115
+ * Supports both single migrations and arrays of migrations.
116
+ */
117
+ readonly $type: FinalTableType<Columns, Migrations>;
61
118
  private requireOrm;
62
119
  get client(): Knex;
63
120
  get query(): Knex.QueryBuilder<InferColumns<Columns>, {
@@ -82,5 +139,17 @@ export declare class Table<Columns extends Record<string, ColumnDef<any, any>> =
82
139
  getColumnNames(): Promise<Array<keyof InferColumns<Columns> & string>>;
83
140
  isEmpty(): Promise<boolean>;
84
141
  make(orm: ORM): Promise<this>;
142
+ /**
143
+ * Get sorted migration keys based on their pattern.
144
+ * - Pure numeric keys ("1", "2", "10") are sorted numerically
145
+ * - Numeric-prefixed keys ("001_add", "010_rename") are sorted by prefix
146
+ * - Pure string keys ("add_email", "rename") use insertion order or alphabetical
147
+ */
148
+ private getMigrationKeys;
149
+ /**
150
+ * Compare migration keys for determining if one is greater than another.
151
+ * Handles both numeric and string comparisons appropriately.
152
+ */
153
+ private compareMigrationKeys;
85
154
  private migrate;
86
155
  }
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,109 @@ 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
+ // 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)`);
174
+ }
175
+ if (allPureNumeric) {
176
+ // Sort purely numeric keys numerically: 1, 2, 10, 20
177
+ return keys.sort((a, b) => Number(a) - Number(b));
178
+ }
179
+ if (allNumericPrefix) {
180
+ // Sort by numeric prefix: "001_x" < "002_y" < "010_z"
181
+ const getNumericPrefix = (key) => parseInt(key.match(/^(\d+)/)?.[1] ?? "0", 10);
182
+ return keys.sort((a, b) => getNumericPrefix(a) - getNumericPrefix(b));
183
+ }
184
+ // Pure strings: alphabetical order OR insertion order
185
+ // Get ORM config if available (and not false for unconnected ORM)
186
+ const ormConfig = this.orm?.config === false ? undefined : this.orm?.config;
187
+ if (ormConfig?.migrations?.alphabeticalOrder) {
188
+ return keys.sort((a, b) => a.localeCompare(b));
189
+ }
190
+ // Warning for insertion order (Git merge risks)
191
+ if (keys.length > 1 && ormConfig) {
192
+ ormConfig.logger?.warn?.(`Table "${this.options.name}": Using insertion order for string migration keys. ` +
193
+ `This may cause issues with Git merges. Consider using numeric prefixes (e.g., "001_init").`);
194
+ }
195
+ return keys; // Insertion order (ES2015+)
196
+ }
197
+ /**
198
+ * Compare migration keys for determining if one is greater than another.
199
+ * Handles both numeric and string comparisons appropriately.
200
+ */
201
+ compareMigrationKeys(a, b) {
202
+ const aIsNumeric = /^\d+$/.test(a);
203
+ const bIsNumeric = /^\d+$/.test(b);
204
+ if (aIsNumeric && bIsNumeric) {
205
+ return Number(a) - Number(b);
206
+ }
207
+ const aHasPrefix = /^\d+/.test(a);
208
+ const bHasPrefix = /^\d+/.test(b);
209
+ if (aHasPrefix && bHasPrefix) {
210
+ const aPrefix = parseInt(a.match(/^(\d+)/)?.[1] ?? "0", 10);
211
+ const bPrefix = parseInt(b.match(/^(\d+)/)?.[1] ?? "0", 10);
212
+ return aPrefix - bPrefix;
213
+ }
214
+ return a.localeCompare(b);
215
+ }
123
216
  async migrate() {
124
217
  if (!this.options.migrations)
125
218
  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]]));
219
+ const sortedKeys = this.getMigrationKeys();
220
+ if (sortedKeys.length === 0)
221
+ return false;
222
+ const migrations = this.options.migrations;
129
223
  const fromDatabase = await this.client("migration")
130
224
  .where("table", this.options.name)
131
225
  .first();
132
226
  const data = fromDatabase || {
133
227
  table: this.options.name,
134
- version: -Infinity,
228
+ version: "",
135
229
  };
136
230
  const baseVersion = data.version;
137
- for (const [version, migration] of migrations) {
231
+ for (const key of sortedKeys) {
232
+ // Skip migrations that have already been applied
233
+ if (data.version !== "" && this.compareMigrationKeys(key, data.version) <= 0) {
234
+ continue;
235
+ }
236
+ const migration = migrations[key];
138
237
  await this.client.schema.alterTable(this.options.name, (builder) => {
139
- if (version <= data.version)
140
- return;
141
- migration(builder);
142
- data.version = version;
238
+ migration.apply(builder);
143
239
  });
240
+ data.version = key;
144
241
  }
145
242
  await this.client("migration").insert(data).onConflict("table").merge();
146
243
  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.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -8,10 +8,9 @@
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 .",
13
- "check": "biome check --write .",
14
- "build": "rimraf dist && tsc",
11
+ "format": "biome format --write .",
12
+ "check": "biome check --write . && tsc --noEmit",
13
+ "build": "rimraf dist && tsc -p tsconfig.build.json",
15
14
  "test": "bun test",
16
15
  "prepublishOnly": "npm run check && npm run build && npm test"
17
16
  },
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)