fastify-auto-sqlapi 0.1.2 → 0.1.4

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 (90) hide show
  1. package/AGENTS_BACKEND.md +41 -2
  2. package/AGENTS_FRONTEND.md +7 -5
  3. package/README.md +105 -5
  4. package/dist/bin/generate-schema.js +2 -2
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/lib/adapters/mysql-adapter.d.ts +4 -0
  8. package/dist/lib/adapters/mysql-adapter.d.ts.map +1 -1
  9. package/dist/lib/adapters/mysql-adapter.js +28 -17
  10. package/dist/lib/adapters/pg-adapter.d.ts +4 -0
  11. package/dist/lib/adapters/pg-adapter.d.ts.map +1 -1
  12. package/dist/lib/adapters/pg-adapter.js +18 -7
  13. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  14. package/dist/lib/cli/mysql-introspect.js +3 -1
  15. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  16. package/dist/lib/cli/pg-introspect.js +13 -4
  17. package/dist/lib/cli/schema-codegen.d.ts +2 -1
  18. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  19. package/dist/lib/cli/schema-codegen.js +17 -3
  20. package/dist/lib/cli/tables-codegen.d.ts +2 -0
  21. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  22. package/dist/lib/cli/tables-codegen.js +43 -12
  23. package/dist/lib/db.d.ts +17 -0
  24. package/dist/lib/db.d.ts.map +1 -1
  25. package/dist/lib/db.js +47 -0
  26. package/dist/lib/engine/bulk/bulk-delete.d.ts.map +1 -1
  27. package/dist/lib/engine/bulk/bulk-delete.js +25 -8
  28. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  29. package/dist/lib/engine/bulk/bulk-upsert.js +12 -9
  30. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  31. package/dist/lib/engine/rest/delete.js +9 -5
  32. package/dist/lib/engine/rest/get.js +1 -1
  33. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  34. package/dist/lib/engine/rest/insert.js +41 -34
  35. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  36. package/dist/lib/engine/rest/update.js +74 -57
  37. package/dist/lib/engine/search/search.d.ts.map +1 -1
  38. package/dist/lib/engine/search/search.js +94 -34
  39. package/dist/lib/schema/bulk-delete.d.ts +1 -1
  40. package/dist/lib/schema/bulk-delete.d.ts.map +1 -1
  41. package/dist/lib/schema/bulk-delete.js +2 -2
  42. package/dist/lib/schema/bulk-upsert.d.ts +1 -1
  43. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  44. package/dist/lib/schema/bulk-upsert.js +5 -3
  45. package/dist/lib/schema/helpers.d.ts +2 -0
  46. package/dist/lib/schema/helpers.d.ts.map +1 -1
  47. package/dist/lib/schema/helpers.js +3 -2
  48. package/dist/lib/schema/insert.d.ts.map +1 -1
  49. package/dist/lib/schema/insert.js +4 -1
  50. package/dist/lib/schema/update.d.ts.map +1 -1
  51. package/dist/lib/schema/update.js +4 -1
  52. package/dist/lib/sql-api.d.ts +2 -0
  53. package/dist/lib/sql-api.d.ts.map +1 -1
  54. package/dist/lib/sql-api.js +1 -0
  55. package/dist/lib/table-helpers.d.ts.map +1 -1
  56. package/dist/lib/table-helpers.js +10 -9
  57. package/dist/lib/tenant.d.ts +24 -2
  58. package/dist/lib/tenant.d.ts.map +1 -1
  59. package/dist/lib/tenant.js +0 -0
  60. package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
  61. package/dist/routes/auto/bulk-delete.routes.js +3 -1
  62. package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
  63. package/dist/routes/auto/bulk-upsert.routes.js +3 -1
  64. package/dist/routes/auto/delete.routes.d.ts.map +1 -1
  65. package/dist/routes/auto/delete.routes.js +1 -0
  66. package/dist/routes/auto/get.routes.d.ts.map +1 -1
  67. package/dist/routes/auto/get.routes.js +1 -0
  68. package/dist/routes/auto/insert.routes.d.ts.map +1 -1
  69. package/dist/routes/auto/insert.routes.js +1 -0
  70. package/dist/routes/auto/route-helpers.d.ts +3 -1
  71. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  72. package/dist/routes/auto/route-helpers.js +3 -0
  73. package/dist/routes/auto/search.routes.d.ts.map +1 -1
  74. package/dist/routes/auto/search.routes.js +9 -0
  75. package/dist/routes/auto/update.routes.d.ts.map +1 -1
  76. package/dist/routes/auto/update.routes.js +1 -0
  77. package/dist/types/cli.d.ts +6 -0
  78. package/dist/types/cli.d.ts.map +1 -1
  79. package/dist/types/db.d.ts +11 -0
  80. package/dist/types/db.d.ts.map +1 -1
  81. package/dist/types/plugin.d.ts +16 -0
  82. package/dist/types/plugin.d.ts.map +1 -1
  83. package/dist/types/plugin.js +4 -1
  84. package/dist/types/schema.d.ts +2 -0
  85. package/dist/types/schema.d.ts.map +1 -1
  86. package/dist/types/search.d.ts +7 -0
  87. package/dist/types/search.d.ts.map +1 -1
  88. package/dist/types/table.d.ts +40 -2
  89. package/dist/types/table.d.ts.map +1 -1
  90. package/package.json +2 -2
