@xacos/orm 1.0.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.
Files changed (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/dist/XModel.d.ts +42 -0
  4. package/dist/XModel.d.ts.map +1 -0
  5. package/dist/XModel.js +240 -0
  6. package/dist/XModel.js.map +1 -0
  7. package/dist/XModel.test.d.ts +2 -0
  8. package/dist/XModel.test.d.ts.map +1 -0
  9. package/dist/XModel.test.js +119 -0
  10. package/dist/XModel.test.js.map +1 -0
  11. package/dist/XMongoModel.d.ts +25 -0
  12. package/dist/XMongoModel.d.ts.map +1 -0
  13. package/dist/XMongoModel.js +86 -0
  14. package/dist/XMongoModel.js.map +1 -0
  15. package/dist/XMongoModel.test.d.ts +2 -0
  16. package/dist/XMongoModel.test.d.ts.map +1 -0
  17. package/dist/XMongoModel.test.js +14 -0
  18. package/dist/XMongoModel.test.js.map +1 -0
  19. package/dist/__tests__/sqlite.integration.test.d.ts +2 -0
  20. package/dist/__tests__/sqlite.integration.test.d.ts.map +1 -0
  21. package/dist/__tests__/sqlite.integration.test.js +106 -0
  22. package/dist/__tests__/sqlite.integration.test.js.map +1 -0
  23. package/dist/connection/db.d.ts +25 -0
  24. package/dist/connection/db.d.ts.map +1 -0
  25. package/dist/connection/db.js +100 -0
  26. package/dist/connection/db.js.map +1 -0
  27. package/dist/connection/drivers.d.ts +3 -0
  28. package/dist/connection/drivers.d.ts.map +1 -0
  29. package/dist/connection/drivers.js +58 -0
  30. package/dist/connection/drivers.js.map +1 -0
  31. package/dist/connection/mongoUri.d.ts +6 -0
  32. package/dist/connection/mongoUri.d.ts.map +1 -0
  33. package/dist/connection/mongoUri.js +22 -0
  34. package/dist/connection/mongoUri.js.map +1 -0
  35. package/dist/decorators.d.ts +47 -0
  36. package/dist/decorators.d.ts.map +1 -0
  37. package/dist/decorators.js +149 -0
  38. package/dist/decorators.js.map +1 -0
  39. package/dist/factories/Factory.d.ts +34 -0
  40. package/dist/factories/Factory.d.ts.map +1 -0
  41. package/dist/factories/Factory.js +48 -0
  42. package/dist/factories/Factory.js.map +1 -0
  43. package/dist/factories/Factory.test.d.ts +2 -0
  44. package/dist/factories/Factory.test.d.ts.map +1 -0
  45. package/dist/factories/Factory.test.js +16 -0
  46. package/dist/factories/Factory.test.js.map +1 -0
  47. package/dist/index.d.ts +16 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +16 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/migrations/Migration.d.ts +6 -0
  52. package/dist/migrations/Migration.d.ts.map +1 -0
  53. package/dist/migrations/Migration.js +3 -0
  54. package/dist/migrations/Migration.js.map +1 -0
  55. package/dist/migrations/MigrationRunner.d.ts +14 -0
  56. package/dist/migrations/MigrationRunner.d.ts.map +1 -0
  57. package/dist/migrations/MigrationRunner.js +142 -0
  58. package/dist/migrations/MigrationRunner.js.map +1 -0
  59. package/dist/migrations/MigrationRunner.test.d.ts +2 -0
  60. package/dist/migrations/MigrationRunner.test.d.ts.map +1 -0
  61. package/dist/migrations/MigrationRunner.test.js +26 -0
  62. package/dist/migrations/MigrationRunner.test.js.map +1 -0
  63. package/dist/migrations/Schema.d.ts +7 -0
  64. package/dist/migrations/Schema.d.ts.map +1 -0
  65. package/dist/migrations/Schema.js +17 -0
  66. package/dist/migrations/Schema.js.map +1 -0
  67. package/dist/migrations/columnHelpers.d.ts +9 -0
  68. package/dist/migrations/columnHelpers.d.ts.map +1 -0
  69. package/dist/migrations/columnHelpers.js +15 -0
  70. package/dist/migrations/columnHelpers.js.map +1 -0
  71. package/dist/seeders/Seeder.d.ts +8 -0
  72. package/dist/seeders/Seeder.d.ts.map +1 -0
  73. package/dist/seeders/Seeder.js +3 -0
  74. package/dist/seeders/Seeder.js.map +1 -0
  75. package/dist/seeders/SeederRunner.d.ts +20 -0
  76. package/dist/seeders/SeederRunner.d.ts.map +1 -0
  77. package/dist/seeders/SeederRunner.js +68 -0
  78. package/dist/seeders/SeederRunner.js.map +1 -0
  79. package/dist/seeders/SeederRunner.test.d.ts +2 -0
  80. package/dist/seeders/SeederRunner.test.d.ts.map +1 -0
  81. package/dist/seeders/SeederRunner.test.js +44 -0
  82. package/dist/seeders/SeederRunner.test.js.map +1 -0
  83. package/dist/utils/paginate.d.ts +14 -0
  84. package/dist/utils/paginate.d.ts.map +1 -0
  85. package/dist/utils/paginate.js +28 -0
  86. package/dist/utils/paginate.js.map +1 -0
  87. package/dist/utils/paginate.test.d.ts +2 -0
  88. package/dist/utils/paginate.test.d.ts.map +1 -0
  89. package/dist/utils/paginate.test.js +23 -0
  90. package/dist/utils/paginate.test.js.map +1 -0
  91. package/package.json +75 -0
  92. package/src/XModel.test.ts +147 -0
  93. package/src/XModel.ts +301 -0
  94. package/src/XMongoModel.test.ts +16 -0
  95. package/src/XMongoModel.ts +119 -0
  96. package/src/__tests__/sqlite.integration.test.ts +116 -0
  97. package/src/connection/db.ts +127 -0
  98. package/src/connection/drivers.ts +65 -0
  99. package/src/connection/mongoUri.ts +25 -0
  100. package/src/decorators.ts +200 -0
  101. package/src/factories/Factory.test.ts +18 -0
  102. package/src/factories/Factory.ts +61 -0
  103. package/src/index.ts +18 -0
  104. package/src/migrations/Migration.ts +8 -0
  105. package/src/migrations/MigrationRunner.test.ts +33 -0
  106. package/src/migrations/MigrationRunner.ts +171 -0
  107. package/src/migrations/Schema.ts +20 -0
  108. package/src/migrations/columnHelpers.ts +28 -0
  109. package/src/seeders/Seeder.ts +8 -0
  110. package/src/seeders/SeederRunner.test.ts +62 -0
  111. package/src/seeders/SeederRunner.ts +76 -0
  112. package/src/types/bun-test.d.ts +8 -0
  113. package/src/utils/paginate.test.ts +24 -0
  114. package/src/utils/paginate.ts +37 -0
@@ -0,0 +1,14 @@
1
+ import type { Knex } from 'knex';
2
+ import type { PaginatedResult } from '@xacos/shared';
3
+ /**
4
+ * Paginate any Knex query builder.
5
+ *
6
+ * @example
7
+ * const result = await paginate(
8
+ * User.query().where('role', 'admin'),
9
+ * req.query.page,
10
+ * 15
11
+ * );
12
+ */
13
+ export declare function paginate<T>(query: Knex.QueryBuilder, page?: number | string, perPage?: number | string): Promise<PaginatedResult<T>>;
14
+ //# sourceMappingURL=paginate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paginate.d.ts","sourceRoot":"","sources":["../../src/utils/paginate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AACjC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAErD;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAAC,CAAC,EAC9B,KAAK,EAAE,IAAI,CAAC,YAAY,EACxB,IAAI,GAAE,MAAM,GAAG,MAAU,EACzB,OAAO,GAAE,MAAM,GAAG,MAAW,GAC5B,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,CAmB7B"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Paginate any Knex query builder.
3
+ *
4
+ * @example
5
+ * const result = await paginate(
6
+ * User.query().where('role', 'admin'),
7
+ * req.query.page,
8
+ * 15
9
+ * );
10
+ */
11
+ export async function paginate(query, page = 1, perPage = 15) {
12
+ const p = Math.max(1, Number(page));
13
+ const pp = Math.min(100, Math.max(1, Number(perPage))); // cap at 100
14
+ const offset = (p - 1) * pp;
15
+ // Clone the query for count — without order/limit
16
+ const countQuery = query.clone().clearOrder().clearSelect().count('* as total').first();
17
+ const countResult = await countQuery;
18
+ const total = Number(countResult?.total ?? 0);
19
+ const data = await query.offset(offset).limit(pp);
20
+ return {
21
+ data,
22
+ total,
23
+ page: p,
24
+ perPage: pp,
25
+ lastPage: Math.ceil(total / pp),
26
+ };
27
+ }
28
+ //# sourceMappingURL=paginate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paginate.js","sourceRoot":"","sources":["../../src/utils/paginate.ts"],"names":[],"mappings":"AAGA;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAC5B,KAAwB,EACxB,OAAwB,CAAC,EACzB,UAA2B,EAAE;IAE7B,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;IACpC,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,aAAa;IACrE,MAAM,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;IAE5B,kDAAkD;IAClD,MAAM,UAAU,GAAG,KAAK,CAAC,KAAK,EAAE,CAAC,UAAU,EAAE,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,KAAK,EAAE,CAAC;IACxF,MAAM,WAAW,GAAG,MAAM,UAAwC,CAAC;IACnE,MAAM,KAAK,GAAG,MAAM,CAAC,WAAW,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC;IAE9C,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,KAAK,CAAC,EAAE,CAAQ,CAAC;IAEzD,OAAO;QACL,IAAI;QACJ,KAAK;QACL,IAAI,EAAE,CAAC;QACP,OAAO,EAAE,EAAE;QACX,QAAQ,EAAE,IAAI,CAAC,IAAI,CAAC,KAAK,GAAG,EAAE,CAAC;KAChC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=paginate.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paginate.test.d.ts","sourceRoot":"","sources":["../../src/utils/paginate.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { paginate } from "./paginate";
3
+ describe("paginate", () => {
4
+ it("caps perPage at 100 and ensures page is at least 1", async () => {
5
+ const mockQuery = {
6
+ clone: () => mockQuery,
7
+ clearOrder: () => mockQuery,
8
+ clearSelect: () => mockQuery,
9
+ count: () => mockQuery,
10
+ first: async () => ({ total: 50 }),
11
+ offset: (off) => ({
12
+ limit: async (lim) => [{ id: 1, off, lim }],
13
+ }),
14
+ };
15
+ const res = await paginate(mockQuery, -5, 999);
16
+ expect(res.page).toBe(1);
17
+ expect(res.perPage).toBe(100);
18
+ expect(res.total).toBe(50);
19
+ expect(res.lastPage).toBe(1);
20
+ expect(res.data).toEqual([{ id: 1, off: 0, lim: 100 }]);
21
+ });
22
+ });
23
+ //# sourceMappingURL=paginate.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paginate.test.js","sourceRoot":"","sources":["../../src/utils/paginate.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,UAAU,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEtC,QAAQ,CAAC,UAAU,EAAE,GAAG,EAAE;IACxB,EAAE,CAAC,oDAAoD,EAAE,KAAK,IAAI,EAAE;QAClE,MAAM,SAAS,GAAG;YAChB,KAAK,EAAE,GAAG,EAAE,CAAC,SAAS;YACtB,UAAU,EAAE,GAAG,EAAE,CAAC,SAAS;YAC3B,WAAW,EAAE,GAAG,EAAE,CAAC,SAAS;YAC5B,KAAK,EAAE,GAAG,EAAE,CAAC,SAAS;YACtB,KAAK,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;YAClC,MAAM,EAAE,CAAC,GAAW,EAAE,EAAE,CAAC,CAAC;gBACxB,KAAK,EAAE,KAAK,EAAE,GAAW,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC;aACpD,CAAC;SACH,CAAC;QAEF,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,SAAgB,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;QACtD,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACzB,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC9B,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC3B,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC7B,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,CAAQ,CAAC,CAAC;IACjE,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@xacos/orm",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.json",
15
+ "test": "bun test src --seq",
16
+ "test:integration": "DB_DRIVER=sqlite DB_PATH=./tmp-integration.sqlite bun test src/__tests__/sqlite.integration.test.ts",
17
+ "type-check": "tsc --noEmit -p tsconfig.json"
18
+ },
19
+ "dependencies": {
20
+ "@faker-js/faker": "^10.4.0",
21
+ "@xacos/shared": "workspace:*",
22
+ "knex": "^3.1.0",
23
+ "mongoose": "^8.10.1",
24
+ "sqlite3": "^5.1.7"
25
+ },
26
+ "optionalDependencies": {
27
+ "mysql2": "^3.11.5",
28
+ "pg": "^8.13.1"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.15.3",
32
+ "bun-types": "^1.3.12"
33
+ },
34
+ "license": "MIT",
35
+ "author": "XAOCS Team",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/zoherr/xaocs.git",
39
+ "directory": "packages/orm"
40
+ },
41
+ "homepage": "https://xaocs.dev",
42
+ "bugs": {
43
+ "url": "https://github.com/zoherr/xaocs/issues"
44
+ },
45
+ "keywords": [
46
+ "xaocs",
47
+ "xacos",
48
+ "framework",
49
+ "typescript",
50
+ "fullstack",
51
+ "fastify",
52
+ "react",
53
+ "orm",
54
+ "knex",
55
+ "sqlite",
56
+ "postgres",
57
+ "mysql",
58
+ "active-record"
59
+ ],
60
+ "files": [
61
+ "dist",
62
+ "src",
63
+ "README.md",
64
+ "LICENSE"
65
+ ],
66
+ "publishConfig": {
67
+ "access": "public",
68
+ "registry": "https://registry.npmjs.org",
69
+ "provenance": false
70
+ },
71
+ "engines": {
72
+ "bun": ">=1.0.0",
73
+ "node": ">=18.0.0"
74
+ }
75
+ }
@@ -0,0 +1,147 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
2
+ import { registerColumnMeta, registerRelation } from "./decorators";
3
+ import { closeDb, getKnex, initDb } from "./index";
4
+ import { XModel } from "./XModel";
5
+
6
+ class Post extends XModel {
7
+ static override table = "posts";
8
+ }
9
+
10
+ class User extends XModel {
11
+ static override table = "users";
12
+ posts?: unknown;
13
+ }
14
+
15
+ registerRelation(User, {
16
+ kind: "hasMany",
17
+ related: () => Post,
18
+ foreignKey: "user_id",
19
+ propertyKey: "posts",
20
+ });
21
+
22
+ class UserSoft extends XModel {
23
+ static override table = "users";
24
+ static override softDeletes = true;
25
+ }
26
+
27
+ class Profile extends XModel {
28
+ static override table = "profiles";
29
+ secret?: string;
30
+ }
31
+
32
+ registerColumnMeta(Profile, "secret", { hidden: true });
33
+
34
+ describe("XModel", () => {
35
+ beforeAll(async () => {
36
+ initDb({ __memorySqlite: true });
37
+ const knex = getKnex();
38
+
39
+ await knex.schema.dropTableIfExists("posts");
40
+ await knex.schema.dropTableIfExists("profiles");
41
+ await knex.schema.dropTableIfExists("users");
42
+
43
+ await knex.schema.createTable("users", (table) => {
44
+ table.increments("id").primary();
45
+ table.string("name", 255).notNullable();
46
+ table.timestamp("created_at").defaultTo(knex.fn.now());
47
+ table.timestamp("updated_at").defaultTo(knex.fn.now());
48
+ table.timestamp("deleted_at").nullable();
49
+ });
50
+
51
+ await knex.schema.createTable("posts", (table) => {
52
+ table.increments("id").primary();
53
+ table.integer("user_id").unsigned().notNullable();
54
+ table.string("title", 255).notNullable();
55
+ table.timestamp("created_at").defaultTo(knex.fn.now());
56
+ table.timestamp("updated_at").defaultTo(knex.fn.now());
57
+ });
58
+
59
+ await knex.schema.createTable("profiles", (table) => {
60
+ table.increments("id").primary();
61
+ table.string("secret", 255).notNullable();
62
+ table.timestamp("created_at").defaultTo(knex.fn.now());
63
+ table.timestamp("updated_at").defaultTo(knex.fn.now());
64
+ });
65
+ });
66
+
67
+ afterAll(async () => {
68
+ await closeDb();
69
+ });
70
+
71
+ it("creates, finds, and lists rows", async () => {
72
+ const created = await User.create({ name: "alice" });
73
+ expect(created.name).toBe("alice");
74
+
75
+ const found = await User.find(created.id as number);
76
+ expect(found?.name).toBe("alice");
77
+
78
+ const all = await User.all();
79
+ expect(all.length).toBeGreaterThan(0);
80
+ });
81
+
82
+ it("throws findOrFail when missing", async () => {
83
+ await expect(User.findOrFail(9_007_199_254_740_991)).rejects.toThrow("Not found");
84
+ });
85
+
86
+ it("supports where filters", async () => {
87
+ const rows = await User.where("name", "alice").select();
88
+ expect(rows.length).toBeGreaterThan(0);
89
+ });
90
+
91
+ it("updates rows via static update and instance save", async () => {
92
+ const created = await User.create({ name: "bob" });
93
+ await User.update(created.id as number, { name: "bobby" });
94
+ const updated = await User.find(created.id as number);
95
+ expect(updated?.name).toBe("bobby");
96
+
97
+ const instance = Object.assign(new User(), updated);
98
+ await instance.save();
99
+ const reloaded = await User.find(created.id as number);
100
+ expect(reloaded?.name).toBe("bobby");
101
+ });
102
+
103
+ it("destroys rows", async () => {
104
+ const created = await User.create({ name: "tmp" });
105
+ await User.destroy(created.id as number);
106
+ expect(await User.find(created.id as number)).toBeNull();
107
+ });
108
+
109
+ it("paginates with the shared envelope shape", async () => {
110
+ const page = await User.paginate(1, 5);
111
+ expect(page.data.length).toBeGreaterThan(0);
112
+ expect(page.total).toBeGreaterThan(0);
113
+ expect(page.page).toBe(1);
114
+ expect(page.perPage).toBe(5);
115
+ expect(page.lastPage).toBeGreaterThan(0);
116
+ });
117
+
118
+ it("eager-loads hasMany relations", async () => {
119
+ const unique = `owner-${Math.random().toString(16).slice(2)}`;
120
+ const owner = await User.create({ name: unique });
121
+ await Post.create({ user_id: owner.id, title: "hello" });
122
+
123
+ const rows = await User.with("posts").where("name", unique).get();
124
+ expect(rows[0]?.posts).toBeDefined();
125
+ expect(Array.isArray(rows[0]?.posts)).toBe(true);
126
+ expect((rows[0]?.posts as { title: string }[])[0]?.title).toBe("hello");
127
+ });
128
+
129
+ it("applies soft deletes", async () => {
130
+ const created = await UserSoft.create({ name: "ghost" });
131
+ const instance = Object.assign(new UserSoft(), created);
132
+ await instance.delete();
133
+
134
+ expect(await UserSoft.find(created.id as number)).toBeNull();
135
+ const trashed = await UserSoft.withTrashed().where("id", created.id as number).first();
136
+ expect(trashed).not.toBeNull();
137
+ });
138
+
139
+ it("strips hidden columns from toJSON", async () => {
140
+ const created = await Profile.create({ secret: "hide-me" });
141
+ const instance = Object.assign(new Profile(), created);
142
+ const json = instance.toJSON();
143
+ expect(json.secret).toBeUndefined();
144
+ expect(json.id).toBeDefined();
145
+ });
146
+ });
147
+
package/src/XModel.ts ADDED
@@ -0,0 +1,301 @@
1
+ import type { Knex } from "knex";
2
+ import type { PaginatedResult } from "@xacos/shared";
3
+ import { getColumnOptions, getRelations, type ModelConstructor, type RelationMeta } from "./decorators";
4
+ import { getKnex } from "./connection/db";
5
+
6
+ async function resolveInsertedId(
7
+ knex: Knex,
8
+ table: string,
9
+ insertResult: unknown,
10
+ payload: Record<string, unknown>,
11
+ ): Promise<number | string> {
12
+ if (Array.isArray(insertResult)) {
13
+ const first = insertResult[0];
14
+ if (typeof first === "number" || typeof first === "string") {
15
+ return first;
16
+ }
17
+ if (first && typeof first === "object" && "id" in first) {
18
+ return (first as { id: number | string }).id;
19
+ }
20
+ }
21
+ if (typeof insertResult === "number" || typeof insertResult === "string") {
22
+ return insertResult;
23
+ }
24
+ if (payload.id != null) {
25
+ return payload.id as number | string;
26
+ }
27
+
28
+ const row = await knex(table).select<{ id: number | string }>("id").orderBy("id", "desc").first();
29
+ if (!row) {
30
+ throw new Error("[XAOCS ORM] Unable to resolve inserted primary key");
31
+ }
32
+ return row.id;
33
+ }
34
+
35
+ async function attachHasMany(
36
+ rows: Record<string, unknown>[],
37
+ propertyKey: string,
38
+ Related: ModelConstructor,
39
+ foreignKey: string,
40
+ ): Promise<void> {
41
+ if (rows.length === 0) return;
42
+
43
+ const ids = rows
44
+ .map((row) => row.id)
45
+ .filter((id): id is number | string => id !== undefined && id !== null);
46
+
47
+ if (ids.length === 0) return;
48
+
49
+ const relatedModel = Related as unknown as { query(): Knex.QueryBuilder };
50
+ const relatedRows = (await relatedModel.query().whereIn(foreignKey, ids).select()) as Record<string, unknown>[];
51
+ const grouped = new Map<number | string, Record<string, unknown>[]>();
52
+
53
+ for (const rel of relatedRows) {
54
+ const fk = rel[foreignKey] as number | string;
55
+ const bucket = grouped.get(fk) ?? [];
56
+ bucket.push(rel);
57
+ grouped.set(fk, bucket);
58
+ }
59
+
60
+ for (const row of rows) {
61
+ const id = row.id as number | string;
62
+ row[propertyKey] = grouped.get(id) ?? [];
63
+ }
64
+ }
65
+
66
+ export class RelationScope {
67
+ private base: Knex.QueryBuilder;
68
+
69
+ constructor(
70
+ private readonly Model: typeof XModel,
71
+ private readonly relations: readonly string[],
72
+ base: Knex.QueryBuilder,
73
+ ) {
74
+ this.base = base;
75
+ }
76
+
77
+ where(column: string, value: unknown): this {
78
+ this.base = this.base.clone().where(column, value as Knex.Value);
79
+ return this;
80
+ }
81
+
82
+ whereIn(column: string, values: readonly unknown[]): this {
83
+ this.base = this.base.clone().whereIn(column, values as readonly Knex.Value[]);
84
+ return this;
85
+ }
86
+
87
+ orderBy(column: string, direction: "asc" | "desc" = "asc"): this {
88
+ this.base = this.base.clone().orderBy(column, direction);
89
+ return this;
90
+ }
91
+
92
+ limit(value: number): this {
93
+ this.base = this.base.clone().limit(value);
94
+ return this;
95
+ }
96
+
97
+ offset(value: number): this {
98
+ this.base = this.base.clone().offset(value);
99
+ return this;
100
+ }
101
+
102
+ async first(): Promise<Record<string, unknown> | null> {
103
+ const row = (await this.base.clone().first()) as Record<string, unknown> | undefined;
104
+ if (!row) return null;
105
+ const rows = [row];
106
+ await this.Model.hydrateRelations(rows, this.relations);
107
+ return rows[0] ?? null;
108
+ }
109
+
110
+ async get(): Promise<Record<string, unknown>[]> {
111
+ const rows = (await this.base.clone().select()) as Record<string, unknown>[];
112
+ await this.Model.hydrateRelations(rows, this.relations);
113
+ return rows;
114
+ }
115
+ }
116
+
117
+ export abstract class XModel {
118
+ static table = "";
119
+ static softDeletes = false;
120
+
121
+ static query(): Knex.QueryBuilder {
122
+ const knex = getKnex();
123
+ const Model = this as typeof XModel;
124
+ if (!Model.table) {
125
+ throw new Error("[XAOCS ORM] static table must be set on the model");
126
+ }
127
+
128
+ let builder = knex(Model.table);
129
+ if (Model.softDeletes) {
130
+ builder = builder.whereNull("deleted_at");
131
+ }
132
+ return builder;
133
+ }
134
+
135
+ static withTrashed(): Knex.QueryBuilder {
136
+ const Model = this as typeof XModel;
137
+ if (!Model.table) {
138
+ throw new Error("[XAOCS ORM] static table must be set on the model");
139
+ }
140
+ return getKnex()(Model.table);
141
+ }
142
+
143
+ /**
144
+ * @security WARNING: Never pass user input directly to raw().
145
+ * Always use parameterized bindings: db.raw('WHERE id = ?', [userInput])
146
+ */
147
+ static raw(sql: string, bindings?: readonly Knex.RawBinding[]): Knex.Raw {
148
+ return getKnex().raw(sql, bindings ?? []);
149
+ }
150
+
151
+ static with(...relations: string[]): RelationScope {
152
+ const Model = this as typeof XModel;
153
+ return new RelationScope(Model, relations, Model.query());
154
+ }
155
+
156
+ static async hydrateRelations(rows: Record<string, unknown>[], relations: readonly string[]): Promise<void> {
157
+ const ctor = this as typeof XModel;
158
+ const metas = getRelations(ctor);
159
+
160
+ for (const relationName of relations) {
161
+ const meta = metas.find((m: RelationMeta) => m.propertyKey === relationName);
162
+ if (!meta) continue;
163
+
164
+ if (meta.kind === "hasMany") {
165
+ const Related = meta.related();
166
+ await attachHasMany(rows, meta.propertyKey, Related, meta.foreignKey);
167
+ }
168
+ }
169
+ }
170
+
171
+ static async find(id: number | string): Promise<Record<string, unknown> | null> {
172
+ const row = await (this as typeof XModel).query().where("id", id).first();
173
+ return row ? (row as Record<string, unknown>) : null;
174
+ }
175
+
176
+ static async findOrFail(id: number | string): Promise<Record<string, unknown>> {
177
+ const row = await (this as typeof XModel).find(id);
178
+ if (!row) {
179
+ const err = new Error("Not found");
180
+ (err as Error & { statusCode: number }).statusCode = 404;
181
+ throw err;
182
+ }
183
+ return row;
184
+ }
185
+
186
+ static async all(): Promise<Record<string, unknown>[]> {
187
+ const rows = await (this as typeof XModel).query().select();
188
+ return rows as Record<string, unknown>[];
189
+ }
190
+
191
+ static where(column: string, value: unknown): Knex.QueryBuilder;
192
+ static where(criteria: Record<string, unknown>): Knex.QueryBuilder;
193
+ static where(column: string | Record<string, unknown>, value?: unknown): Knex.QueryBuilder {
194
+ const Model = this as typeof XModel;
195
+ if (typeof column === "string") {
196
+ return Model.query().where(column, value as Knex.Value);
197
+ }
198
+ return Model.query().where(column);
199
+ }
200
+
201
+ static async create(data: Record<string, unknown>): Promise<Record<string, unknown>> {
202
+ const Model = this as typeof XModel;
203
+ const knex = getKnex();
204
+ const now = new Date();
205
+ const payload: Record<string, unknown> = {
206
+ ...data,
207
+ created_at: data.created_at ?? now,
208
+ updated_at: data.updated_at ?? now,
209
+ };
210
+
211
+ const insertResult = await knex(Model.table).insert(payload);
212
+ const id = await resolveInsertedId(knex, Model.table, insertResult, payload);
213
+ const created = await Model.find(id);
214
+ if (!created) {
215
+ throw new Error("[XAOCS ORM] Row missing after insert");
216
+ }
217
+ return created;
218
+ }
219
+
220
+ static async update(id: number | string, data: Record<string, unknown>): Promise<void> {
221
+ const Model = this as typeof XModel;
222
+ await Model.withTrashed().where("id", id).update({ ...data, updated_at: new Date() });
223
+ }
224
+
225
+ static async destroy(id: number | string): Promise<void> {
226
+ const Model = this as typeof XModel;
227
+ const existing = await Model.withTrashed().where("id", id).first();
228
+ if (!existing) return;
229
+
230
+ if (Model.softDeletes) {
231
+ await Model.withTrashed().where("id", id).update({ deleted_at: new Date(), updated_at: new Date() });
232
+ return;
233
+ }
234
+
235
+ await Model.withTrashed().where("id", id).del();
236
+ }
237
+
238
+ static async paginate(page = 1, perPage = 15): Promise<PaginatedResult<Record<string, unknown>>> {
239
+ const Model = this as typeof XModel;
240
+ const offset = (page - 1) * perPage;
241
+
242
+ const countRows = (await Model.query()
243
+ .clone()
244
+ .clearOrder()
245
+ .count<{ count: string | number }>("* as count")) as unknown as Array<{ count: string | number }>;
246
+ const total = Number(countRows[0]?.count ?? 0);
247
+
248
+ const data = (await Model.query().clone().offset(offset).limit(perPage).select()) as Record<string, unknown>[];
249
+ const lastPage = Math.max(1, Math.ceil(total / perPage));
250
+
251
+ return {
252
+ data,
253
+ total,
254
+ page,
255
+ perPage,
256
+ lastPage,
257
+ };
258
+ }
259
+
260
+ toJSON(): Record<string, unknown> {
261
+ const ctor = this.constructor as typeof XModel;
262
+ const source = this as Record<string, unknown>;
263
+ const result: Record<string, unknown> = { ...source };
264
+
265
+ for (const key of Object.keys(result)) {
266
+ const meta = getColumnOptions(ctor, key);
267
+ if (meta?.hidden) {
268
+ delete result[key];
269
+ }
270
+ }
271
+
272
+ return result;
273
+ }
274
+
275
+ async save(): Promise<void> {
276
+ const Model = this.constructor as typeof XModel;
277
+ const row = this as Record<string, unknown>;
278
+ const id = row.id;
279
+ if (id === undefined || id === null) {
280
+ throw new Error("[XAOCS ORM] save() requires an id — use Model.create() for inserts");
281
+ }
282
+
283
+ const { id: _ignored, ...rest } = row;
284
+ await getKnex()(Model.table).where("id", id).update({ ...rest, updated_at: new Date() });
285
+ }
286
+
287
+ async delete(): Promise<void> {
288
+ const Model = this.constructor as typeof XModel;
289
+ const id = (this as Record<string, unknown>).id as number | string;
290
+
291
+ if (Model.softDeletes) {
292
+ await Model.withTrashed().where("id", id).update({ deleted_at: new Date(), updated_at: new Date() });
293
+ return;
294
+ }
295
+
296
+ await Model.withTrashed().where("id", id).del();
297
+ }
298
+ }
299
+
300
+ export default XModel;
301
+
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { Schema } from "mongoose";
3
+ import { XMongoModel } from "./XMongoModel";
4
+
5
+ class MissingModelName extends XMongoModel {
6
+ static getSchema(): Schema {
7
+ return new Schema({ label: { type: String } });
8
+ }
9
+ }
10
+
11
+ describe("XMongoModel", () => {
12
+ it("requires modelName before accessing model", () => {
13
+ expect(() => MissingModelName.model).toThrow("modelName");
14
+ });
15
+ });
16
+