fastify-auto-sqlapi 0.0.5 → 0.1.2

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 (123) hide show
  1. package/AGENTS.md +15 -2
  2. package/AGENTS_BACKEND.md +144 -20
  3. package/AGENTS_FRONTEND.md +161 -67
  4. package/README.md +47 -26
  5. package/dist/bin/generate-schema.js +9 -26
  6. package/dist/bin/generate-tables.js +7 -30
  7. package/dist/bin/utils.d.ts +28 -0
  8. package/dist/bin/utils.d.ts.map +1 -1
  9. package/dist/bin/utils.js +69 -0
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/lib/cli/load-dependency.d.ts +8 -0
  13. package/dist/lib/cli/load-dependency.d.ts.map +1 -0
  14. package/dist/lib/cli/load-dependency.js +21 -0
  15. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  16. package/dist/lib/cli/mysql-introspect.js +2 -18
  17. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  18. package/dist/lib/cli/pg-introspect.js +2 -17
  19. package/dist/lib/db.d.ts +1 -0
  20. package/dist/lib/db.d.ts.map +1 -1
  21. package/dist/lib/db.js +33 -55
  22. package/dist/lib/dialect.d.ts +17 -0
  23. package/dist/lib/dialect.d.ts.map +1 -1
  24. package/dist/lib/dialect.js +32 -0
  25. package/dist/lib/engine/bulk/bulk-delete.d.ts.map +1 -1
  26. package/dist/lib/engine/bulk/bulk-delete.js +8 -2
  27. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  28. package/dist/lib/engine/bulk/bulk-upsert.js +15 -43
  29. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  30. package/dist/lib/engine/rest/delete.js +14 -12
  31. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  32. package/dist/lib/engine/rest/get.js +3 -5
  33. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  34. package/dist/lib/engine/rest/insert.js +11 -31
  35. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  36. package/dist/lib/engine/rest/update.js +13 -46
  37. package/dist/lib/engine/search/search.d.ts.map +1 -1
  38. package/dist/lib/engine/search/search.js +553 -229
  39. package/dist/lib/engine/write-helpers.d.ts +39 -2
  40. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  41. package/dist/lib/engine/write-helpers.js +55 -12
  42. package/dist/lib/errors.d.ts +11 -0
  43. package/dist/lib/errors.d.ts.map +1 -0
  44. package/dist/lib/errors.js +12 -0
  45. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  46. package/dist/lib/schema/bulk-upsert.js +9 -25
  47. package/dist/lib/schema/helpers.d.ts +28 -1
  48. package/dist/lib/schema/helpers.d.ts.map +1 -1
  49. package/dist/lib/schema/helpers.js +38 -0
  50. package/dist/lib/schema/insert.d.ts.map +1 -1
  51. package/dist/lib/schema/insert.js +9 -19
  52. package/dist/lib/schema/search.d.ts.map +1 -1
  53. package/dist/lib/schema/search.js +138 -50
  54. package/dist/lib/schema/update.d.ts.map +1 -1
  55. package/dist/lib/schema/update.js +9 -25
  56. package/dist/lib/sql-api.d.ts +6 -6
  57. package/dist/lib/sql-api.d.ts.map +1 -1
  58. package/dist/lib/sql-api.js +10 -10
  59. package/dist/lib/table-helpers.d.ts +6 -1
  60. package/dist/lib/table-helpers.d.ts.map +1 -1
  61. package/dist/lib/table-helpers.js +42 -2
  62. package/dist/lib/tenant.d.ts +30 -0
  63. package/dist/lib/tenant.d.ts.map +1 -1
  64. package/dist/lib/tenant.js +78 -9
  65. package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
  66. package/dist/routes/auto/bulk-delete.routes.js +17 -23
  67. package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
  68. package/dist/routes/auto/bulk-upsert.routes.js +16 -22
  69. package/dist/routes/auto/delete.routes.d.ts.map +1 -1
  70. package/dist/routes/auto/delete.routes.js +18 -24
  71. package/dist/routes/auto/get.routes.d.ts.map +1 -1
  72. package/dist/routes/auto/get.routes.js +16 -23
  73. package/dist/routes/auto/insert.routes.d.ts.map +1 -1
  74. package/dist/routes/auto/insert.routes.js +19 -26
  75. package/dist/routes/auto/plugin.d.ts.map +1 -1
  76. package/dist/routes/auto/plugin.js +40 -5
  77. package/dist/routes/auto/route-helpers.d.ts +27 -2
  78. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  79. package/dist/routes/auto/route-helpers.js +39 -1
  80. package/dist/routes/auto/search.routes.d.ts.map +1 -1
  81. package/dist/routes/auto/search.routes.js +47 -47
  82. package/dist/routes/auto/update.routes.d.ts.map +1 -1
  83. package/dist/routes/auto/update.routes.js +20 -27
  84. package/dist/types/cli.d.ts +22 -0
  85. package/dist/types/cli.d.ts.map +1 -0
  86. package/dist/types/cli.js +1 -0
  87. package/dist/types/computed.d.ts +29 -0
  88. package/dist/types/computed.d.ts.map +1 -0
  89. package/dist/types/computed.js +1 -0
  90. package/dist/types/conditions.d.ts +16 -0
  91. package/dist/types/conditions.d.ts.map +1 -0
  92. package/dist/types/conditions.js +1 -0
  93. package/dist/types/db.d.ts +22 -0
  94. package/dist/types/db.d.ts.map +1 -0
  95. package/dist/types/db.js +1 -0
  96. package/dist/types/dml.d.ts +66 -0
  97. package/dist/types/dml.d.ts.map +1 -0
  98. package/dist/types/dml.js +1 -0
  99. package/dist/types/join.d.ts +44 -0
  100. package/dist/types/join.d.ts.map +1 -0
  101. package/dist/types/join.js +1 -0
  102. package/dist/types/plugin.d.ts +20 -0
  103. package/dist/types/plugin.d.ts.map +1 -0
  104. package/dist/types/plugin.js +1 -0
  105. package/dist/types/schema.d.ts +10 -0
  106. package/dist/types/schema.d.ts.map +1 -0
  107. package/dist/types/schema.js +1 -0
  108. package/dist/types/search.d.ts +45 -0
  109. package/dist/types/search.d.ts.map +1 -0
  110. package/dist/types/search.js +1 -0
  111. package/dist/types/table.d.ts +76 -0
  112. package/dist/types/table.d.ts.map +1 -0
  113. package/dist/types/table.js +7 -0
  114. package/dist/types/tenant.d.ts +19 -0
  115. package/dist/types/tenant.d.ts.map +1 -0
  116. package/dist/types/tenant.js +1 -0
  117. package/dist/types/validation.d.ts +21 -0
  118. package/dist/types/validation.d.ts.map +1 -0
  119. package/dist/types/validation.js +1 -0
  120. package/dist/types.d.ts +12 -277
  121. package/dist/types.d.ts.map +1 -1
  122. package/dist/types.js +12 -7
  123. package/package.json +3 -3
