@aruvili/api 0.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.
Files changed (158) hide show
  1. package/dist/config.d.ts +22 -0
  2. package/dist/config.d.ts.map +1 -0
  3. package/dist/config.js +34 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/context.d.ts +7 -0
  6. package/dist/context.d.ts.map +1 -0
  7. package/dist/context.js +3 -0
  8. package/dist/context.js.map +1 -0
  9. package/dist/controllers/index.d.ts +39 -0
  10. package/dist/controllers/index.d.ts.map +1 -0
  11. package/dist/controllers/index.js +39 -0
  12. package/dist/controllers/index.js.map +1 -0
  13. package/dist/db.d.ts +6 -0
  14. package/dist/db.d.ts.map +1 -0
  15. package/dist/db.js +74 -0
  16. package/dist/db.js.map +1 -0
  17. package/dist/index.d.ts +17 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +154 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/middleware/auth.d.ts +15 -0
  22. package/dist/middleware/auth.d.ts.map +1 -0
  23. package/dist/middleware/auth.js +93 -0
  24. package/dist/middleware/auth.js.map +1 -0
  25. package/dist/middleware/body-limit.d.ts +9 -0
  26. package/dist/middleware/body-limit.d.ts.map +1 -0
  27. package/dist/middleware/body-limit.js +15 -0
  28. package/dist/middleware/body-limit.js.map +1 -0
  29. package/dist/middleware/rate-limit.d.ts +6 -0
  30. package/dist/middleware/rate-limit.d.ts.map +1 -0
  31. package/dist/middleware/rate-limit.js +40 -0
  32. package/dist/middleware/rate-limit.js.map +1 -0
  33. package/dist/middleware/rbac.d.ts +10 -0
  34. package/dist/middleware/rbac.d.ts.map +1 -0
  35. package/dist/middleware/rbac.js +61 -0
  36. package/dist/middleware/rbac.js.map +1 -0
  37. package/dist/middleware/tenant.d.ts +3 -0
  38. package/dist/middleware/tenant.d.ts.map +1 -0
  39. package/dist/middleware/tenant.js +19 -0
  40. package/dist/middleware/tenant.js.map +1 -0
  41. package/dist/registry.d.ts +26 -0
  42. package/dist/registry.d.ts.map +1 -0
  43. package/dist/registry.js +112 -0
  44. package/dist/registry.js.map +1 -0
  45. package/dist/routes/auth.d.ts +3 -0
  46. package/dist/routes/auth.d.ts.map +1 -0
  47. package/dist/routes/auth.js +141 -0
  48. package/dist/routes/auth.js.map +1 -0
  49. package/dist/routes/crud.d.ts +7 -0
  50. package/dist/routes/crud.d.ts.map +1 -0
  51. package/dist/routes/crud.js +845 -0
  52. package/dist/routes/crud.js.map +1 -0
  53. package/dist/routes/files.d.ts +7 -0
  54. package/dist/routes/files.d.ts.map +1 -0
  55. package/dist/routes/files.js +123 -0
  56. package/dist/routes/files.js.map +1 -0
  57. package/dist/routes/meta.d.ts +3 -0
  58. package/dist/routes/meta.d.ts.map +1 -0
  59. package/dist/routes/meta.js +352 -0
  60. package/dist/routes/meta.js.map +1 -0
  61. package/dist/scheduler.d.ts +33 -0
  62. package/dist/scheduler.d.ts.map +1 -0
  63. package/dist/scheduler.js +97 -0
  64. package/dist/scheduler.js.map +1 -0
  65. package/dist/utils/link-validator.d.ts +7 -0
  66. package/dist/utils/link-validator.d.ts.map +1 -0
  67. package/dist/utils/link-validator.js +33 -0
  68. package/dist/utils/link-validator.js.map +1 -0
  69. package/dist/utils/resolver.d.ts +5 -0
  70. package/dist/utils/resolver.d.ts.map +1 -0
  71. package/dist/utils/resolver.js +58 -0
  72. package/dist/utils/resolver.js.map +1 -0
  73. package/package.json +24 -0
  74. package/src/api.test.ts +362 -0
  75. package/src/config.d.ts +22 -0
  76. package/src/config.d.ts.map +1 -0
  77. package/src/config.js +34 -0
  78. package/src/config.js.map +1 -0
  79. package/src/config.ts +38 -0
  80. package/src/context.d.ts +7 -0
  81. package/src/context.d.ts.map +1 -0
  82. package/src/context.js +3 -0
  83. package/src/context.js.map +1 -0
  84. package/src/context.ts +8 -0
  85. package/src/controllers/index.d.ts +39 -0
  86. package/src/controllers/index.d.ts.map +1 -0
  87. package/src/controllers/index.js +39 -0
  88. package/src/controllers/index.js.map +1 -0
  89. package/src/controllers/index.ts +51 -0
  90. package/src/db.d.ts +6 -0
  91. package/src/db.d.ts.map +1 -0
  92. package/src/db.js +74 -0
  93. package/src/db.js.map +1 -0
  94. package/src/db.ts +73 -0
  95. package/src/index.ts +178 -0
  96. package/src/integration.test.ts +453 -0
  97. package/src/middleware/auth.d.ts +15 -0
  98. package/src/middleware/auth.d.ts.map +1 -0
  99. package/src/middleware/auth.js +93 -0
  100. package/src/middleware/auth.js.map +1 -0
  101. package/src/middleware/auth.ts +109 -0
  102. package/src/middleware/body-limit.d.ts +9 -0
  103. package/src/middleware/body-limit.d.ts.map +1 -0
  104. package/src/middleware/body-limit.js +15 -0
  105. package/src/middleware/body-limit.js.map +1 -0
  106. package/src/middleware/body-limit.ts +16 -0
  107. package/src/middleware/rate-limit.d.ts +6 -0
  108. package/src/middleware/rate-limit.d.ts.map +1 -0
  109. package/src/middleware/rate-limit.js +40 -0
  110. package/src/middleware/rate-limit.js.map +1 -0
  111. package/src/middleware/rate-limit.ts +47 -0
  112. package/src/middleware/rbac.d.ts +10 -0
  113. package/src/middleware/rbac.d.ts.map +1 -0
  114. package/src/middleware/rbac.js +61 -0
  115. package/src/middleware/rbac.js.map +1 -0
  116. package/src/middleware/rbac.ts +71 -0
  117. package/src/middleware/tenant.d.ts +3 -0
  118. package/src/middleware/tenant.d.ts.map +1 -0
  119. package/src/middleware/tenant.js +19 -0
  120. package/src/middleware/tenant.js.map +1 -0
  121. package/src/middleware/tenant.ts +24 -0
  122. package/src/registry.d.ts +26 -0
  123. package/src/registry.d.ts.map +1 -0
  124. package/src/registry.js +112 -0
  125. package/src/registry.js.map +1 -0
  126. package/src/registry.ts +123 -0
  127. package/src/routes/auth.d.ts +3 -0
  128. package/src/routes/auth.d.ts.map +1 -0
  129. package/src/routes/auth.js +141 -0
  130. package/src/routes/auth.js.map +1 -0
  131. package/src/routes/auth.ts +164 -0
  132. package/src/routes/crud.d.ts +7 -0
  133. package/src/routes/crud.d.ts.map +1 -0
  134. package/src/routes/crud.js +845 -0
  135. package/src/routes/crud.js.map +1 -0
  136. package/src/routes/crud.ts +1029 -0
  137. package/src/routes/files.d.ts +7 -0
  138. package/src/routes/files.d.ts.map +1 -0
  139. package/src/routes/files.js +123 -0
  140. package/src/routes/files.js.map +1 -0
  141. package/src/routes/files.ts +143 -0
  142. package/src/routes/meta.d.ts +3 -0
  143. package/src/routes/meta.d.ts.map +1 -0
  144. package/src/routes/meta.js +352 -0
  145. package/src/routes/meta.js.map +1 -0
  146. package/src/routes/meta.ts +448 -0
  147. package/src/scheduler.ts +118 -0
  148. package/src/utils/link-validator.d.ts +7 -0
  149. package/src/utils/link-validator.d.ts.map +1 -0
  150. package/src/utils/link-validator.js +33 -0
  151. package/src/utils/link-validator.js.map +1 -0
  152. package/src/utils/link-validator.ts +45 -0
  153. package/src/utils/resolver.d.ts +5 -0
  154. package/src/utils/resolver.d.ts.map +1 -0
  155. package/src/utils/resolver.js +58 -0
  156. package/src/utils/resolver.js.map +1 -0
  157. package/src/utils/resolver.ts +65 -0
  158. package/tsconfig.json +9 -0
