@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,61 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import type { XModel } from '../XModel';
3
+
4
+ type DefinitionFn<T> = () => Promise<Partial<T>> | Partial<T>;
5
+
6
+ class FactoryBuilder {
7
+ constructor(private count: number) {}
8
+
9
+ /**
10
+ * Create `count` instances of a model using the provided definition function.
11
+ *
12
+ * @example
13
+ * await Factory.times(10).create(User, () => ({
14
+ * name: Factory.faker.person.fullName(),
15
+ * email: Factory.faker.internet.email(),
16
+ * }));
17
+ */
18
+ async create<T extends XModel>(
19
+ ModelClass: typeof XModel & { create(data: Partial<T>): Promise<T> },
20
+ definition: DefinitionFn<T>
21
+ ): Promise<T[]> {
22
+ const results: T[] = [];
23
+ for (let i = 0; i < this.count; i++) {
24
+ const data = await definition();
25
+ const instance = await ModelClass.create(data as Partial<T>);
26
+ results.push(instance as T);
27
+ }
28
+ return results;
29
+ }
30
+
31
+ /**
32
+ * Build plain objects without persisting to DB.
33
+ * Useful for unit tests that don't need a DB.
34
+ */
35
+ async build<T>(definition: DefinitionFn<T>): Promise<T[]> {
36
+ const results: T[] = [];
37
+ for (let i = 0; i < this.count; i++) {
38
+ results.push((await definition()) as T);
39
+ }
40
+ return results;
41
+ }
42
+ }
43
+
44
+ export class Factory {
45
+ /** Access Faker directly: Factory.faker.person.fullName() */
46
+ static faker = faker;
47
+
48
+ static times(count: number): FactoryBuilder {
49
+ return new FactoryBuilder(count);
50
+ }
51
+
52
+ /** Shorthand for Factory.times(1).create(...) */
53
+ static async create<T extends XModel>(
54
+ ModelClass: typeof XModel & { create(data: Partial<T>): Promise<T> },
55
+ definition: DefinitionFn<T>
56
+ ): Promise<T> {
57
+ const [result] = await Factory.times(1).create(ModelClass, definition);
58
+ return result!;
59
+ }
60
+ }
61
+
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export { initDb, closeDb, getKnex, initMongo, initOrmFromEnv, getMongoose, isKnexInitialized, isMongooseConnected, type InitDbOptions, type InitMongoOptions } from "./connection/db";
2
+ export { buildKnexConfig } from "./connection/drivers";
3
+ export { buildMongoUri } from "./connection/mongoUri";
4
+ export { XModel, RelationScope } from "./XModel";
5
+ export { default } from "./XModel";
6
+ export { XMongoModel } from "./XMongoModel";
7
+ export * from "./decorators";
8
+ export { Migration } from "./migrations/Migration";
9
+ export { Schema } from "./migrations/Schema";
10
+ export { MigrationRunner } from "./migrations/MigrationRunner";
11
+ export { augmentTable, type XCreateTableBuilder } from "./migrations/columnHelpers";
12
+ export { Seeder } from "./seeders/Seeder";
13
+ export { SeederRunner } from "./seeders/SeederRunner";
14
+ export { Factory } from "./factories/Factory";
15
+ export { paginate } from "./utils/paginate";
16
+
17
+
18
+
@@ -0,0 +1,8 @@
1
+ import type { Schema } from "./Schema";
2
+
3
+ export abstract class Migration {
4
+ abstract up(schema: Schema): Promise<void>;
5
+
6
+ abstract down(schema: Schema): Promise<void>;
7
+ }
8
+
@@ -0,0 +1,33 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { closeDb, getKnex, initDb, MigrationRunner } from "../index";
5
+
6
+ describe("MigrationRunner", () => {
7
+ const fixturesDir = join(fileURLToPath(new URL(".", import.meta.url)), "..", "..", "test-fixtures", "migrations");
8
+
9
+ beforeAll(() => {
10
+ initDb({ __memorySqlite: true });
11
+ });
12
+
13
+ afterAll(async () => {
14
+ await closeDb();
15
+ });
16
+
17
+ it("runs pending migrations, rolls back the last batch, and reapplies on fresh", async () => {
18
+ const runner = new MigrationRunner();
19
+ const knex = getKnex();
20
+
21
+ await runner.run(fixturesDir);
22
+ expect(await knex.schema.hasTable("migration_test")).toBe(true);
23
+ expect((await runner.getPending(fixturesDir)).length).toBe(0);
24
+
25
+ await runner.rollback(fixturesDir);
26
+ expect(await knex.schema.hasTable("migration_test")).toBe(false);
27
+
28
+ await runner.fresh(fixturesDir);
29
+ expect(await knex.schema.hasTable("migration_test")).toBe(true);
30
+ expect(await knex.schema.hasTable("xacos_migrations")).toBe(true);
31
+ });
32
+ });
33
+
@@ -0,0 +1,171 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import type { Knex } from "knex";
5
+ import { getKnex } from "../connection/db";
6
+ import type { Migration } from "./Migration";
7
+ import { Schema } from "./Schema";
8
+
9
+ async function listUserTables(knex: Knex): Promise<string[]> {
10
+ const client = knex.client.config.client;
11
+
12
+ if (client === "better-sqlite3" || client === "sqlite3") {
13
+ const rows = (await knex
14
+ .select<{ name: string }>("name")
15
+ .from("sqlite_master")
16
+ .where("type", "table")
17
+ .whereNotIn("name", ["sqlite_sequence", "sqlite_stat1"])) as unknown as Array<{ name: string }>;
18
+
19
+ return rows.map((row) => row.name);
20
+ }
21
+
22
+ if (client === "mysql2" || client === "mysql") {
23
+ const dbName = (knex.client.config.connection as { database?: string }).database;
24
+ if (!dbName) return [];
25
+ const rows = (await knex
26
+ .select<{ TABLE_NAME: string }>("TABLE_NAME")
27
+ .from("information_schema.tables")
28
+ .where({
29
+ table_schema: dbName,
30
+ table_type: "BASE TABLE",
31
+ })) as unknown as Array<{ TABLE_NAME: string }>;
32
+ return rows.map((row) => row.TABLE_NAME);
33
+ }
34
+
35
+ if (client === "pg") {
36
+ const rows = (await knex
37
+ .select<{ tablename: string }>("tablename")
38
+ .from("pg_tables")
39
+ .where("schemaname", "public")) as unknown as Array<{ tablename: string }>;
40
+ return rows.map((row) => row.tablename);
41
+ }
42
+
43
+ throw new Error(`[XAOCS ORM] Unsupported SQL client for migrations: ${String(client)}`);
44
+ }
45
+
46
+ export class MigrationRunner {
47
+ private readonly schema = new Schema();
48
+
49
+ private async ensureMigrationsTable(): Promise<void> {
50
+ const knex = getKnex();
51
+ const exists = await knex.schema.hasTable("xacos_migrations");
52
+ if (exists) return;
53
+
54
+ await knex.schema.createTable("xacos_migrations", (table) => {
55
+ table.increments("id").primary();
56
+ table.string("name", 255).notNullable();
57
+ table.integer("batch").notNullable();
58
+ table.timestamp("ran_at").defaultTo(knex.fn.now());
59
+ });
60
+ }
61
+
62
+ async getPending(migrationsDir: string): Promise<string[]> {
63
+ await this.ensureMigrationsTable();
64
+ const knex = getKnex();
65
+ const ranRows = (await knex.select<{ name: string }>("name").from("xacos_migrations")) as unknown as Array<{
66
+ name: string;
67
+ }>;
68
+ const ran = new Set(ranRows.map((row) => row.name));
69
+
70
+ let files: string[];
71
+ try {
72
+ files = readdirSync(migrationsDir);
73
+ } catch {
74
+ return [];
75
+ }
76
+
77
+ return files
78
+ .filter((file) => file.endsWith(".ts") || file.endsWith(".js"))
79
+ .filter((file) => !ran.has(file))
80
+ .sort((a, b) => a.localeCompare(b));
81
+ }
82
+
83
+ async run(migrationsDir: string): Promise<void> {
84
+ await this.ensureMigrationsTable();
85
+ const knex = getKnex();
86
+ const pending = await this.getPending(migrationsDir);
87
+ if (pending.length === 0) return;
88
+
89
+ const lastBatchRow = await knex("xacos_migrations").max<{ b: number | string | null }>({ b: "batch" }).first();
90
+ const batch = Number(lastBatchRow?.b ?? 0) + 1;
91
+
92
+ for (const file of pending) {
93
+ const abs = join(migrationsDir, file);
94
+ const mod = (await import(pathToFileURL(abs).href)) as { default?: new () => Migration };
95
+ const MigrationClass = mod.default;
96
+ if (!MigrationClass) {
97
+ throw new Error(`[XAOCS ORM] Migration file must default-export a Migration class: ${file}`);
98
+ }
99
+
100
+ const migration = new MigrationClass();
101
+ await migration.up(this.schema);
102
+ await knex("xacos_migrations").insert({ name: file, batch, ran_at: knex.fn.now() });
103
+ }
104
+ }
105
+
106
+ async rollback(migrationsDir: string): Promise<void> {
107
+ await this.ensureMigrationsTable();
108
+ const knex = getKnex();
109
+ const lastBatchRow = await knex("xacos_migrations").max<{ b: number | string | null }>({ b: "batch" }).first();
110
+ const lastBatch = Number(lastBatchRow?.b ?? 0);
111
+ if (!lastBatch) return;
112
+
113
+ const rows = (await knex("xacos_migrations").where("batch", lastBatch).orderBy("id", "desc")) as Array<{
114
+ id: number | string;
115
+ name: string;
116
+ }>;
117
+ const schema = new Schema();
118
+
119
+ for (const row of rows) {
120
+ const abs = join(migrationsDir, row.name);
121
+ const mod = (await import(pathToFileURL(abs).href)) as { default?: new () => Migration };
122
+ const MigrationClass = mod.default;
123
+ if (!MigrationClass) {
124
+ throw new Error(`[XAOCS ORM] Migration file must default-export a Migration class: ${row.name}`);
125
+ }
126
+
127
+ const migration = new MigrationClass();
128
+ await migration.down(schema);
129
+ await knex("xacos_migrations").where("id", row.id as number | string).del();
130
+ }
131
+ }
132
+
133
+ async fresh(migrationsDir: string): Promise<void> {
134
+ const knex = getKnex();
135
+ const tables = await listUserTables(knex);
136
+
137
+ for (const name of tables) {
138
+ await knex.schema.dropTableIfExists(name);
139
+ }
140
+
141
+ await this.run(migrationsDir);
142
+ }
143
+
144
+ async status(migrationsDir: string): Promise<Array<{ name: string; ran: boolean; batch?: number | undefined }>> {
145
+ await this.ensureMigrationsTable();
146
+ const knex = getKnex();
147
+ const ranRows = (await knex.select<{ name: string; batch: number }>("name", "batch").from("xacos_migrations")) as unknown as Array<{
148
+ name: string;
149
+ batch: number;
150
+ }>;
151
+ const ranMap = new Map(ranRows.map((row) => [row.name, row.batch]));
152
+
153
+ let files: string[];
154
+ try {
155
+ files = readdirSync(migrationsDir);
156
+ } catch {
157
+ files = [];
158
+ }
159
+
160
+ const migrationFiles = files
161
+ .filter((file) => file.endsWith(".ts") || file.endsWith(".js"))
162
+ .sort((a, b) => a.localeCompare(b));
163
+
164
+ return migrationFiles.map((file) => ({
165
+ name: file,
166
+ ran: ranMap.has(file),
167
+ batch: ranMap.get(file),
168
+ }));
169
+ }
170
+ }
171
+
@@ -0,0 +1,20 @@
1
+ import { getKnex } from "../connection/db";
2
+ import { augmentTable, type XCreateTableBuilder } from "./columnHelpers";
3
+
4
+ export class Schema {
5
+ async createTable(name: string, callback: (table: XCreateTableBuilder) => void): Promise<void> {
6
+ const knex = getKnex();
7
+ await knex.schema.createTable(name, (table) => {
8
+ callback(augmentTable(table, knex));
9
+ });
10
+ }
11
+
12
+ async dropTable(name: string): Promise<void> {
13
+ await getKnex().schema.dropTableIfExists(name);
14
+ }
15
+
16
+ async hasTable(name: string): Promise<boolean> {
17
+ return getKnex().schema.hasTable(name);
18
+ }
19
+ }
20
+
@@ -0,0 +1,28 @@
1
+ import type { Knex } from "knex";
2
+
3
+ /** Augmented table builder: use `xacosTimestamps()` / `xacosSoftDeletes()` to avoid clashing with Knex's `timestamps()`. */
4
+ export type XCreateTableBuilder = Knex.CreateTableBuilder & {
5
+ id(): void;
6
+ xacosTimestamps(): void;
7
+ xacosSoftDeletes(): void;
8
+ };
9
+
10
+ export function augmentTable(table: Knex.CreateTableBuilder, knex: Knex): XCreateTableBuilder {
11
+ const extended = table as XCreateTableBuilder;
12
+
13
+ extended.id = () => {
14
+ table.bigIncrements("id").primary();
15
+ };
16
+
17
+ extended.xacosTimestamps = () => {
18
+ table.timestamp("created_at").defaultTo(knex.fn.now()).notNullable();
19
+ table.timestamp("updated_at").defaultTo(knex.fn.now()).notNullable();
20
+ };
21
+
22
+ extended.xacosSoftDeletes = () => {
23
+ table.timestamp("deleted_at").nullable();
24
+ };
25
+
26
+ return extended;
27
+ }
28
+
@@ -0,0 +1,8 @@
1
+ export abstract class Seeder {
2
+ /**
3
+ * Implement this method with your seed logic.
4
+ * Called by the SeederRunner when `xacos db:seed` is invoked.
5
+ */
6
+ abstract run(): Promise<void>;
7
+ }
8
+
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { SeederRunner } from './SeederRunner';
3
+ import { writeFileSync, mkdirSync, rmSync } from 'fs';
4
+ import { resolve } from 'path';
5
+
6
+ const TMP = resolve('./tmp-seeders-test');
7
+
8
+ describe('SeederRunner', () => {
9
+ beforeEach(() => {
10
+ mkdirSync(TMP, { recursive: true });
11
+ writeFileSync(
12
+ `${TMP}/TestSeeder.ts`,
13
+ `export default class TestSeeder { async run() { (global as any).__seederRan = true; } }`
14
+ );
15
+ });
16
+
17
+ afterEach(() => rmSync(TMP, { recursive: true, force: true }));
18
+
19
+ it('throws when seeder file does not exist', async () => {
20
+ const runner = new SeederRunner(TMP);
21
+ await expect(runner.runOne('NonExistent')).rejects.toThrow('Seeder not found');
22
+ });
23
+
24
+ it('runs a single seeder', async () => {
25
+ const runner = new SeederRunner(TMP);
26
+ (global as any).__seederRan = false;
27
+ await runner.runOne('TestSeeder');
28
+ expect((global as any).__seederRan).toBe(true);
29
+ });
30
+
31
+ it('runs all seeders in directory', async () => {
32
+ writeFileSync(
33
+ `${TMP}/AnotherSeeder.ts`,
34
+ `export default class AnotherSeeder { async run() { (global as any).__anotherRan = true; } }`
35
+ );
36
+ const runner = new SeederRunner(TMP);
37
+ (global as any).__seederRan = false;
38
+ (global as any).__anotherRan = false;
39
+ await runner.runAll();
40
+ expect((global as any).__seederRan).toBe(true);
41
+ expect((global as any).__anotherRan).toBe(true);
42
+ });
43
+
44
+ it('sorts seeders: numbered before alpha', async () => {
45
+ // create files in reverse to ensure sorting matters
46
+ writeFileSync(`${TMP}/ZebraSeeder.ts`, 'export default class ZebraSeeder { async run() { (global as any).__order.push("zebra"); } }');
47
+ writeFileSync(`${TMP}/001_UserSeeder.ts`, 'export default class UserSeeder { async run() { (global as any).__order.push("user"); } }');
48
+ writeFileSync(`${TMP}/002_PostSeeder.ts`, 'export default class PostSeeder { async run() { (global as any).__order.push("post"); } }');
49
+
50
+ const runner = new SeederRunner(TMP);
51
+ (global as any).__order = [];
52
+
53
+ // We need to clear out the default seeders created in beforeEach if they interfere
54
+ rmSync(resolve(TMP, 'TestSeeder.ts'));
55
+
56
+ await runner.runAll();
57
+
58
+ expect((global as any).__order).toEqual(['user', 'post', 'zebra']);
59
+ });
60
+ });
61
+
62
+
@@ -0,0 +1,76 @@
1
+ import { resolve } from 'path';
2
+ import { existsSync, readdirSync } from 'fs';
3
+ import type { Seeder } from './Seeder';
4
+
5
+ export class SeederRunner {
6
+ constructor(private seedersDir: string) {}
7
+
8
+ /**
9
+ * Run a specific seeder by class name.
10
+ * e.g. runOne('UserSeeder')
11
+ */
12
+ async runOne(name: string): Promise<void> {
13
+ const path = resolve(this.seedersDir, `${name}.ts`);
14
+ const jsPath = resolve(this.seedersDir, `${name}.js`);
15
+
16
+ let targetPath = path;
17
+ if (!existsSync(path)) {
18
+ if (existsSync(jsPath)) {
19
+ targetPath = jsPath;
20
+ } else {
21
+ throw new Error(`[XAOCS] Seeder not found: ${name} at ${path}`);
22
+ }
23
+ }
24
+
25
+ const mod = await import(targetPath);
26
+ const SeederClass: new () => Seeder = mod.default ?? mod[name];
27
+
28
+ if (!SeederClass) {
29
+ throw new Error(`[XAOCS] Seeder class not exported from ${name}.ts`);
30
+ }
31
+
32
+ const instance = new SeederClass();
33
+ console.log(` ▶ Running seeder: ${name}`);
34
+ await instance.run();
35
+ console.log(` ✓ Done: ${name}`);
36
+ }
37
+
38
+ /**
39
+ * Run all seeders in the seeders directory, in alphabetical order.
40
+ */
41
+ async runAll(): Promise<void> {
42
+ const files = this.getSortedFiles();
43
+ if (files.length === 0) {
44
+ console.log('[XAOCS] No seeder files found.');
45
+ return;
46
+ }
47
+
48
+ for (const file of files) {
49
+ const name = file.replace(/\.(ts|js)$/, '');
50
+ await this.runOne(name);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Scan the seeders directory and return file names sorted correctly.
56
+ * Files prefixed with numbers sort numerically: 001_, 002_, etc.
57
+ * Files without prefix sort alphabetically after numbered ones.
58
+ */
59
+ private getSortedFiles(): string[] {
60
+ const fullPath = resolve(this.seedersDir);
61
+ if (!existsSync(fullPath)) return [];
62
+
63
+ return readdirSync(fullPath)
64
+ .filter((f: string) => f.endsWith('.ts') || f.endsWith('.js'))
65
+ .sort((a: string, b: string) => {
66
+ // Extract leading number if present: "001_UserSeeder.ts" → 1
67
+ const numA = parseInt(a.match(/^(\d+)/)?.[1] ?? 'Infinity', 10);
68
+ const numB = parseInt(b.match(/^(\d+)/)?.[1] ?? 'Infinity', 10);
69
+
70
+ if (numA !== numB) return numA - numB;
71
+ return a.localeCompare(b);
72
+ });
73
+ }
74
+ }
75
+
76
+
@@ -0,0 +1,8 @@
1
+ declare module 'bun:test' {
2
+ export function describe(name: string, fn: () => void): void;
3
+ export function it(name: string, fn: () => Promise<void> | void): void;
4
+ export function expect(value: any): any;
5
+ export function beforeAll(fn: () => Promise<void> | void): void;
6
+ export function afterAll(fn: () => Promise<void> | void): void;
7
+ }
8
+
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { paginate } from "./paginate";
3
+
4
+ describe("paginate", () => {
5
+ it("caps perPage at 100 and ensures page is at least 1", async () => {
6
+ const mockQuery = {
7
+ clone: () => mockQuery,
8
+ clearOrder: () => mockQuery,
9
+ clearSelect: () => mockQuery,
10
+ count: () => mockQuery,
11
+ first: async () => ({ total: 50 }),
12
+ offset: (off: number) => ({
13
+ limit: async (lim: number) => [{ id: 1, off, lim }],
14
+ }),
15
+ };
16
+
17
+ const res = await paginate(mockQuery as any, -5, 999);
18
+ expect(res.page).toBe(1);
19
+ expect(res.perPage).toBe(100);
20
+ expect(res.total).toBe(50);
21
+ expect(res.lastPage).toBe(1);
22
+ expect(res.data).toEqual([{ id: 1, off: 0, lim: 100 }] as any);
23
+ });
24
+ });
@@ -0,0 +1,37 @@
1
+ import type { Knex } from 'knex';
2
+ import type { PaginatedResult } from '@xacos/shared';
3
+
4
+ /**
5
+ * Paginate any Knex query builder.
6
+ *
7
+ * @example
8
+ * const result = await paginate(
9
+ * User.query().where('role', 'admin'),
10
+ * req.query.page,
11
+ * 15
12
+ * );
13
+ */
14
+ export async function paginate<T>(
15
+ query: Knex.QueryBuilder,
16
+ page: number | string = 1,
17
+ perPage: number | string = 15
18
+ ): Promise<PaginatedResult<T>> {
19
+ const p = Math.max(1, Number(page));
20
+ const pp = Math.min(100, Math.max(1, Number(perPage))); // cap at 100
21
+ const offset = (p - 1) * pp;
22
+
23
+ // Clone the query for count — without order/limit
24
+ const countQuery = query.clone().clearOrder().clearSelect().count('* as total').first();
25
+ const countResult = await countQuery as { total: string | number };
26
+ const total = Number(countResult?.total ?? 0);
27
+
28
+ const data = await query.offset(offset).limit(pp) as T[];
29
+
30
+ return {
31
+ data,
32
+ total,
33
+ page: p,
34
+ perPage: pp,
35
+ lastPage: Math.ceil(total / pp),
36
+ };
37
+ }