@@ -1,4 +1,5 @@
1
1
  import { ConditionBuilder } from 'node-condition-builder';
2
+ import { httpError } from './errors.js';
2
3
  function isIndirect(scope) {
3
4
  return 'through' in scope;
4
5
  }
@@ -39,9 +40,7 @@ export function injectTenantValue(record, scope, tenantIds) {
39
40
  const col = scope.column;
40
41
  if (col in record) {
41
42
  if (!tenantIds.includes(record[col])) {
42
- const err = new Error('Access denied: tenant value does not match');
43
- err.statusCode = 403;
44
- throw err;
43
+ throw httpError(403, 'Access denied: tenant value does not match');
45
44
  }
46
45
  return;
47
46
  }
@@ -49,9 +48,7 @@ export function injectTenantValue(record, scope, tenantIds) {
49
48
  record[col] = tenantIds[0];
50
49
  return;
51
50
  }
52
- const err = new Error('Ambiguous tenant: specify the tenant value');
53
- err.statusCode = 400;
54
- throw err;
51
+ throw httpError(400, 'Ambiguous tenant: specify the tenant value');
55
52
  }
56
53
  export async function validateTenantFK(db, scope, tenantIds, fkValues) {
57
54
  if (!fkValues.length)
@@ -71,9 +68,7 @@ export async function validateTenantFK(db, scope, tenantIds, fkValues) {
71
68
  `WHERE ${where}`;
72
69
  const result = await db.query(sql, values);
73
70
  if (result.rows.length > 0) {
74
- const err = new Error('Access denied: records do not belong to tenant');
75
- err.statusCode = 403;
76
- throw err;
71
+ throw httpError(403, 'Access denied: records do not belong to tenant');
77
72
  }
78
73
  }
79
74
  /**
@@ -103,8 +98,82 @@ export function buildTenantDeleteWhere(db, tableName, pkCol, pkValue, tenant) {
103
98
  const values = cb.getValues();
104
99
  return { where, values };
105
100
  }
101
+ /**
102
+ * Mutates `fields` in-place: removes the tenant column from a SET payload (direct scopes only).
103
+ * No-op for indirect or no scope.
104
+ */
106
105
  export function stripTenantColumn(fields, scope) {
107
106
  if (isIndirect(scope))
108
107
  return;
109
108
  delete fields[scope.column];
110
109
  }
110
+ /**
111
+ * For insert/bulk-upsert: enforce tenant on records that are about to be written.
112
+ * For direct scopes, mutates each record in-place to inject/validate the tenant column.
113
+ * For indirect scopes, batch-validates the FK values against the tenant scope.
114
+ * Throws 400 (ambiguous tenant) or 403 (mismatch) on violation. No-op when `tenant` is undefined.
115
+ */
116
+ export async function enforceTenantOnWrites(db, tenant, records) {
117
+ if (!tenant)
118
+ return;
119
+ if (isIndirect(tenant.scope)) {
120
+ const fkCol = tenant.scope.through.localField;
121
+ await validateTenantFK(db, tenant.scope, tenant.ids, records.map((r) => r[fkCol]));
122
+ return;
123
+ }
124
+ for (const r of records)
125
+ injectTenantValue(r, tenant.scope, tenant.ids);
126
+ }
127
+ /**
128
+ * For update: builds the optional `extraCondition` ConditionBuilder for direct tenants.
129
+ * Returns undefined for indirect or no-tenant scenarios — those are handled separately
130
+ * (indirect uses pre-check via `assertTenantOwnership`).
131
+ */
132
+ export function buildTenantUpdateExtra(tenant) {
133
+ if (!tenant || isIndirect(tenant.scope))
134
+ return undefined;
135
+ const cb = new ConditionBuilder('AND');
136
+ if (tenant.ids.length === 1)
137
+ cb.isEqual(tenant.scope.column, tenant.ids[0]);
138
+ else
139
+ cb.isIn(tenant.scope.column, tenant.ids);
140
+ return cb;
141
+ }
142
+ /**
143
+ * For update with indirect tenant: pre-check ownership via `SELECT 1 ... INNER JOIN ... LIMIT 1`.
144
+ * Throws 404 if the record either does not exist or does not belong to the tenant.
145
+ * No-op for direct or no-tenant — those are enforced via `extraCondition` in the UPDATE itself.
146
+ */
147
+ export async function assertTenantOwnership(db, tenant, tableName, pkCol, pkValue) {
148
+ if (!tenant || !isIndirect(tenant.scope))
149
+ return;
150
+ const cb = new ConditionBuilder('AND');
151
+ cb.isEqual(`${db.qi(tableName)}.${db.qi(pkCol)}`, pkValue);
152
+ cb.append(buildTenantCondition(db, tenant.scope, tenant.ids));
153
+ const sql = `SELECT 1 FROM ${db.qi(tableName)} ${buildTenantJoin(db, tenant.scope, tableName)} WHERE ${cb.build(1, db.ph)} LIMIT 1`;
154
+ const r = await db.query(sql, cb.getValues());
155
+ if (r.rows.length === 0) {
156
+ throw httpError(404, 'Record not found');
157
+ }
158
+ }
159
+ /**
160
+ * Assert that EVERY pk in `ids` exists and belongs to the tenant, for both direct and indirect
161
+ * scopes. Throws 404 if any id is missing or not owned. No-op when `tenant` is undefined or `ids`
162
+ * is empty. Used by the delete engines to enforce tenant ownership BEFORE running a `beforeDelete`/
163
+ * `beforeBulkDelete` hook, so user code never runs against rows the caller cannot access.
164
+ */
165
+ export async function assertTenantOwnsAll(db, tenant, tableName, pkCol, ids) {
166
+ if (!tenant || !ids.length)
167
+ return;
168
+ const qualifiedPk = `${db.qi(tableName)}.${db.qi(pkCol)}`;
169
+ const cb = new ConditionBuilder('AND');
170
+ cb.isIn(qualifiedPk, ids);
171
+ cb.append(buildTenantCondition(db, tenant.scope, tenant.ids));
172
+ const join = isIndirect(tenant.scope) ? ` ${buildTenantJoin(db, tenant.scope, tableName)}` : '';
173
+ const sql = `SELECT DISTINCT ${qualifiedPk} AS pk FROM ${db.qi(tableName)}${join} WHERE ${cb.build(1, db.ph)}`;
174
+ const r = await db.query(sql, cb.getValues());
175
+ const uniqueRequested = new Set(ids.map((v) => String(v))).size;
176
+ if (r.rows.length < uniqueRequested) {
177
+ throw httpError(404, 'Record not found');
178
+ }
179
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"bulk-delete.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/bulk-delete.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAI/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE1D,wBAA8B,gBAAgB,CAC5C,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAyBf"}
1
+ {"version":3,"file":"bulk-delete.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/bulk-delete.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAI/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE1D,wBAA8B,gBAAgB,CAC5C,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAiBf"}
@@ -1,27 +1,21 @@
1
1
  import { BulkDeleteTableBody, BulkDeleteTableResponse } from '../../lib/schema/bulk-delete.js';
2
2
  import { primaryAsString } from '../../types.js';
3
- import { mergeOnRequests } from './route-helpers.js';
3
+ import { registerForAllTables } from './route-helpers.js';
4
4
  export default async function bulkDeleteRoutes(fastify, options) {
5
- const { DbTables } = options;
6
- for (const [tableName, tableConf] of Object.entries(DbTables)) {
7
- const bodySchema = BulkDeleteTableBody(DbTables, tableName);
8
- const responseSchema = BulkDeleteTableResponse(DbTables, tableName);
9
- fastify.route({
10
- method: 'POST',
11
- url: `/bulk/${tableConf.Schema.tableName}/delete`,
12
- schema: {
13
- body: bodySchema,
14
- response: { 200: responseSchema },
15
- tags: [`SqlAPI-${tableName}`],
16
- summary: `Bulk delete ${tableName}`,
17
- description: `Delete multiple records from ${tableName} by primary key`,
18
- },
19
- onRequest: mergeOnRequests(options, tableConf),
20
- handler: async (request, reply) => {
21
- const items = request.body;
22
- const ids = items.map((item) => item[primaryAsString(tableConf.primary)]);
23
- reply.send(await fastify.sqlApi.bulkDelete(tableName, ids, request));
24
- },
25
- });
26
- }
5
+ await registerForAllTables(fastify, options, {
6
+ method: 'POST',
7
+ url: (tc) => `/bulk/${tc.Schema.tableName}/delete`,
8
+ successStatus: 200,
9
+ schemas: (db, table) => ({
10
+ body: BulkDeleteTableBody(db, table),
11
+ response: BulkDeleteTableResponse(db, table),
12
+ }),
13
+ summary: 'Bulk delete',
14
+ description: (name) => `Delete multiple records from ${name} by primary key`,
15
+ handle: (fastify, tableName, tc, request) => {
16
+ const items = request.body;
17
+ const ids = items.map((item) => item[primaryAsString(tc.primary)]);
18
+ return fastify.sqlApi.bulkDelete(tableName, ids, request);
19
+ },
20
+ });
27
21
  }
@@ -1 +1 @@
1
- {"version":3,"file":"bulk-upsert.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/bulk-upsert.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG/C,OAAO,KAAK,EAAE,mBAAmB,EAAkB,MAAM,gBAAgB,CAAC;AAE1E,wBAA8B,gBAAgB,CAC5C,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAwBf"}
1
+ {"version":3,"file":"bulk-upsert.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/bulk-upsert.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG/C,OAAO,KAAK,EAAE,mBAAmB,EAAkB,MAAM,gBAAgB,CAAC;AAE1E,wBAA8B,gBAAgB,CAC5C,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAgBf"}
@@ -1,25 +1,19 @@
1
1
  import { BulkUpsertTableBody, BulkUpsertTableResponse } from '../../lib/schema/bulk-upsert.js';
2
- import { mergeOnRequests, buildWriteDescription } from './route-helpers.js';
2
+ import { registerForAllTables, buildWriteDescription } from './route-helpers.js';
3
3
  export default async function bulkUpsertRoutes(fastify, options) {
4
- const { DbTables } = options;
5
- for (const [tableName, tableConf] of Object.entries(DbTables)) {
6
- const bodySchema = BulkUpsertTableBody(DbTables, tableName);
7
- const responseSchema = BulkUpsertTableResponse(DbTables, tableName);
8
- fastify.route({
9
- method: 'PUT',
10
- url: `/bulk/${tableConf.Schema.tableName}`,
11
- schema: {
12
- body: bodySchema,
13
- response: { 200: responseSchema },
14
- tags: [`SqlAPI-${tableName}`],
15
- summary: `Bulk upsert ${tableName}`,
16
- description: buildWriteDescription('Bulk upsert records in', tableName, tableConf),
17
- },
18
- onRequest: mergeOnRequests(options, tableConf),
19
- handler: async (request, reply) => {
20
- const items = request.body;
21
- reply.send(await fastify.sqlApi.bulkUpsert(tableName, items, request));
22
- },
23
- });
24
- }
4
+ await registerForAllTables(fastify, options, {
5
+ method: 'PUT',
6
+ url: (tc) => `/bulk/${tc.Schema.tableName}`,
7
+ successStatus: 200,
8
+ schemas: (db, table) => ({
9
+ body: BulkUpsertTableBody(db, table),
10
+ response: BulkUpsertTableResponse(db, table),
11
+ }),
12
+ summary: 'Bulk upsert',
13
+ description: (name, tc) => buildWriteDescription('Bulk upsert records in', name, tc),
14
+ handle: (fastify, tableName, _tc, request) => {
15
+ const items = request.body;
16
+ return fastify.sqlApi.bulkUpsert(tableName, items, request);
17
+ },
18
+ });
25
19
  }
@@ -1 +1 @@
1
- {"version":3,"file":"delete.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/delete.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAI/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE1D,wBAA8B,YAAY,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CA2Bf"}
1
+ {"version":3,"file":"delete.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/delete.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAI/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE1D,wBAA8B,YAAY,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAmBf"}
@@ -1,29 +1,23 @@
1
1
  import { Type } from '@sinclair/typebox';
2
2
  import { primaryAsString } from '../../types.js';
3
- import { mergeOnRequests } from './route-helpers.js';
3
+ import { registerForAllTables } from './route-helpers.js';
4
4
  export default async function deleteRoutes(fastify, options) {
5
- const { DbTables } = options;
6
- for (const [tableName, tableConf] of Object.entries(DbTables)) {
7
- const pkField = primaryAsString(tableConf.primary);
8
- const pkType = tableConf.Schema.fields[pkField];
9
- const responseSchema = Type.Object({
10
- main: Type.Object({ [pkField]: pkType }),
11
- });
12
- fastify.route({
13
- method: 'DELETE',
14
- url: `/rest/${tableConf.Schema.tableName}/:id`,
15
- schema: {
5
+ await registerForAllTables(fastify, options, {
6
+ method: 'DELETE',
7
+ url: (tc) => `/rest/${tc.Schema.tableName}/:id`,
8
+ successStatus: 200,
9
+ schemas: (_db, _table, tc) => {
10
+ const pkField = primaryAsString(tc.primary);
11
+ return {
16
12
  params: Type.Object({ id: Type.String() }),
17
- response: { 200: responseSchema },
18
- tags: [`SqlAPI-${tableName}`],
19
- summary: `Delete ${tableName}`,
20
- description: `Delete a record from ${tableName} by primary key`,
21
- },
22
- onRequest: mergeOnRequests(options, tableConf),
23
- handler: async (request, reply) => {
24
- const { id } = request.params;
25
- reply.send(await fastify.sqlApi.delete(tableName, id, request));
26
- },
27
- });
28
- }
13
+ response: Type.Object({ main: Type.Object({ [pkField]: tc.Schema.fields[pkField] }) }),
14
+ };
15
+ },
16
+ summary: 'Delete',
17
+ description: (name) => `Delete a record from ${name} by primary key`,
18
+ handle: (fastify, tableName, _tc, request) => {
19
+ const { id } = request.params;
20
+ return fastify.sqlApi.delete(tableName, id, request);
21
+ },
22
+ });
29
23
  }
@@ -1 +1 @@
1
- {"version":3,"file":"get.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/get.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE1D,wBAA8B,SAAS,CACrC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAyBf"}
1
+ {"version":3,"file":"get.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/get.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE1D,wBAA8B,SAAS,CACrC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAgBf"}
@@ -1,26 +1,19 @@
1
1
  import { Type } from '@sinclair/typebox';
2
- import { mergeOnRequests } from './route-helpers.js';
2
+ import { registerForAllTables } from './route-helpers.js';
3
3
  export default async function getRoutes(fastify, options) {
4
- const { DbTables } = options;
5
- for (const [tableName, tableConf] of Object.entries(DbTables)) {
6
- const responseSchema = Type.Object({
7
- main: Type.Partial(Type.Object(tableConf.Schema.fields)),
8
- });
9
- fastify.route({
10
- method: 'GET',
11
- url: `/rest/${tableConf.Schema.tableName}/:id`,
12
- schema: {
13
- params: Type.Object({ id: Type.String() }),
14
- response: { 200: responseSchema },
15
- tags: [`SqlAPI-${tableName}`],
16
- summary: `Get ${tableName}`,
17
- description: `Get a record from ${tableName} by primary key`,
18
- },
19
- onRequest: mergeOnRequests(options, tableConf),
20
- handler: async (request, reply) => {
21
- const { id } = request.params;
22
- reply.send(await fastify.sqlApi.get(tableName, id, request));
23
- },
24
- });
25
- }
4
+ await registerForAllTables(fastify, options, {
5
+ method: 'GET',
6
+ url: (tc) => `/rest/${tc.Schema.tableName}/:id`,
7
+ successStatus: 200,
8
+ schemas: (_db, _table, tc) => ({
9
+ params: Type.Object({ id: Type.String() }),
10
+ response: Type.Object({ main: Type.Partial(Type.Object(tc.Schema.fields)) }),
11
+ }),
12
+ summary: 'Get',
13
+ description: (name) => `Get a record from ${name} by primary key`,
14
+ handle: (fastify, tableName, _tc, request) => {
15
+ const { id } = request.params;
16
+ return fastify.sqlApi.get(tableName, id, request);
17
+ },
18
+ });
26
19
  }
@@ -1 +1 @@
1
- {"version":3,"file":"insert.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/insert.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE1D,wBAA8B,YAAY,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAiCf"}
1
+ {"version":3,"file":"insert.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/insert.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE1D,wBAA8B,YAAY,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAsBf"}
@@ -1,29 +1,22 @@
1
1
  import { InsertTableBody, InsertTableResponse } from '../../lib/schema/insert.js';
2
- import { mergeOnRequests, buildWriteDescription } from './route-helpers.js';
2
+ import { registerForAllTables, buildWriteDescription } from './route-helpers.js';
3
3
  export default async function insertRoutes(fastify, options) {
4
- const { DbTables } = options;
5
- for (const [tableName, tableConf] of Object.entries(DbTables)) {
6
- const bodySchema = InsertTableBody(DbTables, tableName);
7
- const responseSchema = InsertTableResponse(DbTables, tableName);
8
- fastify.route({
9
- method: 'POST',
10
- url: `/rest/${tableConf.Schema.tableName}`,
11
- schema: {
12
- body: bodySchema,
13
- response: { 201: responseSchema },
14
- tags: [`SqlAPI-${tableName}`],
15
- summary: `Insert ${tableName}`,
16
- description: buildWriteDescription('Insert a record into', tableName, tableConf),
17
- },
18
- onRequest: mergeOnRequests(options, tableConf),
19
- handler: async (request, reply) => {
20
- const body = request.body;
21
- const result = await fastify.sqlApi.insert(tableName, {
22
- record: body.main,
23
- secondaries: body.secondaries,
24
- }, request);
25
- reply.status(201).send(result);
26
- },
27
- });
28
- }
4
+ await registerForAllTables(fastify, options, {
5
+ method: 'POST',
6
+ url: (tc) => `/rest/${tc.Schema.tableName}`,
7
+ successStatus: 201,
8
+ schemas: (db, table) => ({
9
+ body: InsertTableBody(db, table),
10
+ response: InsertTableResponse(db, table),
11
+ }),
12
+ summary: 'Insert',
13
+ description: (name, tc) => buildWriteDescription('Insert a record into', name, tc),
14
+ handle: (fastify, tableName, _tc, request) => {
15
+ const body = request.body;
16
+ return fastify.sqlApi.insert(tableName, {
17
+ record: body.main,
18
+ secondaries: body.secondaries,
19
+ }, request);
20
+ },
21
+ });
29
22
  }
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAgB,MAAM,SAAS,CAAC;AAO7D,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,sBAAsB,CAAC;AASjE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE1D,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,eAAe;QACvB,MAAM,EAAE,MAAM,CAAC;KAChB;CACF;kCAGU,eAAe,WACf,mBAAmB,KAC3B,OAAO,CAAC,IAAI,CAAC;AAHhB,wBAqFoC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAgB,MAAM,SAAS,CAAC;AAO7D,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,sBAAsB,CAAC;AASjE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE1D,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,eAAe;QACvB,MAAM,EAAE,MAAM,CAAC;KAChB;CACF;kCAuDU,eAAe,WACf,mBAAmB,KAC3B,OAAO,CAAC,IAAI,CAAC;AAHhB,wBAiFoC"}
@@ -13,6 +13,45 @@ import updateRoutes from './update.routes.js';
13
13
  import deleteRoutes from './delete.routes.js';
14
14
  import bulkUpsertRoutes from './bulk-upsert.routes.js';
15
15
  import bulkDeleteRoutes from './bulk-delete.routes.js';
16
+ // Schema combinators for which Ajv reports one error per failed branch plus a
17
+ // single combinator-level summary error, all targeting the same path.
18
+ const COMBINATOR_KEYWORDS = new Set(['anyOf', 'oneOf']);
19
+ /**
20
+ * Converts Ajv validation errors into response fields, stripping the noise Ajv
21
+ * emits for schema combinators.
22
+ *
23
+ * An enum modelled as `anyOf` of `const`s fails every branch when invalid, so Ajv
24
+ * produces one `const` error per branch plus an `anyOf` error — all on the same
25
+ * path. We keep only the combinator error for those paths and drop the per-branch
26
+ * errors, then deduplicate any remaining identical entries. `required` errors are
27
+ * re-pointed from the parent object to the actually missing property.
28
+ */
29
+ function normalizeAjvErrors(errors) {
30
+ const toPath = (v) => {
31
+ const path = (v.instancePath || '').replace(/\//g, '.').replace(/^\./, '');
32
+ const missing = v.params?.missingProperty;
33
+ if (v.keyword === 'required' && missing) {
34
+ return path ? `${path}.${missing}` : missing;
35
+ }
36
+ return path;
37
+ };
38
+ const mapped = errors.map((v) => ({
39
+ path: toPath(v),
40
+ message: v.message || 'invalid',
41
+ code: v.keyword || 'unknown',
42
+ }));
43
+ const combinatorPaths = new Set(mapped.filter((f) => COMBINATOR_KEYWORDS.has(f.code)).map((f) => f.path));
44
+ const seen = new Set();
45
+ return mapped.filter((f) => {
46
+ if (combinatorPaths.has(f.path) && !COMBINATOR_KEYWORDS.has(f.code))
47
+ return false;
48
+ const key = `${f.path}|${f.code}|${f.message}`;
49
+ if (seen.has(key))
50
+ return false;
51
+ seen.add(key);
52
+ return true;
53
+ });
54
+ }
16
55
  export default fp(async function fastifyAutoSqlApi(fastify, options) {
17
56
  // Set ConditionBuilder dialect globally
18
57
  const dialect = getDialect(options.dialect || 'postgres');
@@ -49,11 +88,7 @@ export default fp(async function fastifyAutoSqlApi(fastify, options) {
49
88
  instance.setErrorHandler((error, request, reply) => {
50
89
  // Schema validation errors (Ajv)
51
90
  if (error.validation) {
52
- const fields = error.validation.map((v) => ({
53
- path: (v.instancePath || '').replace(/\//g, '.').replace(/^\./, ''),
54
- message: v.message || 'invalid',
55
- code: v.keyword || 'unknown',
56
- }));
91
+ const fields = normalizeAjvErrors(error.validation);
57
92
  return reply.status(400).send({
58
93
  statusCode: 400,
59
94
  error: 'Bad Request',
@@ -1,7 +1,32 @@
1
- import type { FastifyRequest, FastifyReply } from 'fastify';
2
- import type { ITable, SqlApiPluginOptions } from '../../types.js';
1
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
+ import type { ITable, SqlApiPluginOptions, DbTables } from '../../types.js';
3
3
  type RequestHook = (request: FastifyRequest, reply: FastifyReply) => Promise<void | FastifyReply>;
4
4
  export declare function mergeOnRequests(options: SqlApiPluginOptions, tableConf: ITable): RequestHook[];
5
5
  export declare function buildWriteDescription(action: string, tableName: string, tableConf: ITable): string;
6
+ interface RouteSchema {
7
+ body?: any;
8
+ params?: any;
9
+ querystring?: any;
10
+ response?: any;
11
+ }
12
+ export interface AutoRouteSpec {
13
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
14
+ /** URL builder receiving the table configuration to read `Schema.tableName`. */
15
+ url: (tableConf: ITable) => string;
16
+ successStatus: 200 | 201;
17
+ /** Build per-table schemas (body, params, querystring, response). Response is wrapped with `{ [successStatus]: ... }` automatically. */
18
+ schemas: (dbTables: DbTables, tableName: string, tableConf: ITable) => RouteSchema;
19
+ summary: string;
20
+ description: (tableName: string, tableConf: ITable, schemas: RouteSchema) => string;
21
+ /** Translates the request to a sqlApi call. If it does not call reply.send/status itself,
22
+ * the registrar will reply.status(successStatus).send(returnValue). */
23
+ handle: (fastify: FastifyInstance, tableName: string, tableConf: ITable, request: FastifyRequest, reply: FastifyReply) => Promise<unknown>;
24
+ }
25
+ /**
26
+ * Iterates over every table in `options.DbTables` and registers one Fastify route for it
27
+ * following `spec`. Centralizes onRequest hook merging, tags, schema response wrapping,
28
+ * default reply when handler returns a value.
29
+ */
30
+ export declare function registerForAllTables(fastify: FastifyInstance, options: SqlApiPluginOptions, spec: AutoRouteSpec): Promise<void>;
6
31
  export {};
7
32
  //# sourceMappingURL=route-helpers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAElE,KAAK,WAAW,GAAG,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,GAAG,YAAY,CAAC,CAAC;AAElG,wBAAgB,eAAe,CAAC,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,GAAG,WAAW,EAAE,CAE9F;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG"}
1
+ {"version":3,"file":"route-helpers.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/route-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC7E,OAAO,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE5E,KAAK,WAAW,GAAG,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,GAAG,YAAY,CAAC,CAAC;AAElG,wBAAgB,eAAe,CAAC,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,GAAG,WAAW,EAAE,CAE9F;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQlG;AAED,UAAU,WAAW;IAEnB,IAAI,CAAC,EAAE,GAAG,CAAC;IAEX,MAAM,CAAC,EAAE,GAAG,CAAC;IAEb,WAAW,CAAC,EAAE,GAAG,CAAC;IAElB,QAAQ,CAAC,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC1C,gFAAgF;IAChF,GAAG,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;IACnC,aAAa,EAAE,GAAG,GAAG,GAAG,CAAC;IACzB,wIAAwI;IACxI,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,WAAW,CAAC;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,KAAK,MAAM,CAAC;IACpF;2EACuE;IACvE,MAAM,EAAE,CACN,OAAO,EAAE,eAAe,EACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,YAAY,KAChB,OAAO,CAAC,OAAO,CAAC,CAAC;CACvB;AAED;;;;GAIG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,EAC5B,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,IAAI,CAAC,CA8Bf"}
@@ -3,10 +3,48 @@ export function mergeOnRequests(options, tableConf) {
3
3
  }
4
4
  export function buildWriteDescription(action, tableName, tableConf) {
5
5
  const joinList = tableConf.allowedWriteJoins
6
- ?.map(([joinSchema]) => joinSchema.tableName)
6
+ ?.map((j) => j.alias)
7
7
  .join(', ');
8
8
  return [
9
9
  `${action} ${tableName}`,
10
10
  joinList && `Available secondaries: ${joinList}`,
11
11
  ].filter(Boolean).join('. ');
12
12
  }
13
+ /**
14
+ * Iterates over every table in `options.DbTables` and registers one Fastify route for it
15
+ * following `spec`. Centralizes onRequest hook merging, tags, schema response wrapping,
16
+ * default reply when handler returns a value.
17
+ */
18
+ export async function registerForAllTables(fastify, options, spec) {
19
+ const { DbTables } = options;
20
+ for (const [tableName, tableConf] of Object.entries(DbTables)) {
21
+ const schemas = spec.schemas(DbTables, tableName, tableConf);
22
+ // Only include schema keys that are actually defined: Fastify checks for key
23
+ // *presence* (not undefined value) to decide whether to emit FSTWRN001.
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ const schema = {
26
+ tags: [`SqlAPI-${tableName}`],
27
+ summary: `${spec.summary} ${tableName}`,
28
+ description: spec.description(tableName, tableConf, schemas),
29
+ };
30
+ if (schemas.body !== undefined)
31
+ schema.body = schemas.body;
32
+ if (schemas.params !== undefined)
33
+ schema.params = schemas.params;
34
+ if (schemas.querystring !== undefined)
35
+ schema.querystring = schemas.querystring;
36
+ if (schemas.response !== undefined)
37
+ schema.response = { [spec.successStatus]: schemas.response };
38
+ fastify.route({
39
+ method: spec.method,
40
+ url: spec.url(tableConf),
41
+ schema,
42
+ onRequest: mergeOnRequests(options, tableConf),
43
+ handler: async (request, reply) => {
44
+ const result = await spec.handle(fastify, tableName, tableConf, request, reply);
45
+ if (!reply.sent)
46
+ reply.status(spec.successStatus).send(result);
47
+ },
48
+ });
49
+ }
50
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"search.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/search.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE1D,wBAA8B,YAAY,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAoDf"}
1
+ {"version":3,"file":"search.routes.d.ts","sourceRoot":"","sources":["../../../src/routes/auto/search.routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG/C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE1D,wBAA8B,YAAY,CACxC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAiDf"}