@@ -0,0 +1,33 @@
1
+ export interface BackgroundTask {
2
+ name: string;
3
+ intervalSeconds: number;
4
+ execute: (client: any) => Promise<void>;
5
+ }
6
+ declare class AruviliScheduler {
7
+ private tasks;
8
+ private timer;
9
+ private isRunning;
10
+ /**
11
+ * Registers a task with the scheduler.
12
+ */
13
+ register(task: BackgroundTask): void;
14
+ /**
15
+ * Starts the scheduler loop.
16
+ */
17
+ start(tickIntervalMs?: number): Promise<void>;
18
+ /**
19
+ * Stops the scheduler.
20
+ */
21
+ stop(): void;
22
+ /**
23
+ * Performs a single scheduler loop check.
24
+ */
25
+ private tick;
26
+ /**
27
+ * Tries to lock and run a background task.
28
+ */
29
+ private runTaskDistributed;
30
+ }
31
+ export declare const scheduler: AruviliScheduler;
32
+ export {};
33
+ //# sourceMappingURL=scheduler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scheduler.d.ts","sourceRoot":"","sources":["../src/scheduler.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,CAAC,MAAM,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzC;AAED,cAAM,gBAAgB;IACpB,OAAO,CAAC,KAAK,CAAwB;IACrC,OAAO,CAAC,KAAK,CAAa;IAC1B,OAAO,CAAC,SAAS,CAAS;IAE1B;;OAEG;IACI,QAAQ,CAAC,IAAI,EAAE,cAAc;IAIpC;;OAEG;IACU,KAAK,CAAC,cAAc,GAAE,MAAc;IAuBjD;;OAEG;IACI,IAAI;IASX;;OAEG;YACW,IAAI;IAMlB;;OAEG;YACW,kBAAkB;CA6CjC;AAED,eAAO,MAAM,SAAS,kBAAyB,CAAC"}
@@ -0,0 +1,97 @@
1
+ import { query, withTransaction } from './db.js';
2
+ class AruviliScheduler {
3
+ tasks = [];
4
+ timer = null;
5
+ isRunning = false;
6
+ /**
7
+ * Registers a task with the scheduler.
8
+ */
9
+ register(task) {
10
+ this.tasks.push(task);
11
+ }
12
+ /**
13
+ * Starts the scheduler loop.
14
+ */
15
+ async start(tickIntervalMs = 10000) {
16
+ if (this.isRunning)
17
+ return;
18
+ this.isRunning = true;
19
+ // 1. Ensure the jobs tracking table exists
20
+ await query(`
21
+ CREATE TABLE IF NOT EXISTS _scheduled_jobs (
22
+ name VARCHAR(255) PRIMARY KEY,
23
+ last_started TIMESTAMP WITH TIME ZONE,
24
+ last_completed TIMESTAMP WITH TIME ZONE,
25
+ status VARCHAR(50),
26
+ error_message TEXT
27
+ );
28
+ `);
29
+ console.log(`[SCHEDULER] Started scheduler with tick interval: ${tickIntervalMs}ms. Registered tasks: ${this.tasks.length}`);
30
+ // Run the scheduler tick loop
31
+ this.timer = setInterval(async () => {
32
+ await this.tick();
33
+ }, tickIntervalMs);
34
+ }
35
+ /**
36
+ * Stops the scheduler.
37
+ */
38
+ stop() {
39
+ if (this.timer) {
40
+ clearInterval(this.timer);
41
+ this.timer = null;
42
+ }
43
+ this.isRunning = false;
44
+ console.log('[SCHEDULER] Scheduler stopped.');
45
+ }
46
+ /**
47
+ * Performs a single scheduler loop check.
48
+ */
49
+ async tick() {
50
+ for (const task of this.tasks) {
51
+ await this.runTaskDistributed(task);
52
+ }
53
+ }
54
+ /**
55
+ * Tries to lock and run a background task.
56
+ */
57
+ async runTaskDistributed(task) {
58
+ try {
59
+ // Try to acquire distributed lock atomically
60
+ const acquired = await withTransaction(async (client) => {
61
+ const sql = `
62
+ INSERT INTO _scheduled_jobs (name, last_started, status)
63
+ VALUES ($1, NOW(), 'RUNNING')
64
+ ON CONFLICT (name) DO UPDATE
65
+ SET last_started = NOW(), status = 'RUNNING'
66
+ WHERE (_scheduled_jobs.status != 'RUNNING' AND _scheduled_jobs.last_started < NOW() - (INTERVAL '1 second' * $2))
67
+ OR (_scheduled_jobs.status = 'RUNNING' AND _scheduled_jobs.last_started < NOW() - (INTERVAL '1 second' * $2 * 2))
68
+ RETURNING 1;
69
+ `;
70
+ const res = await client.query(sql, [task.name, task.intervalSeconds]);
71
+ return res.rows.length > 0;
72
+ });
73
+ if (!acquired) {
74
+ return; // Lock not acquired (already running or completed too recently)
75
+ }
76
+ console.log(`[SCHEDULER] Running task: ${task.name}`);
77
+ const start = Date.now();
78
+ try {
79
+ await withTransaction(async (client) => {
80
+ await task.execute(client);
81
+ });
82
+ // Update task state on success
83
+ await query(`UPDATE _scheduled_jobs SET last_completed = NOW(), status = 'SUCCESS', error_message = NULL WHERE name = $1`, [task.name]);
84
+ console.log(`[SCHEDULER] Task ${task.name} finished successfully in ${Date.now() - start}ms`);
85
+ }
86
+ catch (err) {
87
+ console.error(`[SCHEDULER ERROR] Task ${task.name} failed:`, err.message);
88
+ await query(`UPDATE _scheduled_jobs SET last_completed = NOW(), status = 'FAILED', error_message = $2 WHERE name = $1`, [task.name, err.message]);
89
+ }
90
+ }
91
+ catch (err) {
92
+ // Catch db concurrency locking serialization or connection errors
93
+ }
94
+ }
95
+ }
96
+ export const scheduler = new AruviliScheduler();
97
+ //# sourceMappingURL=scheduler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scheduler.js","sourceRoot":"","sources":["../src/scheduler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ import { DocTypeDefinition } from '@aruvili/core';
2
+ /**
3
+ * Validates all Link field values exist in their target DocType tables.
4
+ * Prevents dangling references (foreign key integrity without DB-level FK constraints).
5
+ */
6
+ export declare function validateLinks(definition: DocTypeDefinition, record: Record<string, any>, client: any): Promise<string[]>;
7
+ //# sourceMappingURL=link-validator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"link-validator.d.ts","sourceRoot":"","sources":["../../src/utils/link-validator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAgB,MAAM,eAAe,CAAC;AAGhE;;;GAGG;AACH,wBAAsB,aAAa,CACjC,UAAU,EAAE,iBAAiB,EAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,MAAM,EAAE,GAAG,GACV,OAAO,CAAC,MAAM,EAAE,CAAC,CAiCnB"}
@@ -0,0 +1,33 @@
1
+ import { getTableName } from '@aruvili/core';
2
+ /**
3
+ * Validates all Link field values exist in their target DocType tables.
4
+ * Prevents dangling references (foreign key integrity without DB-level FK constraints).
5
+ */
6
+ export async function validateLinks(definition, record, client) {
7
+ const errors = [];
8
+ const linkFields = definition.fields.filter(f => f.fieldtype === 'Link' && f.options && record[f.fieldname]);
9
+ for (const field of linkFields) {
10
+ const targetDocType = field.options;
11
+ const targetTable = getTableName(targetDocType);
12
+ const value = record[field.fieldname];
13
+ if (typeof value !== 'string' || value.trim() === '')
14
+ continue;
15
+ try {
16
+ const res = await client.query(`SELECT 1 FROM ${targetTable} WHERE name = $1 LIMIT 1`, [value]);
17
+ if (res.rows.length === 0) {
18
+ errors.push(`Link field '${field.fieldname}': value '${value}' does not exist in ${targetDocType}.`);
19
+ }
20
+ }
21
+ catch (err) {
22
+ // Table might not exist yet if DocType not registered
23
+ if (err.code === '42P01') { // undefined_table
24
+ errors.push(`Link field '${field.fieldname}': target DocType '${targetDocType}' table does not exist.`);
25
+ }
26
+ else {
27
+ throw err;
28
+ }
29
+ }
30
+ }
31
+ return errors;
32
+ }
33
+ //# sourceMappingURL=link-validator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"link-validator.js","sourceRoot":"","sources":["../../src/utils/link-validator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,YAAY,EAAE,MAAM,eAAe,CAAC;AAGhE;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,UAA6B,EAC7B,MAA2B,EAC3B,MAAW;IAEX,MAAM,MAAM,GAAa,EAAE,CAAC;IAE5B,MAAM,UAAU,GAAG,UAAU,CAAC,MAAM,CAAC,MAAM,CACzC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,MAAM,IAAI,CAAC,CAAC,OAAO,IAAI,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAChE,CAAC;IAEF,KAAK,MAAM,KAAK,IAAI,UAAU,EAAE,CAAC;QAC/B,MAAM,aAAa,GAAG,KAAK,CAAC,OAAQ,CAAC;QACrC,MAAM,WAAW,GAAG,YAAY,CAAC,aAAa,CAAC,CAAC;QAChD,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;QAEtC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE;YAAE,SAAS;QAE/D,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,KAAK,CAC5B,iBAAiB,WAAW,0BAA0B,EACtD,CAAC,KAAK,CAAC,CACR,CAAC;YACF,IAAI,GAAG,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBAC1B,MAAM,CAAC,IAAI,CAAC,eAAe,KAAK,CAAC,SAAS,aAAa,KAAK,uBAAuB,aAAa,GAAG,CAAC,CAAC;YACvG,CAAC;QACH,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAClB,sDAAsD;YACtD,IAAI,GAAG,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC,CAAC,kBAAkB;gBAC5C,MAAM,CAAC,IAAI,CAAC,eAAe,KAAK,CAAC,SAAS,sBAAsB,aAAa,yBAAyB,CAAC,CAAC;YAC1G,CAAC;iBAAM,CAAC;gBACN,MAAM,GAAG,CAAC;YACZ,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Resolves all Link fields for a list of document records in a single batch, preventing N+1 queries.
3
+ */
4
+ export declare function resolveLinkFields(doctypeName: string, records: any[]): Promise<any[]>;
5
+ //# sourceMappingURL=resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolver.d.ts","sourceRoot":"","sources":["../../src/utils/resolver.ts"],"names":[],"mappings":"AAIA;;GAEG;AACH,wBAAsB,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,CAyD3F"}
@@ -0,0 +1,58 @@
1
+ import { registry } from '../registry.js';
2
+ import { getTableName } from '@aruvili/core';
3
+ import { query } from '../db.js';
4
+ /**
5
+ * Resolves all Link fields for a list of document records in a single batch, preventing N+1 queries.
6
+ */
7
+ export async function resolveLinkFields(doctypeName, records) {
8
+ if (records.length === 0)
9
+ return records;
10
+ const definition = await registry.get(doctypeName);
11
+ if (!definition)
12
+ return records;
13
+ // Identify fields of type Link
14
+ const linkFields = definition.fields.filter(f => f.fieldtype === 'Link' && f.options);
15
+ if (linkFields.length === 0)
16
+ return records;
17
+ // Deep clone to prevent mutating original parameters
18
+ const resolved = JSON.parse(JSON.stringify(records));
19
+ for (const field of linkFields) {
20
+ const targetDocType = field.options;
21
+ const fieldname = field.fieldname;
22
+ // Collect all unique referenced names
23
+ const ids = Array.from(new Set(resolved.map((r) => r[fieldname]).filter((v) => typeof v === 'string' && v.trim() !== '')));
24
+ if (ids.length === 0)
25
+ continue;
26
+ // Retrieve target configuration to locate its title field
27
+ const targetDef = await registry.get(targetDocType);
28
+ const titleField = targetDef?.title_field || 'name';
29
+ const targetTable = getTableName(targetDocType);
30
+ try {
31
+ // Fetch key and title mappings in a single batch query
32
+ const sql = `SELECT name, ${titleField} AS title FROM ${targetTable} WHERE name = ANY($1)`;
33
+ const res = await query(sql, [ids]);
34
+ const map = new Map();
35
+ for (const row of res.rows) {
36
+ map.set(row.name, {
37
+ name: row.name,
38
+ title_field_value: String(row.title)
39
+ });
40
+ }
41
+ // Merge results
42
+ for (const record of resolved) {
43
+ const val = record[fieldname];
44
+ if (val && map.has(val)) {
45
+ record[fieldname] = map.get(val);
46
+ }
47
+ else if (val) {
48
+ record[fieldname] = { name: val, title_field_value: val };
49
+ }
50
+ }
51
+ }
52
+ catch (err) {
53
+ console.error(`Failed to resolve link field '${fieldname}' pointing to '${targetDocType}':`, err);
54
+ }
55
+ }
56
+ return resolved;
57
+ }
58
+ //# sourceMappingURL=resolver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolver.js","sourceRoot":"","sources":["../../src/utils/resolver.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@aruvili/api",
3
+ "version": "0.1.0",
4
+ "description": "Bun + Hono generic CRUD engine for Meta Framework",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "type": "module",
8
+ "scripts": {
9
+ "dev": "bun --watch src/index.ts",
10
+ "build": "tsc",
11
+ "test": "bun test"
12
+ },
13
+ "dependencies": {
14
+ "@aruvili/core": "^0.1.0",
15
+ "hono": "^4.4.7",
16
+ "pg": "^8.12.0",
17
+ "zod": "^3.23.8"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^20.19.43",
21
+ "@types/pg": "^8.11.6",
22
+ "typescript": "~6.0.2"
23
+ }
24
+ }
@@ -0,0 +1,362 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ // Establish dbMock globally for unit tests BEFORE importing modules that query db
4
+ (globalThis as any).dbMock = {
5
+ query: async (text: string, params?: any[]) => {
6
+ if (text.includes('SELECT definition FROM _doctype_meta')) {
7
+ if (params && params[0] === 'Ticket') {
8
+ return { rows: [{ definition: {
9
+ name: 'Ticket',
10
+ fields: [
11
+ { fieldname: 'title', label: 'Title', fieldtype: 'Text', required: true },
12
+ { fieldname: 'workflow_state', label: 'State', fieldtype: 'Text' }
13
+ ],
14
+ permissions: [{ role: 'Employee', create: true, read: true, update: true, delete: true }],
15
+ workflow: {
16
+ fieldname: 'workflow_state',
17
+ initial_state: 'Draft',
18
+ transitions: [
19
+ { state: 'Draft', action: 'Approve', next_state: 'Approved', allowed_roles: ['System Manager'] }
20
+ ]
21
+ }
22
+ }}]};
23
+ }
24
+ return { rows: [{ definition: {
25
+ name: 'Project', title_field: 'project_name',
26
+ fields: [{ fieldname: 'project_name', label: 'Project Name', fieldtype: 'Text', required: true }],
27
+ permissions: [{ role: 'System Manager', create: true, read: true, update: true, delete: true }]
28
+ }}]};
29
+ }
30
+ return { rows: [] };
31
+ },
32
+ withTransaction: async (cb: any) => {
33
+ const clientMock = {
34
+ query: async (text: string, params?: any[]) => {
35
+ if (text.includes('SELECT * FROM dt_ticket')) {
36
+ return { rows: [{ name: 'TKT-100', title: 'Need access', workflow_state: 'Draft', docstatus: 0 }] };
37
+ }
38
+ if (text.includes('UPDATE dt_ticket')) {
39
+ return { rows: [{ name: 'TKT-100', title: 'Need access', workflow_state: 'Approved', docstatus: 0 }] };
40
+ }
41
+ return { rows: [] };
42
+ }
43
+ };
44
+ return cb(clientMock);
45
+ }
46
+ };
47
+
48
+ import { registry } from './registry.js';
49
+ import { authMiddleware } from './middleware/auth.js';
50
+ import { controllerRegistry, DocTypeController } from './controllers/index.js';
51
+
52
+ describe('Registry Metadata Cache', () => {
53
+ it('should resolve and cache doctype schemas', async () => {
54
+ const doc = await registry.get('Project');
55
+ expect(doc).not.toBeNull();
56
+ expect(doc!.name).toBe('Project');
57
+ expect(doc!.title_field).toBe('project_name');
58
+ });
59
+
60
+ it('should compile Zod validators dynamically', async () => {
61
+ const val = await registry.getValidator('Project');
62
+ expect(val).not.toBeNull();
63
+ expect(val!.safeParse({ project_name: 'Test' }).success).toBe(true);
64
+ });
65
+
66
+ it('should invalidate cache on demand', async () => {
67
+ registry.invalidate('Project');
68
+ const doc = await registry.get('Project');
69
+ expect(doc).not.toBeNull(); // Re-fetched from mock DB
70
+ });
71
+ });
72
+
73
+ describe('Authentication Middleware', () => {
74
+ it('should resolve mock admin token in non-production', async () => {
75
+ let contextUser: any = null;
76
+ const mockCtx: any = {
77
+ req: { header: (n: string) => n === 'Authorization' ? 'Bearer admin-token' : null },
78
+ set: (k: string, v: any) => { if (k === 'user') contextUser = v; },
79
+ json: () => {}
80
+ };
81
+ let called = false;
82
+ await authMiddleware(mockCtx, async () => { called = true; });
83
+ expect(called).toBe(true);
84
+ expect(contextUser.roles).toContain('System Manager');
85
+ expect(contextUser.session_id).toBeDefined();
86
+ });
87
+
88
+ it('should assign Guest role when no auth header', async () => {
89
+ let contextUser: any = null;
90
+ const mockCtx: any = {
91
+ req: { header: () => null },
92
+ set: (k: string, v: any) => { if (k === 'user') contextUser = v; }
93
+ };
94
+ let called = false;
95
+ await authMiddleware(mockCtx, async () => { called = true; });
96
+ expect(called).toBe(true);
97
+ expect(contextUser.roles).toContain('Guest');
98
+ expect(contextUser.session_id).toBeDefined();
99
+ });
100
+
101
+ it('should reject empty bearer tokens', async () => {
102
+ let statusCode = 0;
103
+ const mockCtx: any = {
104
+ req: { header: (n: string) => n === 'Authorization' ? 'Bearer ' : null },
105
+ set: () => {},
106
+ json: (_body: any, status: number) => { statusCode = status; }
107
+ };
108
+ await authMiddleware(mockCtx, async () => {});
109
+ expect(statusCode).toBe(401);
110
+ });
111
+ });
112
+
113
+ describe('Controller Lifecycle Hooks', () => {
114
+ it('should execute registered hook', async () => {
115
+ let hookCalled = false;
116
+ class TestCtrl extends DocTypeController {
117
+ override async before_insert() { hookCalled = true; }
118
+ }
119
+ controllerRegistry.register('Project', new TestCtrl());
120
+ await controllerRegistry.triggerBeforeInsert('Project', {}, { db: {} as any, user: { email: 'a', roles: [] } });
121
+ expect(hookCalled).toBe(true);
122
+ });
123
+
124
+ it('should not throw for unregistered doctypes', async () => {
125
+ await controllerRegistry.triggerBeforeInsert('NonExistent', {}, { db: {} as any, user: { email: 'a', roles: [] } });
126
+ });
127
+ });
128
+
129
+ describe('Link Validation', () => {
130
+ it('should pass if no link fields or values are present', async () => {
131
+ const { validateLinks } = await import('./utils/link-validator.js');
132
+ const docDef: any = {
133
+ name: 'Task',
134
+ fields: [{ fieldname: 'title', fieldtype: 'Text' }]
135
+ };
136
+ const errors = await validateLinks(docDef, { title: 'Hello' }, { query: async () => ({ rows: [] }) });
137
+ expect(errors.length).toBe(0);
138
+ });
139
+
140
+ it('should fail if link target does not exist in db', async () => {
141
+ const { validateLinks } = await import('./utils/link-validator.js');
142
+ const docDef: any = {
143
+ name: 'Task',
144
+ fields: [{ fieldname: 'project', fieldtype: 'Link', options: 'Project' }]
145
+ };
146
+ const mockClient = {
147
+ query: async () => ({ rows: [] }) // Empty means not found
148
+ };
149
+ const errors = await validateLinks(docDef, { project: 'Proj-1' }, mockClient);
150
+ expect(errors.length).toBe(1);
151
+ expect(errors[0]).toContain('does not exist');
152
+ });
153
+
154
+ it('should pass if link target exists in db', async () => {
155
+ const { validateLinks } = await import('./utils/link-validator.js');
156
+ const docDef: any = {
157
+ name: 'Task',
158
+ fields: [{ fieldname: 'project', fieldtype: 'Link', options: 'Project' }]
159
+ };
160
+ const mockClient = {
161
+ query: async () => ({ rows: [{ name: 'Proj-1' }] }) // Found
162
+ };
163
+ const errors = await validateLinks(docDef, { project: 'Proj-1' }, mockClient);
164
+ expect(errors.length).toBe(0);
165
+ });
166
+ });
167
+
168
+ describe('Rate Limiter Middleware', () => {
169
+ it('should allow requests within limit and block when exceeded', async () => {
170
+ const { rateLimitMiddleware } = await import('./middleware/rate-limit.js');
171
+ const { config } = await import('./config.js');
172
+
173
+ // Temporarily reduce rate limit to 2 for testing
174
+ const originalMax = config.rateLimitMax;
175
+ config.rateLimitMax = 2;
176
+
177
+ const mockCtx: any = {
178
+ get: (k: string) => k === 'user' ? { email: 'test@user.com' } : null,
179
+ header: () => {},
180
+ req: { header: () => null },
181
+ json: (body: any, status: number) => ({ body, status })
182
+ };
183
+
184
+ let called = 0;
185
+ const next = async () => { called++; };
186
+
187
+ // Request 1: Allow
188
+ let res = await rateLimitMiddleware(mockCtx, next);
189
+ expect(res).toBeUndefined();
190
+ expect(called).toBe(1);
191
+
192
+ // Request 2: Allow
193
+ res = await rateLimitMiddleware(mockCtx, next);
194
+ expect(res).toBeUndefined();
195
+ expect(called).toBe(2);
196
+
197
+ // Request 3: Block (returns Hono response representation)
198
+ res = await rateLimitMiddleware(mockCtx, next);
199
+ expect(res).toBeDefined();
200
+ expect(res!.status).toBe(429);
201
+ expect(called).toBe(2); // next not called
202
+
203
+ // Restore rate limit config
204
+ config.rateLimitMax = originalMax;
205
+ });
206
+ });
207
+
208
+ describe('Body Limit Middleware', () => {
209
+ it('should block requests exceeding bodyLimitBytes', async () => {
210
+ const { bodyLimitMiddleware } = await import('./middleware/body-limit.js');
211
+ const { config } = await import('./config.js');
212
+
213
+ const originalLimit = config.bodyLimitBytes;
214
+ config.bodyLimitBytes = 100; // 100 bytes
215
+
216
+ const mockCtx: any = {
217
+ req: { header: (k: string) => k === 'content-length' ? '150' : null },
218
+ json: (body: any, status: number) => ({ body, status })
219
+ };
220
+
221
+ const next = async () => {};
222
+ const res = await bodyLimitMiddleware(mockCtx, next);
223
+ expect(res).toBeDefined();
224
+ expect(res!.status).toBe(413);
225
+
226
+ config.bodyLimitBytes = originalLimit;
227
+ });
228
+
229
+ it('should allow requests within limit', async () => {
230
+ const { bodyLimitMiddleware } = await import('./middleware/body-limit.js');
231
+ const mockCtx: any = {
232
+ req: { header: (k: string) => k === 'content-length' ? '50' : null },
233
+ json: () => {}
234
+ };
235
+
236
+ let called = false;
237
+ const next = async () => { called = true; };
238
+ await bodyLimitMiddleware(mockCtx, next);
239
+ expect(called).toBe(true);
240
+ });
241
+ });
242
+
243
+ describe('Amend & Bulk Route Handlers', () => {
244
+ it('should process bulk insert successfully', async () => {
245
+ const { Hono } = await import('hono');
246
+ const { crudRouter } = await import('./routes/crud.js');
247
+ const app = new Hono<any>();
248
+
249
+ app.use('*', async (c, next) => {
250
+ c.set('user', { email: 'test@user.com', roles: ['System Manager'] });
251
+ await next();
252
+ });
253
+ app.route('/api/doctype/:doctype', crudRouter);
254
+
255
+ const res = await app.request('/api/doctype/Project/bulk', {
256
+ method: 'POST',
257
+ headers: { 'Content-Type': 'application/json' },
258
+ body: JSON.stringify({
259
+ action: 'insert',
260
+ items: [{ project_name: 'Bulk Proj 1' }]
261
+ })
262
+ });
263
+ expect(res.status).toBe(200);
264
+ const body = await res.json();
265
+ expect(body.success).toBe(true);
266
+ expect(body.count).toBe(1);
267
+ });
268
+
269
+ it('should fail bulk insert with invalid data', async () => {
270
+ const { Hono } = await import('hono');
271
+ const { crudRouter } = await import('./routes/crud.js');
272
+ const app = new Hono<any>();
273
+
274
+ app.use('*', async (c, next) => {
275
+ c.set('user', { email: 'test@user.com', roles: ['System Manager'] });
276
+ await next();
277
+ });
278
+ app.route('/api/doctype/:doctype', crudRouter);
279
+
280
+ // Schema validator fails when required fields are missing
281
+ // Project name is not present in items
282
+ const res = await app.request('/api/doctype/Project/bulk', {
283
+ method: 'POST',
284
+ headers: { 'Content-Type': 'application/json' },
285
+ body: JSON.stringify({
286
+ action: 'insert',
287
+ items: [{ wrong_field: 'Wrong' }]
288
+ })
289
+ });
290
+ expect(res.status).toBe(500);
291
+ const body = await res.json();
292
+ expect(body.error).toContain('Bulk transaction rolled back');
293
+ });
294
+ });
295
+
296
+ describe('File Attachments Router', () => {
297
+ it('should block file uploads for Guest users', async () => {
298
+ const { Hono } = await import('hono');
299
+ const { filesRouter } = await import('./routes/files.js');
300
+ const app = new Hono<any>();
301
+ app.use('*', async (c, next) => {
302
+ c.set('user', { email: 'guest@user.com', roles: ['Guest'] });
303
+ await next();
304
+ });
305
+ app.route('/api/files', filesRouter);
306
+
307
+ const formData = new FormData();
308
+ formData.append('file', new File(['hello'], 'hello.txt', { type: 'text/plain' }));
309
+
310
+ const res = await app.request('/api/files/upload', {
311
+ method: 'POST',
312
+ body: formData
313
+ });
314
+ expect(res.status).toBe(401);
315
+ });
316
+ });
317
+
318
+ describe('Workflow Engine Router', () => {
319
+ it('should successfully execute transition if user has allowed role', async () => {
320
+ const { Hono } = await import('hono');
321
+ const { crudRouter } = await import('./routes/crud.js');
322
+ const app = new Hono<any>();
323
+ app.use('*', async (c, next) => {
324
+ c.set('user', { email: 'manager@user.com', roles: ['System Manager'] });
325
+ await next();
326
+ });
327
+ app.route('/api/doctype/:doctype', crudRouter);
328
+
329
+ const res = await app.request('/api/doctype/Ticket/TKT-100/workflow', {
330
+ method: 'POST',
331
+ headers: { 'Content-Type': 'application/json' },
332
+ body: JSON.stringify({ action: 'Approve' })
333
+ });
334
+ expect(res.status).toBe(200);
335
+ const body = await res.json();
336
+ expect(body.workflow_state).toBe('Approved');
337
+ });
338
+
339
+ it('should reject transition if user does not have allowed role', async () => {
340
+ const { Hono } = await import('hono');
341
+ const { crudRouter } = await import('./routes/crud.js');
342
+ const app = new Hono<any>();
343
+ app.use('*', async (c, next) => {
344
+ c.set('user', { email: 'worker@user.com', roles: ['Employee'] });
345
+ await next();
346
+ });
347
+ app.route('/api/doctype/:doctype', crudRouter);
348
+
349
+ const res = await app.request('/api/doctype/Ticket/TKT-100/workflow', {
350
+ method: 'POST',
351
+ headers: { 'Content-Type': 'application/json' },
352
+ body: JSON.stringify({ action: 'Approve' })
353
+ });
354
+ expect(res.status).toBe(403);
355
+ const body = await res.json();
356
+ expect(body.error).toContain('Workflow transition failed');
357
+ });
358
+ });
359
+
360
+
361
+
362
+