@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,448 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { validateDocType, generateCreateTableDDL, generateAlterTableDDL, getTableName, DocTypeDefinition } from '@aruvili/core';
|
|
3
|
+
import { query, withTransaction } from '../db.js';
|
|
4
|
+
import { registry } from '../registry.js';
|
|
5
|
+
|
|
6
|
+
export const metaRouter = new Hono();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Helper to fetch existing columns in target table from postgres database catalog.
|
|
10
|
+
*/
|
|
11
|
+
async function getExistingColumns(client: any, tableName: string, schema: string) {
|
|
12
|
+
const sql = `
|
|
13
|
+
SELECT
|
|
14
|
+
column_name AS "columnName",
|
|
15
|
+
data_type AS "dataType",
|
|
16
|
+
is_nullable = 'YES' AS "isNullable",
|
|
17
|
+
character_maximum_length AS "characterMaximumLength"
|
|
18
|
+
FROM information_schema.columns
|
|
19
|
+
WHERE table_name = $1 AND table_schema = $2
|
|
20
|
+
`;
|
|
21
|
+
const res = await client.query(sql, [tableName, schema]);
|
|
22
|
+
return res.rows;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Helper to execute schema synchronization across all registered tenant schemas.
|
|
27
|
+
*/
|
|
28
|
+
async function syncDatabaseSchema(client: any, definition: DocTypeDefinition) {
|
|
29
|
+
const tableName = getTableName(definition.name);
|
|
30
|
+
|
|
31
|
+
// Fetch all active tenants
|
|
32
|
+
const tenantsRes = await client.query('SELECT id FROM _tenants');
|
|
33
|
+
const tenantIds = tenantsRes.rows.map((r: any) => r.id);
|
|
34
|
+
|
|
35
|
+
// Sync both public schema and all tenant schemas
|
|
36
|
+
const schemas = Array.from(new Set(['public', ...tenantIds]));
|
|
37
|
+
let totalDdlExecuted: string[] = [];
|
|
38
|
+
|
|
39
|
+
for (const schema of schemas) {
|
|
40
|
+
// 1. Temporarily swap search path to target schema
|
|
41
|
+
await client.query(`SET search_path TO ${schema}, public`);
|
|
42
|
+
|
|
43
|
+
// 2. Check if table exists in the target schema
|
|
44
|
+
const checkTableRes = await client.query(
|
|
45
|
+
`SELECT EXISTS (
|
|
46
|
+
SELECT FROM information_schema.tables
|
|
47
|
+
WHERE table_name = $1 AND table_schema = $2
|
|
48
|
+
)`,
|
|
49
|
+
[tableName, schema]
|
|
50
|
+
);
|
|
51
|
+
const tableExists = checkTableRes.rows[0].exists;
|
|
52
|
+
|
|
53
|
+
let ddl: string[] = [];
|
|
54
|
+
if (!tableExists) {
|
|
55
|
+
ddl = generateCreateTableDDL(definition);
|
|
56
|
+
} else {
|
|
57
|
+
const cols = await getExistingColumns(client, tableName, schema);
|
|
58
|
+
ddl = generateAlterTableDDL(definition, cols);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. Run migration DDL
|
|
62
|
+
for (const sql of ddl) {
|
|
63
|
+
await client.query(sql);
|
|
64
|
+
totalDdlExecuted.push(`[${schema}] ${sql}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return totalDdlExecuted;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* POST /api/meta/doctypes
|
|
73
|
+
* Create a new DocType. Fails with 409 Conflict if duplicate.
|
|
74
|
+
*/
|
|
75
|
+
metaRouter.post('/doctypes', async (c) => {
|
|
76
|
+
const definition = await c.req.json() as DocTypeDefinition;
|
|
77
|
+
|
|
78
|
+
// 1. Perform structural integrity checks on the submitted definition
|
|
79
|
+
const valResult = validateDocType(definition);
|
|
80
|
+
if (!valResult.valid) {
|
|
81
|
+
return c.json({ error: 'Schema validation failed', details: valResult.errors }, 400);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const output = await withTransaction(async (client) => {
|
|
86
|
+
// 2. Check if DocType already exists to satisfy Task 2.1
|
|
87
|
+
const checkExists = await client.query('SELECT 1 FROM _doctype_meta WHERE name = $1', [definition.name]);
|
|
88
|
+
if (checkExists.rows.length > 0) {
|
|
89
|
+
throw new Error(`Conflict: DocType '${definition.name}' already exists.`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Insert active metadata registry record
|
|
93
|
+
await client.query(
|
|
94
|
+
`INSERT INTO _doctype_meta (name, definition, updated_at)
|
|
95
|
+
VALUES ($1, $2, NOW())`,
|
|
96
|
+
[definition.name, JSON.stringify(definition)]
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// 4. Perform dynamic schema synchronization across all tenant databases
|
|
100
|
+
const ddlExecuted = await syncDatabaseSchema(client, definition);
|
|
101
|
+
|
|
102
|
+
// 5. Log migration history
|
|
103
|
+
if (ddlExecuted.length > 0) {
|
|
104
|
+
await client.query(
|
|
105
|
+
`INSERT INTO _doctype_migration_history (doctype_name, version, ddl_executed, executed_by)
|
|
106
|
+
VALUES ($1, 1, $2, $3)`,
|
|
107
|
+
[definition.name, ddlExecuted.join('\n'), 'System Admin']
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 6. Log version history tracking (Task 2.3)
|
|
112
|
+
await client.query(
|
|
113
|
+
`INSERT INTO _doctype_definition_history (doctype_name, version, definition, changed_by, ddl_applied, notes)
|
|
114
|
+
VALUES ($1, 1, $2, $3, $4, $5)`,
|
|
115
|
+
[
|
|
116
|
+
definition.name,
|
|
117
|
+
JSON.stringify(definition),
|
|
118
|
+
'System Admin',
|
|
119
|
+
ddlExecuted.join('\n'),
|
|
120
|
+
'Initial definition creation'
|
|
121
|
+
]
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Invalidate cache immediately
|
|
125
|
+
registry.invalidate(definition.name);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
doctype: definition.name,
|
|
129
|
+
table: getTableName(definition.name),
|
|
130
|
+
action: 'CREATED',
|
|
131
|
+
version: 1,
|
|
132
|
+
changes_applied: ddlExecuted
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return c.json(output);
|
|
137
|
+
} catch (err: any) {
|
|
138
|
+
if (err.message.includes('Conflict:')) {
|
|
139
|
+
return c.json({ error: err.message }, 409);
|
|
140
|
+
}
|
|
141
|
+
console.error('Doctype creation failed:', err);
|
|
142
|
+
return c.json({ error: 'Failed to create database metadata', details: err.message }, 500);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* PUT /api/meta/doctypes/:name
|
|
148
|
+
* Update an existing DocType definition.
|
|
149
|
+
*/
|
|
150
|
+
metaRouter.put('/doctypes/:name', async (c) => {
|
|
151
|
+
const name = c.req.param('name');
|
|
152
|
+
const definition = await c.req.json() as DocTypeDefinition;
|
|
153
|
+
|
|
154
|
+
if (definition.name !== name) {
|
|
155
|
+
return c.json({ error: 'Doctype name mismatch between URL and payload' }, 400);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 1. Perform structural integrity checks on the submitted definition
|
|
159
|
+
const valResult = validateDocType(definition);
|
|
160
|
+
if (!valResult.valid) {
|
|
161
|
+
return c.json({ error: 'Schema validation failed', details: valResult.errors }, 400);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const output = await withTransaction(async (client) => {
|
|
166
|
+
// 2. Verify existence
|
|
167
|
+
const checkExists = await client.query('SELECT 1 FROM _doctype_meta WHERE name = $1', [name]);
|
|
168
|
+
if (checkExists.rows.length === 0) {
|
|
169
|
+
throw new Error(`NotFound: DocType '${name}' does not exist.`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 3. Compute next version number
|
|
173
|
+
const versionRes = await client.query(
|
|
174
|
+
'SELECT COALESCE(MAX(version), 0) + 1 AS next FROM _doctype_definition_history WHERE doctype_name = $1',
|
|
175
|
+
[name]
|
|
176
|
+
);
|
|
177
|
+
const nextVersion = versionRes.rows[0].next;
|
|
178
|
+
|
|
179
|
+
// 4. Perform dynamic schema synchronization across all tenant databases
|
|
180
|
+
const ddlExecuted = await syncDatabaseSchema(client, definition);
|
|
181
|
+
|
|
182
|
+
// 5. Update active registry
|
|
183
|
+
await client.query(
|
|
184
|
+
`UPDATE _doctype_meta SET definition = $2, updated_at = NOW() WHERE name = $1`,
|
|
185
|
+
[name, JSON.stringify(definition)]
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// 6. Log migration history
|
|
189
|
+
if (ddlExecuted.length > 0) {
|
|
190
|
+
await client.query(
|
|
191
|
+
`INSERT INTO _doctype_migration_history (doctype_name, version, ddl_executed, executed_by)
|
|
192
|
+
VALUES ($1, $2, $3, $4)`,
|
|
193
|
+
[name, nextVersion, ddlExecuted.join('\n'), 'System Admin']
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 7. Log version history tracking (Task 2.3)
|
|
198
|
+
await client.query(
|
|
199
|
+
`INSERT INTO _doctype_definition_history (doctype_name, version, definition, changed_by, ddl_applied, notes)
|
|
200
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
201
|
+
[
|
|
202
|
+
name,
|
|
203
|
+
nextVersion,
|
|
204
|
+
JSON.stringify(definition),
|
|
205
|
+
'System Admin',
|
|
206
|
+
ddlExecuted.join('\n'),
|
|
207
|
+
'Updated definition schema'
|
|
208
|
+
]
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// Invalidate cache
|
|
212
|
+
registry.invalidate(name);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
doctype: name,
|
|
216
|
+
table: getTableName(name),
|
|
217
|
+
action: 'MIGRATED',
|
|
218
|
+
version: nextVersion,
|
|
219
|
+
changes_applied: ddlExecuted
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return c.json(output);
|
|
224
|
+
} catch (err: any) {
|
|
225
|
+
if (err.message.includes('NotFound:')) {
|
|
226
|
+
return c.json({ error: err.message }, 404);
|
|
227
|
+
}
|
|
228
|
+
console.error('Doctype update failed:', err);
|
|
229
|
+
return c.json({ error: 'Failed to update database metadata', details: err.message }, 500);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* GET /api/meta/doctypes/:name/history
|
|
235
|
+
* Retrieve historical configuration log.
|
|
236
|
+
*/
|
|
237
|
+
metaRouter.get('/doctypes/:name/history', async (c) => {
|
|
238
|
+
const name = c.req.param('name');
|
|
239
|
+
try {
|
|
240
|
+
const res = await query(
|
|
241
|
+
`SELECT version, changed_by, changed_at, notes, ddl_applied, definition
|
|
242
|
+
FROM _doctype_definition_history
|
|
243
|
+
WHERE doctype_name = $1
|
|
244
|
+
ORDER BY version DESC`,
|
|
245
|
+
[name]
|
|
246
|
+
);
|
|
247
|
+
return c.json(res.rows);
|
|
248
|
+
} catch (err: any) {
|
|
249
|
+
return c.json({ error: 'Failed to fetch metadata history', details: err.message }, 500);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* POST /api/meta/doctypes/:name/revert
|
|
255
|
+
* Revert doctype metadata schema and trigger safe migrations to target version.
|
|
256
|
+
*/
|
|
257
|
+
metaRouter.post('/doctypes/:name/revert', async (c) => {
|
|
258
|
+
const name = c.req.param('name');
|
|
259
|
+
const body = await c.req.json();
|
|
260
|
+
const targetVersion = body.version;
|
|
261
|
+
|
|
262
|
+
if (typeof targetVersion !== 'number') {
|
|
263
|
+
return c.json({ error: 'Missing or invalid version number in body' }, 400);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const output = await withTransaction(async (client) => {
|
|
268
|
+
// 1. Retrieve definition from history
|
|
269
|
+
const historyRes = await client.query(
|
|
270
|
+
`SELECT definition FROM _doctype_definition_history
|
|
271
|
+
WHERE doctype_name = $1 AND version = $2`,
|
|
272
|
+
[name, targetVersion]
|
|
273
|
+
);
|
|
274
|
+
if (historyRes.rows.length === 0) {
|
|
275
|
+
throw new Error(`NotFound: Version ${targetVersion} for DocType '${name}' was not found.`);
|
|
276
|
+
}
|
|
277
|
+
const targetDefinition = historyRes.rows[0].definition as DocTypeDefinition;
|
|
278
|
+
|
|
279
|
+
// 2. Compute next version number
|
|
280
|
+
const versionRes = await client.query(
|
|
281
|
+
'SELECT COALESCE(MAX(version), 0) + 1 AS next FROM _doctype_definition_history WHERE doctype_name = $1',
|
|
282
|
+
[name]
|
|
283
|
+
);
|
|
284
|
+
const nextVersion = versionRes.rows[0].next;
|
|
285
|
+
|
|
286
|
+
// 3. Perform dynamic schema synchronization across all tenant databases
|
|
287
|
+
const ddlExecuted = await syncDatabaseSchema(client, targetDefinition);
|
|
288
|
+
|
|
289
|
+
// 4. Update active registry
|
|
290
|
+
await client.query(
|
|
291
|
+
`UPDATE _doctype_meta SET definition = $2, updated_at = NOW() WHERE name = $1`,
|
|
292
|
+
[name, JSON.stringify(targetDefinition)]
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// 5. Log migration history
|
|
296
|
+
if (ddlExecuted.length > 0) {
|
|
297
|
+
await client.query(
|
|
298
|
+
`INSERT INTO _doctype_migration_history (doctype_name, version, ddl_executed, executed_by)
|
|
299
|
+
VALUES ($1, $2, $3, $4)`,
|
|
300
|
+
[name, nextVersion, ddlExecuted.join('\n'), 'System Admin']
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 6. Log version history tracking
|
|
305
|
+
await client.query(
|
|
306
|
+
`INSERT INTO _doctype_definition_history (doctype_name, version, definition, changed_by, ddl_applied, notes)
|
|
307
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
308
|
+
[
|
|
309
|
+
name,
|
|
310
|
+
nextVersion,
|
|
311
|
+
JSON.stringify(targetDefinition),
|
|
312
|
+
'System Admin',
|
|
313
|
+
ddlExecuted.join('\n'),
|
|
314
|
+
`Reverted active schema to version ${targetVersion}`
|
|
315
|
+
]
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// Invalidate cache
|
|
319
|
+
registry.invalidate(name);
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
doctype: name,
|
|
323
|
+
table: getTableName(name),
|
|
324
|
+
action: 'REVERTED',
|
|
325
|
+
version: nextVersion,
|
|
326
|
+
changes_applied: ddlExecuted
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return c.json(output);
|
|
331
|
+
} catch (err: any) {
|
|
332
|
+
if (err.message.includes('NotFound:')) {
|
|
333
|
+
return c.json({ error: err.message }, 404);
|
|
334
|
+
}
|
|
335
|
+
console.error('Revert failed:', err);
|
|
336
|
+
return c.json({ error: 'Failed to revert metadata schema', details: err.message }, 500);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* POST /api/meta/tenants
|
|
342
|
+
* Register a new tenant, create its Postgres schema, and migrate all existing definitions.
|
|
343
|
+
*/
|
|
344
|
+
metaRouter.post('/tenants', async (c) => {
|
|
345
|
+
const body = await c.req.json();
|
|
346
|
+
const { id, name, domain } = body;
|
|
347
|
+
|
|
348
|
+
if (!id || !name) {
|
|
349
|
+
return c.json({ error: 'Tenant id and name are required' }, 400);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const TENANT_REGEX = /^[a-zA-Z0-9_]{1,63}$/;
|
|
353
|
+
if (!TENANT_REGEX.test(id)) {
|
|
354
|
+
return c.json({ error: 'Invalid Tenant ID format' }, 400);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const tenantId = id.toLowerCase().trim();
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const output = await withTransaction(async (client) => {
|
|
361
|
+
// 1. Check if tenant already exists
|
|
362
|
+
const checkRes = await client.query('SELECT 1 FROM _tenants WHERE id = $1', [tenantId]);
|
|
363
|
+
if (checkRes.rows.length > 0) {
|
|
364
|
+
throw new Error(`Conflict: Tenant '${tenantId}' already exists.`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 2. Insert tenant record into central _tenants table
|
|
368
|
+
await client.query(
|
|
369
|
+
`INSERT INTO _tenants (id, name, domain) VALUES ($1, $2, $3)`,
|
|
370
|
+
[tenantId, name, domain || null]
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
// 3. Create the schema for the tenant
|
|
374
|
+
await client.query(`CREATE SCHEMA IF NOT EXISTS ${tenantId}`);
|
|
375
|
+
|
|
376
|
+
// 4. Retrieve all current doctypes to copy schemas to the new tenant schema
|
|
377
|
+
const doctypesRes = await client.query('SELECT definition FROM _doctype_meta');
|
|
378
|
+
const doctypes = doctypesRes.rows.map((row: any) => row.definition as DocTypeDefinition);
|
|
379
|
+
|
|
380
|
+
const createdTables: string[] = [];
|
|
381
|
+
|
|
382
|
+
// Temporarily swap search_path to the new tenant schema to run table creations
|
|
383
|
+
await client.query(`SET search_path TO ${tenantId}, public`);
|
|
384
|
+
|
|
385
|
+
for (const doc of doctypes) {
|
|
386
|
+
const ddl = generateCreateTableDDL(doc);
|
|
387
|
+
for (const sql of ddl) {
|
|
388
|
+
await client.query(sql);
|
|
389
|
+
}
|
|
390
|
+
createdTables.push(doc.name);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
tenantId,
|
|
395
|
+
name,
|
|
396
|
+
domain,
|
|
397
|
+
createdTables
|
|
398
|
+
};
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
return c.json(output);
|
|
402
|
+
} catch (err: any) {
|
|
403
|
+
if (err.message.includes('Conflict:')) {
|
|
404
|
+
return c.json({ error: err.message }, 409);
|
|
405
|
+
}
|
|
406
|
+
console.error('Tenant registration failed:', err);
|
|
407
|
+
return c.json({ error: 'Failed to register tenant', details: err.message }, 500);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* GET /api/meta/tenants
|
|
413
|
+
* List all registered tenants.
|
|
414
|
+
*/
|
|
415
|
+
metaRouter.get('/tenants', async (c) => {
|
|
416
|
+
try {
|
|
417
|
+
const res = await query('SELECT id, name, domain, created_at FROM _tenants ORDER BY id ASC');
|
|
418
|
+
return c.json(res.rows);
|
|
419
|
+
} catch (err: any) {
|
|
420
|
+
return c.json({ error: 'Failed to retrieve tenants', details: err.message }, 500);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* GET /api/meta/doctypes
|
|
426
|
+
* List all definitions.
|
|
427
|
+
*/
|
|
428
|
+
metaRouter.get('/doctypes', async (c) => {
|
|
429
|
+
try {
|
|
430
|
+
const res = await query('SELECT name, created_at, updated_at FROM _doctype_meta ORDER BY name ASC');
|
|
431
|
+
return c.json(res.rows);
|
|
432
|
+
} catch (err: any) {
|
|
433
|
+
return c.json({ error: 'Failed to retrieve doctypes list', details: err.message }, 500);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* GET /api/meta/doctypes/:name
|
|
439
|
+
* Retrieve specific definition metadata.
|
|
440
|
+
*/
|
|
441
|
+
metaRouter.get('/doctypes/:name', async (c) => {
|
|
442
|
+
const name = c.req.param('name');
|
|
443
|
+
const doc = await registry.get(name);
|
|
444
|
+
if (!doc) {
|
|
445
|
+
return c.json({ error: `DocType '${name}' not found` }, 404);
|
|
446
|
+
}
|
|
447
|
+
return c.json(doc);
|
|
448
|
+
});
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { query, withTransaction } from './db.js';
|
|
2
|
+
|
|
3
|
+
export interface BackgroundTask {
|
|
4
|
+
name: string;
|
|
5
|
+
intervalSeconds: number;
|
|
6
|
+
execute: (client: any) => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
class AruviliScheduler {
|
|
10
|
+
private tasks: BackgroundTask[] = [];
|
|
11
|
+
private timer: any = null;
|
|
12
|
+
private isRunning = false;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Registers a task with the scheduler.
|
|
16
|
+
*/
|
|
17
|
+
public register(task: BackgroundTask) {
|
|
18
|
+
this.tasks.push(task);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Starts the scheduler loop.
|
|
23
|
+
*/
|
|
24
|
+
public async start(tickIntervalMs: number = 10000) {
|
|
25
|
+
if (this.isRunning) return;
|
|
26
|
+
this.isRunning = true;
|
|
27
|
+
|
|
28
|
+
// 1. Ensure the jobs tracking table exists
|
|
29
|
+
await query(`
|
|
30
|
+
CREATE TABLE IF NOT EXISTS _scheduled_jobs (
|
|
31
|
+
name VARCHAR(255) PRIMARY KEY,
|
|
32
|
+
last_started TIMESTAMP WITH TIME ZONE,
|
|
33
|
+
last_completed TIMESTAMP WITH TIME ZONE,
|
|
34
|
+
status VARCHAR(50),
|
|
35
|
+
error_message TEXT
|
|
36
|
+
);
|
|
37
|
+
`);
|
|
38
|
+
|
|
39
|
+
console.log(`[SCHEDULER] Started scheduler with tick interval: ${tickIntervalMs}ms. Registered tasks: ${this.tasks.length}`);
|
|
40
|
+
|
|
41
|
+
// Run the scheduler tick loop
|
|
42
|
+
this.timer = setInterval(async () => {
|
|
43
|
+
await this.tick();
|
|
44
|
+
}, tickIntervalMs);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Stops the scheduler.
|
|
49
|
+
*/
|
|
50
|
+
public stop() {
|
|
51
|
+
if (this.timer) {
|
|
52
|
+
clearInterval(this.timer);
|
|
53
|
+
this.timer = null;
|
|
54
|
+
}
|
|
55
|
+
this.isRunning = false;
|
|
56
|
+
console.log('[SCHEDULER] Scheduler stopped.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Performs a single scheduler loop check.
|
|
61
|
+
*/
|
|
62
|
+
private async tick() {
|
|
63
|
+
for (const task of this.tasks) {
|
|
64
|
+
await this.runTaskDistributed(task);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Tries to lock and run a background task.
|
|
70
|
+
*/
|
|
71
|
+
private async runTaskDistributed(task: BackgroundTask) {
|
|
72
|
+
try {
|
|
73
|
+
// Try to acquire distributed lock atomically
|
|
74
|
+
const acquired = await withTransaction(async (client) => {
|
|
75
|
+
const sql = `
|
|
76
|
+
INSERT INTO _scheduled_jobs (name, last_started, status)
|
|
77
|
+
VALUES ($1, NOW(), 'RUNNING')
|
|
78
|
+
ON CONFLICT (name) DO UPDATE
|
|
79
|
+
SET last_started = NOW(), status = 'RUNNING'
|
|
80
|
+
WHERE (_scheduled_jobs.status != 'RUNNING' AND _scheduled_jobs.last_started < NOW() - (INTERVAL '1 second' * $2))
|
|
81
|
+
OR (_scheduled_jobs.status = 'RUNNING' AND _scheduled_jobs.last_started < NOW() - (INTERVAL '1 second' * $2 * 2))
|
|
82
|
+
RETURNING 1;
|
|
83
|
+
`;
|
|
84
|
+
const res = await client.query(sql, [task.name, task.intervalSeconds]);
|
|
85
|
+
return res.rows.length > 0;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!acquired) {
|
|
89
|
+
return; // Lock not acquired (already running or completed too recently)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(`[SCHEDULER] Running task: ${task.name}`);
|
|
93
|
+
const start = Date.now();
|
|
94
|
+
try {
|
|
95
|
+
await withTransaction(async (client) => {
|
|
96
|
+
await task.execute(client);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Update task state on success
|
|
100
|
+
await query(
|
|
101
|
+
`UPDATE _scheduled_jobs SET last_completed = NOW(), status = 'SUCCESS', error_message = NULL WHERE name = $1`,
|
|
102
|
+
[task.name]
|
|
103
|
+
);
|
|
104
|
+
console.log(`[SCHEDULER] Task ${task.name} finished successfully in ${Date.now() - start}ms`);
|
|
105
|
+
} catch (err: any) {
|
|
106
|
+
console.error(`[SCHEDULER ERROR] Task ${task.name} failed:`, err.message);
|
|
107
|
+
await query(
|
|
108
|
+
`UPDATE _scheduled_jobs SET last_completed = NOW(), status = 'FAILED', error_message = $2 WHERE name = $1`,
|
|
109
|
+
[task.name, err.message]
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
} catch (err: any) {
|
|
113
|
+
// Catch db concurrency locking serialization or connection errors
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const scheduler = new AruviliScheduler();
|
|
@@ -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":["link-validator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAgB,MAAM,YAAY,CAAC;AAG7D;;;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 '@meta/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":["link-validator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,YAAY,EAAE,MAAM,YAAY,CAAC;AAG7D;;;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,45 @@
|
|
|
1
|
+
import { DocTypeDefinition, getTableName } from '@aruvili/core';
|
|
2
|
+
import { registry } from '../registry.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates all Link field values exist in their target DocType tables.
|
|
6
|
+
* Prevents dangling references (foreign key integrity without DB-level FK constraints).
|
|
7
|
+
*/
|
|
8
|
+
export async function validateLinks(
|
|
9
|
+
definition: DocTypeDefinition,
|
|
10
|
+
record: Record<string, any>,
|
|
11
|
+
client: any
|
|
12
|
+
): Promise<string[]> {
|
|
13
|
+
const errors: string[] = [];
|
|
14
|
+
|
|
15
|
+
const linkFields = definition.fields.filter(
|
|
16
|
+
f => f.fieldtype === 'Link' && f.options && record[f.fieldname]
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
for (const field of linkFields) {
|
|
20
|
+
const targetDocType = field.options!;
|
|
21
|
+
const targetTable = getTableName(targetDocType);
|
|
22
|
+
const value = record[field.fieldname];
|
|
23
|
+
|
|
24
|
+
if (typeof value !== 'string' || value.trim() === '') continue;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const res = await client.query(
|
|
28
|
+
`SELECT 1 FROM ${targetTable} WHERE name = $1 LIMIT 1`,
|
|
29
|
+
[value]
|
|
30
|
+
);
|
|
31
|
+
if (res.rows.length === 0) {
|
|
32
|
+
errors.push(`Link field '${field.fieldname}': value '${value}' does not exist in ${targetDocType}.`);
|
|
33
|
+
}
|
|
34
|
+
} catch (err: any) {
|
|
35
|
+
// Table might not exist yet if DocType not registered
|
|
36
|
+
if (err.code === '42P01') { // undefined_table
|
|
37
|
+
errors.push(`Link field '${field.fieldname}': target DocType '${targetDocType}' table does not exist.`);
|
|
38
|
+
} else {
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return errors;
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolver.d.ts","sourceRoot":"","sources":["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"}
|