@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.
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +34 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +3 -0
- package/dist/context.js.map +1 -0
- package/dist/controllers/index.d.ts +39 -0
- package/dist/controllers/index.d.ts.map +1 -0
- package/dist/controllers/index.js +39 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/db.d.ts +6 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +74 -0
- package/dist/db.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +154 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.d.ts +15 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +93 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/body-limit.d.ts +9 -0
- package/dist/middleware/body-limit.d.ts.map +1 -0
- package/dist/middleware/body-limit.js +15 -0
- package/dist/middleware/body-limit.js.map +1 -0
- package/dist/middleware/rate-limit.d.ts +6 -0
- package/dist/middleware/rate-limit.d.ts.map +1 -0
- package/dist/middleware/rate-limit.js +40 -0
- package/dist/middleware/rate-limit.js.map +1 -0
- package/dist/middleware/rbac.d.ts +10 -0
- package/dist/middleware/rbac.d.ts.map +1 -0
- package/dist/middleware/rbac.js +61 -0
- package/dist/middleware/rbac.js.map +1 -0
- package/dist/middleware/tenant.d.ts +3 -0
- package/dist/middleware/tenant.d.ts.map +1 -0
- package/dist/middleware/tenant.js +19 -0
- package/dist/middleware/tenant.js.map +1 -0
- package/dist/registry.d.ts +26 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +112 -0
- package/dist/registry.js.map +1 -0
- package/dist/routes/auth.d.ts +3 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +141 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/crud.d.ts +7 -0
- package/dist/routes/crud.d.ts.map +1 -0
- package/dist/routes/crud.js +845 -0
- package/dist/routes/crud.js.map +1 -0
- package/dist/routes/files.d.ts +7 -0
- package/dist/routes/files.d.ts.map +1 -0
- package/dist/routes/files.js +123 -0
- package/dist/routes/files.js.map +1 -0
- package/dist/routes/meta.d.ts +3 -0
- package/dist/routes/meta.d.ts.map +1 -0
- package/dist/routes/meta.js +352 -0
- package/dist/routes/meta.js.map +1 -0
- package/dist/scheduler.d.ts +33 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +97 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/utils/link-validator.d.ts +7 -0
- package/dist/utils/link-validator.d.ts.map +1 -0
- package/dist/utils/link-validator.js +33 -0
- package/dist/utils/link-validator.js.map +1 -0
- package/dist/utils/resolver.d.ts +5 -0
- package/dist/utils/resolver.d.ts.map +1 -0
- package/dist/utils/resolver.js +58 -0
- package/dist/utils/resolver.js.map +1 -0
- package/package.json +24 -0
- package/src/api.test.ts +362 -0
- package/src/config.d.ts +22 -0
- package/src/config.d.ts.map +1 -0
- package/src/config.js +34 -0
- package/src/config.js.map +1 -0
- package/src/config.ts +38 -0
- package/src/context.d.ts +7 -0
- package/src/context.d.ts.map +1 -0
- package/src/context.js +3 -0
- package/src/context.js.map +1 -0
- package/src/context.ts +8 -0
- package/src/controllers/index.d.ts +39 -0
- package/src/controllers/index.d.ts.map +1 -0
- package/src/controllers/index.js +39 -0
- package/src/controllers/index.js.map +1 -0
- package/src/controllers/index.ts +51 -0
- package/src/db.d.ts +6 -0
- package/src/db.d.ts.map +1 -0
- package/src/db.js +74 -0
- package/src/db.js.map +1 -0
- package/src/db.ts +73 -0
- package/src/index.ts +178 -0
- package/src/integration.test.ts +453 -0
- package/src/middleware/auth.d.ts +15 -0
- package/src/middleware/auth.d.ts.map +1 -0
- package/src/middleware/auth.js +93 -0
- package/src/middleware/auth.js.map +1 -0
- package/src/middleware/auth.ts +109 -0
- package/src/middleware/body-limit.d.ts +9 -0
- package/src/middleware/body-limit.d.ts.map +1 -0
- package/src/middleware/body-limit.js +15 -0
- package/src/middleware/body-limit.js.map +1 -0
- package/src/middleware/body-limit.ts +16 -0
- package/src/middleware/rate-limit.d.ts +6 -0
- package/src/middleware/rate-limit.d.ts.map +1 -0
- package/src/middleware/rate-limit.js +40 -0
- package/src/middleware/rate-limit.js.map +1 -0
- package/src/middleware/rate-limit.ts +47 -0
- package/src/middleware/rbac.d.ts +10 -0
- package/src/middleware/rbac.d.ts.map +1 -0
- package/src/middleware/rbac.js +61 -0
- package/src/middleware/rbac.js.map +1 -0
- package/src/middleware/rbac.ts +71 -0
- package/src/middleware/tenant.d.ts +3 -0
- package/src/middleware/tenant.d.ts.map +1 -0
- package/src/middleware/tenant.js +19 -0
- package/src/middleware/tenant.js.map +1 -0
- package/src/middleware/tenant.ts +24 -0
- package/src/registry.d.ts +26 -0
- package/src/registry.d.ts.map +1 -0
- package/src/registry.js +112 -0
- package/src/registry.js.map +1 -0
- package/src/registry.ts +123 -0
- package/src/routes/auth.d.ts +3 -0
- package/src/routes/auth.d.ts.map +1 -0
- package/src/routes/auth.js +141 -0
- package/src/routes/auth.js.map +1 -0
- package/src/routes/auth.ts +164 -0
- package/src/routes/crud.d.ts +7 -0
- package/src/routes/crud.d.ts.map +1 -0
- package/src/routes/crud.js +845 -0
- package/src/routes/crud.js.map +1 -0
- package/src/routes/crud.ts +1029 -0
- package/src/routes/files.d.ts +7 -0
- package/src/routes/files.d.ts.map +1 -0
- package/src/routes/files.js +123 -0
- package/src/routes/files.js.map +1 -0
- package/src/routes/files.ts +143 -0
- package/src/routes/meta.d.ts +3 -0
- package/src/routes/meta.d.ts.map +1 -0
- package/src/routes/meta.js +352 -0
- package/src/routes/meta.js.map +1 -0
- package/src/routes/meta.ts +448 -0
- package/src/scheduler.ts +118 -0
- package/src/utils/link-validator.d.ts +7 -0
- package/src/utils/link-validator.d.ts.map +1 -0
- package/src/utils/link-validator.js +33 -0
- package/src/utils/link-validator.js.map +1 -0
- package/src/utils/link-validator.ts +45 -0
- package/src/utils/resolver.d.ts +5 -0
- package/src/utils/resolver.d.ts.map +1 -0
- package/src/utils/resolver.js +58 -0
- package/src/utils/resolver.js.map +1 -0
- package/src/utils/resolver.ts +65 -0
- 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 @@
|
|
|
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
|
+
}
|
package/src/api.test.ts
ADDED
|
@@ -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
|
+
|