@ghom/orm 1.10.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.
@@ -1,11 +1,24 @@
1
- import { Knex } from "knex";
2
- import { ORM } from "./orm.js";
3
1
  import { CachedQuery } from "@ghom/query";
2
+ import type { Knex } from "knex";
3
+ import { type ColumnDef, type InferColumns } from "./column.js";
4
+ import type { FinalTableType, TypedMigration } from "./migration.js";
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>;
4
10
  export interface MigrationData {
5
11
  table: string;
6
- version: number;
12
+ version: string;
7
13
  }
8
- export interface TableOptions<Type extends object = object> {
14
+ /**
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)
20
+ */
21
+ export interface TableOptions<Columns extends Record<string, ColumnDef<any, any>>, Migrations extends Record<string, MigrationValue> = {}> {
9
22
  name: string;
10
23
  description?: string;
11
24
  priority?: number;
@@ -14,24 +27,89 @@ export interface TableOptions<Type extends object = object> {
14
27
  * Default is `Infinity`.
15
28
  */
16
29
  caching?: number;
17
- migrations?: {
18
- [version: number]: (table: Knex.CreateTableBuilder) => void;
19
- };
20
- then?: (this: Table<Type>, table: Table<Type>) => unknown;
21
- setup: (table: Knex.CreateTableBuilder) => void;
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;
54
+ /**
55
+ * Typed columns definition with automatic type inference.
56
+ *
57
+ * @example
58
+ * columns: (col) => ({
59
+ * id: col.increments(),
60
+ * username: col.string().unique(),
61
+ * age: col.integer().nullable(),
62
+ * role: col.enum(["admin", "user"]),
63
+ * })
64
+ */
65
+ columns: (col: typeof import("./column.js").col) => Columns;
22
66
  }
23
- export declare class Table<Type extends object = object> {
24
- readonly options: TableOptions<Type>;
67
+ /**
68
+ * Represents a database table with typed columns and migrations.
69
+ *
70
+ * @example
71
+ * const userTable = new Table({
72
+ * name: "user",
73
+ * columns: (col) => ({
74
+ * id: col.increments(),
75
+ * username: col.string().unique(),
76
+ * age: col.integer().nullable(),
77
+ * }),
78
+ * })
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 }
95
+ */
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>;
25
98
  orm?: ORM;
26
99
  _whereCache?: CachedQuery<[
27
- cb: (query: Table<Type>["query"]) => unknown
100
+ cb: (query: Table<Columns, Migrations>["query"]) => unknown
28
101
  ], unknown>;
29
102
  _countCache?: CachedQuery<[where: string | null], number>;
30
- constructor(options: TableOptions<Type>);
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>;
31
109
  private requireOrm;
32
- get db(): Knex;
33
- get query(): Knex.QueryBuilder<Type, {
34
- _base: Type;
110
+ get client(): Knex;
111
+ get query(): Knex.QueryBuilder<InferColumns<Columns>, {
112
+ _base: InferColumns<Columns>;
35
113
  _hasSelection: false;
36
114
  _keys: never;
37
115
  _aliases: {};
@@ -40,17 +118,29 @@ export declare class Table<Type extends object = object> {
40
118
  _unionProps: never;
41
119
  }[]>;
42
120
  get cache(): {
43
- get: <Return>(id: string, cb: (table: Pick<Table<Type>["query"], "select" | "count" | "avg" | "sum" | "countDistinct" | "avgDistinct" | "sumDistinct">) => Return) => Return;
44
- set: <Return>(cb: (table: Pick<Table<Type>["query"], "update" | "delete" | "insert" | "upsert" | "truncate" | "jsonInsert">) => Return) => Return;
121
+ get: <Return>(id: string, cb: (table: Pick<Table<Columns>["query"], "select" | "count" | "avg" | "sum" | "countDistinct" | "avgDistinct" | "sumDistinct">) => Return) => Return;
122
+ set: <Return>(cb: (table: Pick<Table<Columns>["query"], "update" | "delete" | "insert" | "upsert" | "truncate" | "jsonInsert">) => Return) => Return;
45
123
  count: (where?: string) => Promise<number>;
46
124
  invalidate: () => void;
47
125
  };
48
126
  count(where?: string): Promise<number>;
49
- hasColumn(name: keyof Type & string): Promise<boolean>;
50
- getColumn(name: keyof Type & string): Promise<Knex.ColumnInfo>;
51
- getColumns(): Promise<Record<keyof Type & string, Knex.ColumnInfo>>;
52
- getColumnNames(): Promise<Array<keyof Type & string>>;
127
+ hasColumn(name: keyof InferColumns<Columns> & string): Promise<boolean>;
128
+ getColumn(name: keyof InferColumns<Columns> & string): Promise<Knex.ColumnInfo>;
129
+ getColumns(): Promise<Record<keyof InferColumns<Columns> & string, Knex.ColumnInfo>>;
130
+ getColumnNames(): Promise<Array<keyof InferColumns<Columns> & string>>;
53
131
  isEmpty(): Promise<boolean>;
54
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;
55
145
  private migrate;
56
146
  }
package/dist/app/table.js CHANGED
@@ -1,5 +1,35 @@
1
- import { styled } from "./util.js";
2
1
  import { CachedQuery } from "@ghom/query";
2
+ import { buildColumnsSchema, col } from "./column.js";
3
+ import { styled } from "./util.js";
4
+ /**
5
+ * Represents a database table with typed columns and migrations.
6
+ *
7
+ * @example
8
+ * const userTable = new Table({
9
+ * name: "user",
10
+ * columns: (col) => ({
11
+ * id: col.increments(),
12
+ * username: col.string().unique(),
13
+ * age: col.integer().nullable(),
14
+ * }),
15
+ * })
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 }
32
+ */
3
33
  export class Table {
4
34
  options;
5
35
  orm;
@@ -11,15 +41,15 @@ export class Table {
11
41
  requireOrm() {
12
42
  if (!this.orm)
13
43
  throw new Error("missing ORM");
14
- if (!this.orm.client)
44
+ if (!this.orm._client)
15
45
  throw new Error("ORM client is not initialized");
16
46
  }
17
- get db() {
47
+ get client() {
18
48
  this.requireOrm();
19
49
  return this.orm.client;
20
50
  }
21
51
  get query() {
22
- return this.db(this.options.name);
52
+ return this.client(this.options.name);
23
53
  }
24
54
  get cache() {
25
55
  if (!this._whereCache || !this._countCache)
@@ -46,18 +76,18 @@ export class Table {
46
76
  }
47
77
  async count(where) {
48
78
  return this.query
49
- .select(this.db.raw("count(*) as total"))
79
+ .select(this.client.raw("count(*) as total"))
50
80
  .whereRaw(where ?? "1=1")
51
81
  .then((rows) => +(rows?.[0] ?? { total: 0 }).total);
52
82
  }
53
83
  async hasColumn(name) {
54
- return this.db.schema.hasColumn(this.options.name, name);
84
+ return this.client.schema.hasColumn(this.options.name, name);
55
85
  }
56
86
  async getColumn(name) {
57
- return this.db(this.options.name).columnInfo(name);
87
+ return this.client(this.options.name).columnInfo(name);
58
88
  }
59
89
  async getColumns() {
60
- return this.db(this.options.name).columnInfo();
90
+ return this.client(this.options.name).columnInfo();
61
91
  }
62
92
  async getColumnNames() {
63
93
  return this.getColumns().then(Object.keys);
@@ -74,12 +104,15 @@ export class Table {
74
104
  ? ` ${styled(this.orm, this.options.description, "description")}`
75
105
  : ""}`;
76
106
  try {
77
- await this.db.schema.createTable(this.options.name, this.options.setup);
107
+ await this.client.schema.createTable(this.options.name, (builder) => {
108
+ const columns = this.options.columns(col);
109
+ buildColumnsSchema(builder, columns);
110
+ });
78
111
  this.orm.config.logger?.log(`created table ${tableNameLog}`);
79
112
  }
80
113
  catch (error) {
81
114
  if (error.toString().includes("syntax error")) {
82
- this.orm.config.logger?.error(`you need to implement the "setup" method in options of your ${styled(this.orm, this.options.name, "highlight")} table!`);
115
+ this.orm.config.logger?.error(`you need to implement the "columns" callback in options of your ${styled(this.orm, this.options.name, "highlight")} table!`);
83
116
  throw error;
84
117
  }
85
118
  else {
@@ -96,36 +129,106 @@ export class Table {
96
129
  this.orm.config.logger?.error(error);
97
130
  throw error;
98
131
  }
99
- if ((await this.count()) === 0)
100
- await this.options.then?.bind(this)(this);
132
+ if ((await this.count()) === 0) {
133
+ const thenFn = this.options.then;
134
+ await thenFn?.bind(this)(this);
135
+ }
101
136
  return this;
102
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
+ }
103
198
  async migrate() {
104
199
  if (!this.options.migrations)
105
200
  return false;
106
- const migrations = new Map(Object.entries(this.options.migrations)
107
- .sort((a, b) => Number(a[0]) - Number(b[0]))
108
- .map((entry) => [Number(entry[0]), entry[1]]));
109
- const fromDatabase = await this.db("migration")
201
+ const sortedKeys = this.getMigrationKeys();
202
+ if (sortedKeys.length === 0)
203
+ return false;
204
+ const migrations = this.options.migrations;
205
+ const fromDatabase = await this.client("migration")
110
206
  .where("table", this.options.name)
111
207
  .first();
112
208
  const data = fromDatabase || {
113
209
  table: this.options.name,
114
- version: -Infinity,
210
+ version: "",
115
211
  };
116
212
  const baseVersion = data.version;
117
- for (const [version, migration] of migrations) {
118
- await this.db.schema.alterTable(this.options.name, (builder) => {
119
- if (version <= data.version)
120
- return;
121
- migration(builder);
122
- data.version = version;
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];
219
+ 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
+ }
123
228
  });
229
+ data.version = key;
124
230
  }
125
- await this.db("migration")
126
- .insert(data)
127
- .onConflict("table")
128
- .merge();
231
+ await this.client("migration").insert(data).onConflict("table").merge();
129
232
  return baseVersion === data.version ? false : data.version;
130
233
  }
131
234
  }
@@ -4,7 +4,7 @@ export type TextStyle = Parameters<typeof util.styleText>[0];
4
4
  export declare const DEFAULT_BACKUP_LOCATION: string;
5
5
  export declare const DEFAULT_BACKUP_CHUNK_SIZE: number;
6
6
  export declare const DEFAULT_LOGGER_HIGHLIGHT = "blueBright";
7
- export declare const DEFAULT_LOGGER_DESCRIPTION = "grey";
7
+ export declare const DEFAULT_LOGGER_DESCRIPTION = "gray";
8
8
  export declare const DEFAULT_LOGGER_RAW_VALUE = "magentaBright";
9
9
  declare let isCJS: boolean;
10
10
  export { isCJS };
package/dist/app/util.js CHANGED
@@ -1,15 +1,15 @@
1
- import util from "node:util";
2
- import path from "node:path";
3
1
  import fs from "node:fs";
2
+ import path from "node:path";
3
+ import util from "node:util";
4
4
  export const DEFAULT_BACKUP_LOCATION = path.join(process.cwd(), "backup");
5
5
  export const DEFAULT_BACKUP_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
6
6
  export const DEFAULT_LOGGER_HIGHLIGHT = "blueBright";
7
- export const DEFAULT_LOGGER_DESCRIPTION = "grey";
7
+ export const DEFAULT_LOGGER_DESCRIPTION = "gray";
8
8
  export const DEFAULT_LOGGER_RAW_VALUE = "magentaBright";
9
9
  let isCJS = false;
10
10
  try {
11
11
  const pack = JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
12
- isCJS = pack.type === "commonjs" || pack.type == void 0;
12
+ isCJS = pack.type === "commonjs" || pack.type === void 0;
13
13
  }
14
14
  catch {
15
15
  throw new Error("Missing package.json: Can't detect the type of modules.\n" +
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
+ export * from "./app/backup.js";
2
+ export * from "./app/column.js";
3
+ export * from "./app/migration.js";
1
4
  export * from "./app/orm.js";
2
5
  export * from "./app/table.js";
3
- export * from "./app/backup.js";
4
6
  export * from "./app/util.js";
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
+ export * from "./app/backup.js";
2
+ export * from "./app/column.js";
3
+ export * from "./app/migration.js";
1
4
  export * from "./app/orm.js";
2
5
  export * from "./app/table.js";
3
- export * from "./app/backup.js";
4
6
  export * from "./app/util.js";
package/package.json CHANGED
@@ -1,47 +1,42 @@
1
- {
2
- "name": "@ghom/orm",
3
- "version": "1.10.0",
4
- "license": "MIT",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
8
- "description": "TypeScript KnexJS ORM & handler",
9
- "homepage": "https://github.com/GhomKrosmonaute/orm",
10
- "prettier": {
11
- "semi": false
12
- },
13
- "scripts": {
14
- "format": "prettier --write src tsconfig.json tests",
15
- "build": "rimraf dist && tsc",
16
- "test": "npm run build && node --experimental-vm-modules node_modules/jest/bin/jest.js tests/test.js --detectOpenHandles",
17
- "prepublishOnly": "npm run format && npm test"
18
- },
19
- "devDependencies": {
20
- "@types/jest": "^29.5.6",
21
- "@types/node": "^22.0.0",
22
- "dotenv": "^16.3.1",
23
- "jest": "^29.7.0",
24
- "prettier": "^3.0.3",
25
- "rimraf": "^6.0.1",
26
- "typescript": "^5.2.2"
27
- },
28
- "optionalDependencies": {
29
- "mysql2": "^3.6.2",
30
- "pg": "^8.11.3",
31
- "sqlite3": "^5.1.6"
32
- },
33
- "dependencies": {
34
- "@ghom/handler": "^3.1.0",
35
- "@ghom/query": "1.0.0",
36
- "csv-parser": "^3.0.0",
37
- "json-2-csv": "^5.5.6",
38
- "knex": "^3.0.1"
39
- },
40
- "engines": {
41
- "node": ">=22.0.0"
42
- },
43
- "repository": {
44
- "url": "https://github.com/GhomKrosmonaute/orm.git",
45
- "type": "git"
46
- }
47
- }
1
+ {
2
+ "name": "@ghom/orm",
3
+ "version": "2.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "description": "TypeScript KnexJS ORM & handler",
9
+ "homepage": "https://github.com/GhomKrosmonaute/orm",
10
+ "scripts": {
11
+ "format": "biome format --write .",
12
+ "check": "biome check --write .",
13
+ "build": "rimraf dist && tsc",
14
+ "test": "bun test",
15
+ "prepublishOnly": "npm run check && npm run build && npm test"
16
+ },
17
+ "devDependencies": {
18
+ "@biomejs/biome": "^2.3.13",
19
+ "@types/bun": "^1.1.0",
20
+ "rimraf": "^6.0.1",
21
+ "typescript": "^5.2.2"
22
+ },
23
+ "optionalDependencies": {
24
+ "mysql2": "^3.6.2",
25
+ "pg": "^8.11.3",
26
+ "sqlite3": "^5.1.6"
27
+ },
28
+ "dependencies": {
29
+ "@ghom/handler": "^3.1.0",
30
+ "@ghom/query": "1.0.0",
31
+ "csv-parser": "^3.0.0",
32
+ "json-2-csv": "^5.5.6",
33
+ "knex": "^3.0.1"
34
+ },
35
+ "engines": {
36
+ "node": ">=22.0.0"
37
+ },
38
+ "repository": {
39
+ "url": "https://github.com/GhomKrosmonaute/orm.git",
40
+ "type": "git"
41
+ }
42
+ }