@@ -20,15 +20,34 @@ export function parseSchemaFile(content) {
20
20
  }
21
21
  if (fields.length === 0)
22
22
  return null;
23
+ // primaryKey: ["code"] — emitted by generate-schema from DB introspection
24
+ let primary;
25
+ const pkMatch = content.match(/primaryKey:\s*\[([^\]]*)\]/);
26
+ if (pkMatch) {
27
+ primary = pkMatch[1]
28
+ .split(',')
29
+ .map((s) => s.trim().replace(/^["']|["']$/g, ''))
30
+ .filter(Boolean);
31
+ if (primary.length === 0)
32
+ primary = undefined;
33
+ }
23
34
  return {
24
35
  schemaName: exportMatch[1],
25
36
  tableName: tableNameMatch[1],
26
37
  fields,
27
38
  fieldTypes,
39
+ primary,
28
40
  };
29
41
  }
30
42
  // ─── Detection ───────────────────────────────────────────────
31
43
  function detectPrimaryKey(schema) {
44
+ // Preferred source: the PK introspected from the DB (primaryKey in the schema file).
45
+ if (schema.primary?.length) {
46
+ const pk = schema.primary.length === 1 ? schema.primary[0] : schema.primary;
47
+ const first = schema.primary[0];
48
+ return { pk, autoIncrement: schema.fieldTypes[first]?.includes('Optional') ?? false };
49
+ }
50
+ // Heuristic fallback for hand-written/legacy schema files without primaryKey.
32
51
  if (schema.fields.includes('id')) {
33
52
  return { pk: 'id', autoIncrement: schema.fieldTypes['id']?.includes('Optional') ?? false };
34
53
  }
@@ -39,6 +58,18 @@ function detectPrimaryKey(schema) {
39
58
  }
40
59
  return { pk: schema.fields[0], autoIncrement: false };
41
60
  }
61
+ /** First PK field: used for defaultOrder, relations and example snippets. */
62
+ function pkFirst(pk) {
63
+ return Array.isArray(pk) ? pk[0] : pk;
64
+ }
65
+ /** Render the `primary:` value for the defineTable template. */
66
+ function formatPrimary(pk) {
67
+ return Array.isArray(pk) ? `[${pk.map((p) => `'${p}'`).join(', ')}]` : `'${pk}'`;
68
+ }
69
+ /** All PK fields as an array, for "non-PK field" lookups in example snippets. */
70
+ function pkFields(pk) {
71
+ return Array.isArray(pk) ? pk : [pk];
72
+ }
42
73
  export function detectRelations(schemas) {
43
74
  const tableBySnakeName = new Map();
44
75
  for (const schema of schemas) {
@@ -55,7 +86,7 @@ export function detectRelations(schemas) {
55
86
  const { pk } = detectPrimaryKey(parent);
56
87
  relations.push({
57
88
  parentSchemaName: parent.schemaName,
58
- parentField: pk,
89
+ parentField: pkFirst(pk),
59
90
  childSchemaName: schema.schemaName,
60
91
  childField: field,
61
92
  });
@@ -113,7 +144,7 @@ export function generateSingleTableFile(schema, allSchemas) {
113
144
  lines.push(``);
114
145
  lines.push(`// Fields: ${schema.fields.join(', ')}`);
115
146
  lines.push(`export const ${tableVarName} = defineTable({`);
116
- lines.push(` primary: '${pk}',`);
147
+ lines.push(` primary: ${formatPrimary(pk)},`);
117
148
  lines.push(` ...exportTableInfo(Schema),`);
118
149
  // Commented example with extraFilters + extendedCondition
119
150
  lines.push(` // ...exportTableInfo(`);
@@ -123,9 +154,9 @@ export function generateSingleTableFile(schema, allSchemas) {
123
154
  lines.push(` // if (filters.q) condition.isILike(Schema.col('${schema.fields.includes('name') ? 'name' : schema.fields[0]}'), \`%\${filters.q}%\`);`);
124
155
  lines.push(` // }`);
125
156
  lines.push(` // ),`);
126
- lines.push(` defaultOrder: '${pk}',`);
157
+ lines.push(` defaultOrder: '${pkFirst(pk)}',`);
127
158
  if (autoIncrement) {
128
- lines.push(` excludeFromCreation: ['${pk}'],`);
159
+ lines.push(` excludeFromCreation: ['${pkFirst(pk)}'],`);
129
160
  }
130
161
  else {
131
162
  lines.push(` // excludeFromCreation: [],`);
@@ -140,14 +171,14 @@ export function generateSingleTableFile(schema, allSchemas) {
140
171
  else {
141
172
  lines.push(` // allowedReadJoins: [],`);
142
173
  }
143
- lines.push(` // upsertMap: buildUpsertRules(buildUpsertRule(Schema, ['${pk}'])),`);
174
+ lines.push(` // upsertMap: buildUpsertRules(buildUpsertRule(Schema, [${pkFields(pk).map((p) => `'${p}'`).join(', ')}])),`);
144
175
  // schemaOverrides: suggest email format if email field exists, otherwise minLength on first string field
145
176
  const emailField = schema.fields.find(f => f.toLowerCase().includes('email'));
146
177
  if (emailField) {
147
178
  lines.push(` // schemaOverrides: { ${emailField}: Type.String({ format: 'email' }) },`);
148
179
  }
149
180
  else {
150
- const stringField = schema.fields.find(f => f !== pk && schema.fieldTypes[f]?.includes('String'));
181
+ const stringField = schema.fields.find(f => !pkFields(pk).includes(f) && schema.fieldTypes[f]?.includes('String'));
151
182
  if (stringField) {
152
183
  lines.push(` // schemaOverrides: { ${stringField}: Type.String({ minLength: 1 }) },`);
153
184
  }
@@ -156,7 +187,7 @@ export function generateSingleTableFile(schema, allSchemas) {
156
187
  }
157
188
  }
158
189
  // validate: use first non-PK field for example
159
- const exampleField = schema.fields.find(f => f !== pk) || schema.fields[0];
190
+ const exampleField = schema.fields.find(f => !pkFields(pk).includes(f)) || schema.fields[0];
160
191
  lines.push(` validate: async (db, req, main, secondaries) => {`);
161
192
  lines.push(` const errors: ValidationError[] = [];`);
162
193
  lines.push(` // if (!main.${exampleField}) errors.push(['${exampleField}', 'required']);`);
@@ -225,11 +256,11 @@ export function generateTablesFile(schemas) {
225
256
  lines.push(`// ─── ${schema.tableName} ──────────────────────────────`);
226
257
  lines.push(`// Fields: ${schema.fields.join(', ')}`);
227
258
  lines.push(`const ${tableVarName} = defineTable({`);
228
- lines.push(` primary: '${pk}',`);
259
+ lines.push(` primary: ${formatPrimary(pk)},`);
229
260
  lines.push(` ...exportTableInfo(${schema.schemaName}),`);
230
- lines.push(` defaultOrder: '${pk}',`);
261
+ lines.push(` defaultOrder: '${pkFirst(pk)}',`);
231
262
  if (autoIncrement) {
232
- lines.push(` excludeFromCreation: ['${pk}'],`);
263
+ lines.push(` excludeFromCreation: ['${pkFirst(pk)}'],`);
233
264
  }
234
265
  else {
235
266
  lines.push(` // excludeFromCreation: [],`);
@@ -244,9 +275,9 @@ export function generateTablesFile(schemas) {
244
275
  else {
245
276
  lines.push(` // allowedReadJoins: [],`);
246
277
  }
247
- lines.push(` // upsertMap: buildUpsertRules(buildUpsertRule(${schema.schemaName}, ['${pk}'])),`);
278
+ lines.push(` // upsertMap: buildUpsertRules(buildUpsertRule(${schema.schemaName}, [${pkFields(pk).map((p) => `'${p}'`).join(', ')}])),`);
248
279
  lines.push(` // schemaOverrides: {},`);
249
- const exampleFieldLegacy = schema.fields.find(f => f !== pk) || schema.fields[0];
280
+ const exampleFieldLegacy = schema.fields.find(f => !pkFields(pk).includes(f)) || schema.fields[0];
250
281
  lines.push(` validate: async (db, req, main, secondaries) => {`);
251
282
  lines.push(` const errors: ValidationError[] = [];`);
252
283
  lines.push(` // if (!main.${exampleFieldLegacy}) errors.push(['${exampleFieldLegacy}', 'required']);`);
package/dist/lib/db.d.ts CHANGED
@@ -13,10 +13,27 @@ export declare class QueryClient {
13
13
  get qi(): (id: string) => string;
14
14
  get ph(): (i: number) => string;
15
15
  get dialectName(): string;
16
+ /** ConditionBuilder dialect ('postgres' | 'mysql') for per-instance builders. */
17
+ get cbDialect(): SqlDialect['cbDialect'];
18
+ /** Whether the dialect supports `RETURNING` (postgres/mariadb: yes, mysql: no). */
19
+ get supportsReturning(): boolean;
20
+ /** Build the ` RETURNING <quoted pk>` suffix ('' when unsupported). */
21
+ get returningPk(): SqlDialect['returningPk'];
16
22
  get dateTrunc(): SqlDialect['dateTrunc'];
17
23
  setDebug(mode: boolean): void;
18
24
  expression(value: string): Expression;
19
25
  query<T = Record<string, unknown>>(text: string, values?: unknown[]): Promise<SqlResult<T>>;
26
+ /**
27
+ * Run `fn` inside a DB transaction on a dedicated connection (BEGIN/COMMIT, with
28
+ * ROLLBACK + rethrow on error). Requires the underlying Queryable to expose
29
+ * `connect()`; when it does not (legacy custom adapters), `fn` runs directly on
30
+ * this client without transactional guarantees.
31
+ *
32
+ * Nested calls join the outer transaction: the tx QueryClient wraps a dedicated
33
+ * connection that has no `connect()`, so an inner `withTransaction` is a no-op
34
+ * wrapper around the same connection.
35
+ */
36
+ withTransaction<T>(fn: (tx: QueryClient) => Promise<T>): Promise<T>;
20
37
  insert(table: string, record: DbRecord, pkCol: string | string[]): Promise<Record<string, unknown>>;
21
38
  insertOrUpdate(table: string, record: DbRecord, conflictKeys: string[], pkCol: string | string[]): Promise<Record<string, unknown>>;
22
39
  bulkInsert(table: string, records: DbRecord[], pkCol: string | string[], chunkSize?: number): Promise<Record<string, unknown>[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAQ/C,sCAAsC;AACtC,eAAO,MAAM,WAAW,GAAI,GAAG,MAAM,WAA0B,CAAC;AAEhE,qBAAa,WAAW;;IACtB,OAAO,CAAC,MAAM,CAAY;IAC1B,OAAO,CAAC,OAAO,CAAa;IAC5B,OAAO,CAAC,KAAK,CAAS;gBAEV,MAAM,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,UAAU;IAKnD,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAE/B;IAED,IAAI,EAAE,IAAI,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAE9B;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,IAAI,SAAS,IAAI,UAAU,CAAC,WAAW,CAAC,CAEvC;IAED,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI;IAI7B,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU;IAIrC,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,OAAO,EAAE,GACjB,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;IAmGlB,MAAM,CACV,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,QAAQ,EAChB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GACvB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAgB7B,cAAc,CAClB,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,QAAQ,EAChB,YAAY,EAAE,MAAM,EAAE,EACtB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GACvB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAkB7B,UAAU,CACd,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,QAAQ,EAAE,EACnB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,EACxB,SAAS,SAAqB,GAC7B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAoB/B,kBAAkB,CACtB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,QAAQ,EAAE,EACnB,YAAY,EAAE,MAAM,EAAE,EACtB,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,EACxB,SAAS,SAAqB,GAC7B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAuB/B,MAAM,CACV,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,QAAQ,EAChB,KAAK,EAAE,QAAQ,EACf,cAAc,CAAC,EAAE,gBAAgB,GAChC,OAAO,CAAC,MAAM,CAAC;IAuBZ,MAAM,CACV,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,QAAQ,GACd,OAAO,CAAC,MAAM,CAAC;IAYZ,MAAM,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EACxC,SAAS,EACT,OAAa,EACb,KAAK,EACL,MAAM,EACN,KAAY,EACZ,OAAY,EACZ,KAAU,EACV,QAAgB,GACjB,EAAE,aAAa,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC;CAchC;AAED,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,SAAS,EACf,WAAW,CAAC,EAAE,MAAM,GACnB,WAAW,CAGb"}
1
+ {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"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"}
package/dist/lib/db.js CHANGED
@@ -23,6 +23,18 @@ export class QueryClient {
23
23
  get dialectName() {
24
24
  return this.dialect.name;
25
25
  }
26
+ /** ConditionBuilder dialect ('postgres' | 'mysql') for per-instance builders. */
27
+ get cbDialect() {
28
+ return this.dialect.cbDialect;
29
+ }
30
+ /** Whether the dialect supports `RETURNING` (postgres/mariadb: yes, mysql: no). */
31
+ get supportsReturning() {
32
+ return this.dialect.supportsReturning;
33
+ }
34
+ /** Build the ` RETURNING <quoted pk>` suffix ('' when unsupported). */
35
+ get returningPk() {
36
+ return this.dialect.returningPk.bind(this.dialect);
37
+ }
26
38
  get dateTrunc() {
27
39
  return this.dialect.dateTrunc.bind(this.dialect);
28
40
  }
@@ -39,6 +51,41 @@ export class QueryClient {
39
51
  }
40
52
  return this.client.query(text, values);
41
53
  }
54
+ /**
55
+ * Run `fn` inside a DB transaction on a dedicated connection (BEGIN/COMMIT, with
56
+ * ROLLBACK + rethrow on error). Requires the underlying Queryable to expose
57
+ * `connect()`; when it does not (legacy custom adapters), `fn` runs directly on
58
+ * this client without transactional guarantees.
59
+ *
60
+ * Nested calls join the outer transaction: the tx QueryClient wraps a dedicated
61
+ * connection that has no `connect()`, so an inner `withTransaction` is a no-op
62
+ * wrapper around the same connection.
63
+ */
64
+ async withTransaction(fn) {
65
+ if (!this.client.connect)
66
+ return fn(this);
67
+ const conn = await this.client.connect();
68
+ const tx = new QueryClient(conn, this.dialect);
69
+ tx.setDebug(this.debug);
70
+ try {
71
+ await conn.query('BEGIN');
72
+ const result = await fn(tx);
73
+ await conn.query('COMMIT');
74
+ return result;
75
+ }
76
+ catch (err) {
77
+ try {
78
+ await conn.query('ROLLBACK');
79
+ }
80
+ catch {
81
+ // The connection is broken: surface the original error, not the rollback failure.
82
+ }
83
+ throw err;
84
+ }
85
+ finally {
86
+ await conn.release();
87
+ }
88
+ }
42
89
  // Quote a list of fields
43
90
  #q(fields) {
44
91
  return fields.map((f) => this.dialect.qi(f)).join(', ');
@@ -1 +1 @@
1
- {"version":3,"file":"bulk-delete.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/bulk/bulk-delete.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAE5E,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAwC5F"}
1
+ {"version":3,"file":"bulk-delete.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/bulk/bulk-delete.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAE5E,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAiE5F"}
@@ -20,17 +20,34 @@ export async function bulkDeleteEngine(params) {
20
20
  ({ where, values } = buildTenantDeleteWhere(db, tableName, pkCol, ids, tenant));
21
21
  }
22
22
  else {
23
- const cb = new ConditionBuilder('AND');
23
+ const cb = new ConditionBuilder('AND', db.cbDialect);
24
24
  cb.isIn(db.qi(pkCol), ids);
25
25
  where = cb.build(1, db.ph);
26
26
  values = cb.getValues();
27
27
  }
28
- const result = await db.query(`DELETE FROM ${db.qi(tableName)} WHERE ${where}`, values);
29
- // Return PK-only for each deleted record
30
- if (result.affectedRows === ids.length) {
31
- return ids.map((id) => ({ main: { [pk]: id } }));
28
+ let results;
29
+ if (db.supportsReturning) {
30
+ const result = await db.query(`DELETE FROM ${db.qi(tableName)} WHERE ${where}${db.returningPk(db.qi(pkCol))}`, values);
31
+ // Happy path: everything requested was deleted
32
+ if (result.affectedRows === ids.length) {
33
+ results = ids.map((id) => ({ main: { [pk]: id } }));
34
+ }
35
+ else {
36
+ // Partial delete: RETURNING tells exactly which rows went away
37
+ results = result.rows.map((r) => ({ main: { [pk]: r[pkCol] } }));
38
+ }
32
39
  }
33
- // Partial delete: best effort (no RETURNING on MySQL)
34
- const deletedCount = result.affectedRows;
35
- return ids.slice(0, deletedCount).map((id) => ({ main: { [pk]: id } }));
40
+ else {
41
+ // No RETURNING (mysql): affectedRows alone cannot tell WHICH ids were deleted on a
42
+ // partial match, so look up the rows visible through the same WHERE before deleting.
43
+ const existing = await db.query(`SELECT ${db.qi(pkCol)} AS pk FROM ${db.qi(tableName)} WHERE ${where}`, values);
44
+ await db.query(`DELETE FROM ${db.qi(tableName)} WHERE ${where}`, values);
45
+ results = existing.rows.map((r) => ({ main: { [pk]: r.pk } }));
46
+ }
47
+ // Called once with the ACTUALLY deleted ids (possibly a subset of the requested ones)
48
+ if (tableConf.afterBulkDelete && results.length > 0) {
49
+ const deletedIds = results.map((r) => r.main[pk]);
50
+ await tableConf.afterBulkDelete(db, request, deletedIds);
51
+ }
52
+ return results;
36
53
  }
@@ -1 +1 @@
1
- {"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/bulk/bulk-upsert.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,gBAAgB,EAChB,gBAAgB,EAEjB,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAkF5F"}
1
+ {"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/bulk/bulk-upsert.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,gBAAgB,EAChB,gBAAgB,EAEjB,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAoF5F"}
@@ -1,14 +1,15 @@
1
+ import { camelcaseObject } from '../../naming.js';
1
2
  import { processSecondaries, processDeletions, prepareInsertRecord } from '../write-helpers.js';
2
- import { enforceTenantOnWrites } from '../../tenant.js';
3
+ import { enforceTenantOnWrites, assertTenantOwnsConflicts } from '../../tenant.js';
3
4
  import { runBulkValidation } from '../validate.js';
4
- import { primaryAsString } from '../../../types.js';
5
+ import { primaryAsCols } from '../../../types.js';
5
6
  export async function bulkUpsertEngine(params) {
6
7
  const { db, tableConf, dbTables, request, items, tenant } = params;
7
8
  if (!items.length)
8
9
  return [];
9
10
  const schema = tableConf.Schema;
10
- const pk = primaryAsString(tableConf.primary);
11
- const pkCol = schema.col(pk);
11
+ // Full primary key columns — composite PKs need every column (see insertEngine).
12
+ const pkCol = primaryAsCols(tableConf.primary, (f) => schema.col(f));
12
13
  // 1. Bulk validation runs once for all items (skips per-item validate). Otherwise per-item
13
14
  // validation happens inside prepareInsertRecord below.
14
15
  if (tableConf.validateBulk) {
@@ -25,6 +26,8 @@ export async function bulkUpsertEngine(params) {
25
26
  let pkRows;
26
27
  if (upsertKeys) {
27
28
  const conflictCols = upsertKeys.map((k) => schema.col(k));
29
+ // Tenant isolation: a conflicting upsert must not overwrite rows owned by another tenant.
30
+ await assertTenantOwnsConflicts(db, tenant, schema.tableName, conflictCols, preparedMains);
28
31
  pkRows = await db.bulkInsertOrUpdate(schema.tableName, preparedMains, conflictCols, pkCol);
29
32
  }
30
33
  else {
@@ -34,12 +37,12 @@ export async function bulkUpsertEngine(params) {
34
37
  const results = [];
35
38
  for (let i = 0; i < items.length; i++) {
36
39
  const item = items[i];
37
- const mainPk = pkRows[i]; // PK-only (already in camelCase field name since PK uses camelCase field)
38
- // pkRows entries come from SQL RETURNING or insertId — keys are DB column names.
39
- // For simple PK named the same in API and DB (like 'id') this is transparent.
40
- const result = { main: { [pk]: mainPk[pkCol] ?? mainPk[pk] } };
40
+ // pkRows entries come from SQL RETURNING or mysql insertId synthesis — keys are DB
41
+ // column names; camelcaseObject maps them back to schema fields (full composite PK).
42
+ const mainPkCamel = camelcaseObject(pkRows[i], schema);
43
+ const result = { main: mainPkCamel };
41
44
  // FK auto-fill: input camelCase + generated PK
42
- const mainForFK = { ...inputMains[i], [pk]: mainPk[pkCol] ?? mainPk[pk] };
45
+ const mainForFK = { ...inputMains[i], ...mainPkCamel };
43
46
  if (item.secondaries && Object.keys(item.secondaries).length > 0) {
44
47
  const sec = await processSecondaries(db, tableConf, dbTables, mainForFK, item.secondaries);
45
48
  if (Object.keys(sec).length > 0)
@@ -1 +1 @@
1
- {"version":3,"file":"delete.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/delete.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAY,MAAM,mBAAmB,CAAC;AAE9E,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CA8B9E"}
1
+ {"version":3,"file":"delete.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/delete.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAY,MAAM,mBAAmB,CAAC;AAE9E,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAiC9E"}
@@ -17,11 +17,15 @@ export async function deleteEngine(params) {
17
17
  const affectedRows = await db.delete(tableName, { [pkCol]: id });
18
18
  if (affectedRows === 0)
19
19
  throw httpError(404, `Record not found: ${id}`);
20
- return { main: { [pk]: id } };
21
20
  }
22
- const { where, values } = buildTenantDeleteWhere(db, tableName, pkCol, id, tenant);
23
- const result = await db.query(`DELETE FROM ${db.qi(tableName)} WHERE ${where}`, values);
24
- if (result.affectedRows === 0)
25
- throw httpError(404, `Record not found: ${id}`);
21
+ else {
22
+ const { where, values } = buildTenantDeleteWhere(db, tableName, pkCol, id, tenant);
23
+ const result = await db.query(`DELETE FROM ${db.qi(tableName)} WHERE ${where}`, values);
24
+ if (result.affectedRows === 0)
25
+ throw httpError(404, `Record not found: ${id}`);
26
+ }
27
+ if (tableConf.afterDelete) {
28
+ await tableConf.afterDelete(db, request, id);
29
+ }
26
30
  return { main: { [pk]: id } };
27
31
  }
@@ -6,7 +6,7 @@ import { primaryAsString } from '../../../types.js';
6
6
  export async function getEngine(params) {
7
7
  const { db, tableConf, id, tenant } = params;
8
8
  const pkCol = tableConf.Schema.col(primaryAsString(tableConf.primary));
9
- const cb = new ConditionBuilder('AND');
9
+ const cb = new ConditionBuilder('AND', db.cbDialect);
10
10
  cb.isEqual(db.qi(pkCol), id);
11
11
  const joins = [];
12
12
  if (tenant) {
@@ -1 +1 @@
1
- {"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/insert.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAEb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CA0D9E"}
1
+ {"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/insert.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAEb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAiE9E"}
@@ -1,42 +1,49 @@
1
1
  import { camelcaseObject } from '../../naming.js';
2
2
  import { processSecondaries, prepareInsertRecord } from '../write-helpers.js';
3
- import { enforceTenantOnWrites } from '../../tenant.js';
4
- import { primaryAsString } from '../../../types.js';
3
+ import { enforceTenantOnWrites, assertTenantOwnsConflicts } from '../../tenant.js';
4
+ import { primaryAsCols } from '../../../types.js';
5
5
  export async function insertEngine(params) {
6
6
  const { db, tableConf, dbTables, request, record, secondaries, tenant } = params;
7
7
  const schema = tableConf.Schema;
8
- const pk = primaryAsString(tableConf.primary);
9
- const pkCol = schema.col(pk);
8
+ // Full primary key columns (composite PKs need every column for RETURNING /
9
+ // mysql synthesis, otherwise the response is missing PK fields).
10
+ const pkCol = primaryAsCols(tableConf.primary, (f) => schema.col(f));
10
11
  // 1. Validate, run beforeInsert hook, snakecase, drop excluded fields.
11
12
  const { camel: inputRecord, snake: mainRecord } = await prepareInsertRecord({ db, tableConf, request }, record, secondaries);
12
- // 2. Tenant: inject (direct) or validate FK (indirect) — enforced after user mutations
13
- await enforceTenantOnWrites(db, tenant, [mainRecord]);
14
- // 3. Insert main → returns PK-only
15
- let pkResult;
16
- const upsertKeys = tableConf.upsertMap?.get(schema);
17
- if (upsertKeys) {
18
- const conflictCols = upsertKeys.map((k) => schema.col(k));
19
- pkResult = await db.insertOrUpdate(schema.tableName, mainRecord, conflictCols, pkCol);
20
- }
21
- else {
22
- pkResult = await db.insert(schema.tableName, mainRecord, pkCol);
23
- }
24
- const mainPkCamel = camelcaseObject(pkResult, schema);
25
- // 4. Secondaries: need full record for FK auto-fill (camelCase)
26
- let secondaryResults;
27
- if (secondaries && Object.keys(secondaries).length > 0) {
28
- const mainForFK = { ...inputRecord, ...mainPkCamel };
29
- secondaryResults = await processSecondaries(db, tableConf, dbTables, mainForFK, secondaries);
30
- }
31
- // 5. afterInsert hook (camelCase — input merged with generated PK)
32
- if (tableConf.afterInsert) {
33
- const mainForHook = { ...inputRecord, ...mainPkCamel };
34
- await tableConf.afterInsert(db, request, mainForHook, secondaryResults);
35
- }
36
- // 6. Return PK-only
37
- const result = { main: mainPkCamel };
38
- if (secondaryResults && Object.keys(secondaryResults).length > 0) {
39
- result.secondaries = secondaryResults;
40
- }
41
- return result;
13
+ // Steps 2-5 are atomic: a failure in secondaries or afterInsert rolls back the
14
+ // main insert too. Degrades to non-transactional when the adapter has no connect().
15
+ return db.withTransaction(async (tx) => {
16
+ // 2. Tenant: inject (direct) or validate FK (indirect) — enforced after user mutations
17
+ await enforceTenantOnWrites(tx, tenant, [mainRecord]);
18
+ // 3. Insert main → returns PK-only
19
+ let pkResult;
20
+ const upsertKeys = tableConf.upsertMap?.get(schema);
21
+ if (upsertKeys) {
22
+ const conflictCols = upsertKeys.map((k) => schema.col(k));
23
+ // Tenant isolation: a conflicting upsert must not overwrite a row owned by another tenant.
24
+ await assertTenantOwnsConflicts(tx, tenant, schema.tableName, conflictCols, [mainRecord]);
25
+ pkResult = await tx.insertOrUpdate(schema.tableName, mainRecord, conflictCols, pkCol);
26
+ }
27
+ else {
28
+ pkResult = await tx.insert(schema.tableName, mainRecord, pkCol);
29
+ }
30
+ const mainPkCamel = camelcaseObject(pkResult, schema);
31
+ // 4. Secondaries: need full record for FK auto-fill (camelCase)
32
+ let secondaryResults;
33
+ if (secondaries && Object.keys(secondaries).length > 0) {
34
+ const mainForFK = { ...inputRecord, ...mainPkCamel };
35
+ secondaryResults = await processSecondaries(tx, tableConf, dbTables, mainForFK, secondaries);
36
+ }
37
+ // 5. afterInsert hook (camelCase — input merged with generated PK)
38
+ if (tableConf.afterInsert) {
39
+ const mainForHook = { ...inputRecord, ...mainPkCamel };
40
+ await tableConf.afterInsert(tx, request, mainForHook, secondaryResults);
41
+ }
42
+ // 6. Return PK-only
43
+ const result = { main: mainPkCamel };
44
+ if (secondaryResults && Object.keys(secondaryResults).length > 0) {
45
+ result.secondaries = secondaryResults;
46
+ }
47
+ return result;
48
+ });
42
49
  }
@@ -1 +1 @@
1
- {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/update.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAEb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CA+F9E"}
1
+ {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/update.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAEb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAwH9E"}