@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,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
+ });
@@ -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,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":["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"}