fastify-auto-sqlapi 0.2.0 → 0.2.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 (122) hide show
  1. package/AGENTS.md +50 -0
  2. package/AGENTS_BACKEND.md +312 -9
  3. package/AGENTS_FRONTEND.md +1 -1
  4. package/BREAKING_CHANGES.md +132 -0
  5. package/CHANGELOG.md +170 -0
  6. package/README.md +255 -877
  7. package/dist/bin/generate-schema.js +49 -2
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/lib/agent/manifest.d.ts +2 -0
  12. package/dist/lib/agent/manifest.d.ts.map +1 -1
  13. package/dist/lib/agent/manifest.js +4 -0
  14. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  15. package/dist/lib/cli/mysql-introspect.js +15 -5
  16. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  17. package/dist/lib/cli/pg-introspect.js +53 -14
  18. package/dist/lib/cli/schema-codegen.d.ts +2 -1
  19. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  20. package/dist/lib/cli/schema-codegen.js +17 -4
  21. package/dist/lib/cli/tables-codegen.d.ts +4 -0
  22. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  23. package/dist/lib/cli/tables-codegen.js +125 -27
  24. package/dist/lib/db.d.ts +6 -1
  25. package/dist/lib/db.d.ts.map +1 -1
  26. package/dist/lib/db.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 +6 -5
  29. package/dist/lib/engine/read-hooks.d.ts +19 -0
  30. package/dist/lib/engine/read-hooks.d.ts.map +1 -0
  31. package/dist/lib/engine/read-hooks.js +20 -0
  32. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  33. package/dist/lib/engine/rest/get.js +10 -6
  34. package/dist/lib/engine/rest/insert.js +1 -1
  35. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  36. package/dist/lib/engine/rest/update.js +15 -11
  37. package/dist/lib/engine/search/join-families.d.ts +11 -2
  38. package/dist/lib/engine/search/join-families.d.ts.map +1 -1
  39. package/dist/lib/engine/search/join-families.js +11 -13
  40. package/dist/lib/engine/search/search.d.ts.map +1 -1
  41. package/dist/lib/engine/search/search.js +14 -7
  42. package/dist/lib/engine/validate.d.ts +3 -3
  43. package/dist/lib/engine/validate.d.ts.map +1 -1
  44. package/dist/lib/engine/write-helpers.d.ts +7 -3
  45. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  46. package/dist/lib/engine/write-helpers.js +29 -30
  47. package/dist/lib/errors.d.ts +24 -0
  48. package/dist/lib/errors.d.ts.map +1 -1
  49. package/dist/lib/errors.js +59 -0
  50. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  51. package/dist/lib/schema/bulk-upsert.js +2 -1
  52. package/dist/lib/schema/get.d.ts +14 -0
  53. package/dist/lib/schema/get.d.ts.map +1 -0
  54. package/dist/lib/schema/get.js +18 -0
  55. package/dist/lib/schema/helpers.d.ts +21 -3
  56. package/dist/lib/schema/helpers.d.ts.map +1 -1
  57. package/dist/lib/schema/helpers.js +37 -5
  58. package/dist/lib/schema/insert.d.ts.map +1 -1
  59. package/dist/lib/schema/insert.js +7 -3
  60. package/dist/lib/schema/search.d.ts.map +1 -1
  61. package/dist/lib/schema/search.js +3 -11
  62. package/dist/lib/schema/update.d.ts.map +1 -1
  63. package/dist/lib/schema/update.js +7 -3
  64. package/dist/lib/sql-api.d.ts +9 -9
  65. package/dist/lib/sql-api.d.ts.map +1 -1
  66. package/dist/lib/sql-api.js +2 -1
  67. package/dist/lib/table-helpers.d.ts.map +1 -1
  68. package/dist/lib/table-helpers.js +169 -0
  69. package/dist/lib/tenant.d.ts +43 -8
  70. package/dist/lib/tenant.d.ts.map +1 -1
  71. package/dist/lib/tenant.js +169 -54
  72. package/dist/lib/write-access.d.ts +36 -0
  73. package/dist/lib/write-access.d.ts.map +1 -0
  74. package/dist/lib/write-access.js +58 -0
  75. package/dist/routes/auto/get.routes.d.ts.map +1 -1
  76. package/dist/routes/auto/get.routes.js +3 -2
  77. package/dist/routes/auto/plugin.d.ts.map +1 -1
  78. package/dist/routes/auto/plugin.js +8 -2
  79. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  80. package/dist/routes/auto/route-helpers.js +13 -4
  81. package/dist/routes/setup-swagger.d.ts.map +1 -0
  82. package/dist/{lib → routes}/sql-api-decorator.d.ts +1 -1
  83. package/dist/routes/sql-api-decorator.d.ts.map +1 -0
  84. package/dist/{lib → routes}/sql-api-decorator.js +4 -4
  85. package/dist/types/cli.d.ts +27 -1
  86. package/dist/types/cli.d.ts.map +1 -1
  87. package/dist/types/dml.d.ts +6 -4
  88. package/dist/types/dml.d.ts.map +1 -1
  89. package/dist/types/plugin.d.ts +22 -3
  90. package/dist/types/plugin.d.ts.map +1 -1
  91. package/dist/types/request.d.ts +24 -0
  92. package/dist/types/request.d.ts.map +1 -0
  93. package/dist/types/request.js +1 -0
  94. package/dist/types/schema.d.ts +15 -0
  95. package/dist/types/schema.d.ts.map +1 -1
  96. package/dist/types/search.d.ts +3 -0
  97. package/dist/types/search.d.ts.map +1 -1
  98. package/dist/types/table.d.ts +84 -11
  99. package/dist/types/table.d.ts.map +1 -1
  100. package/dist/types/tenant.d.ts +26 -2
  101. package/dist/types/tenant.d.ts.map +1 -1
  102. package/dist/types/validation.d.ts +3 -3
  103. package/dist/types/validation.d.ts.map +1 -1
  104. package/dist/types.d.ts +1 -0
  105. package/dist/types.d.ts.map +1 -1
  106. package/dist/types.js +1 -0
  107. package/docs/GLOSSARY.md +3 -1
  108. package/docs/adr/0004-updates-always-open.md +2 -1
  109. package/docs/adr/0006-raw-db-errors.md +2 -1
  110. package/docs/adr/0010-joins-do-not-run-route-guards.md +4 -1
  111. package/docs/adr/0013-sanitized-db-errors.md +114 -0
  112. package/docs/adr/0014-what-follows-a-write-join.md +84 -0
  113. package/docs/adr/0015-non-writable-columns.md +86 -0
  114. package/docs/adr/0016-a-view-is-a-table-config.md +92 -0
  115. package/docs/adr/0017-the-schema-is-the-contract.md +85 -0
  116. package/docs/adr/0018-one-seam-to-the-http-framework.md +68 -0
  117. package/docs/adr/README.md +6 -0
  118. package/package.json +1 -1
  119. package/dist/lib/setup-swagger.d.ts.map +0 -1
  120. package/dist/lib/sql-api-decorator.d.ts.map +0 -1
  121. /package/dist/{lib → routes}/setup-swagger.d.ts +0 -0
  122. /package/dist/{lib → routes}/setup-swagger.js +0 -0
@@ -10,3 +10,62 @@ export function httpError(statusCode, message) {
10
10
  err.statusCode = statusCode;
11
11
  return err;
12
12
  }
13
+ /**
14
+ * Replace an error escaping a route handler with one that carries no database detail.
15
+ *
16
+ * Driver messages name tables, columns and constraints, and the route error handler sends
17
+ * `error.message` verbatim — so a unique violation describes the schema to whoever
18
+ * triggered it. Errors the plugin raises itself are deliberate `4xx` with messages written
19
+ * for clients (`httpError`, `validationErrors`) and pass through untouched; everything
20
+ * else becomes a bare `500`.
21
+ *
22
+ * The original is attached as `cause`, so a consumer `setErrorHandler` can still apply its
23
+ * own policy — mapping SQLSTATE to a status code stays the consumer's decision (ADR 0006).
24
+ * No status code is remapped here: a `500` stays a `500`.
25
+ *
26
+ * `requestId` is carried on the sanitized error and rendered in the response body: it is the
27
+ * same value Fastify logs as `reqId` on every line, so the log entry holding the real error
28
+ * is one grep away from the client's `500`.
29
+ *
30
+ * With `exposeDebugInfo`, the original is also described in a `debugInfo` payload rendered
31
+ * beside those fields — additive, so the response a client codes against is the same one it
32
+ * gets in production. It is off by default and derived from nothing: `debug` belongs to the
33
+ * consumer, who may wire it to an environment variable, and no option may open an external
34
+ * channel as a side effect of meaning something internal. See ADR 0013.
35
+ */
36
+ export function sanitizeRouteError(err, requestId, exposeDebugInfo = false) {
37
+ const statusCode = err?.statusCode;
38
+ if (typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500)
39
+ return err;
40
+ const safe = httpError(500, 'Internal Server Error');
41
+ safe.name = 'Internal Server Error';
42
+ safe.cause = err;
43
+ if (requestId)
44
+ safe.requestId = requestId;
45
+ if (exposeDebugInfo)
46
+ safe.debugInfo = describeError(err);
47
+ return safe;
48
+ }
49
+ /**
50
+ * Driver properties worth handing a developer, across both supported drivers: `pg` puts the
51
+ * schema detail in `constraint`/`detail`/`table`/`column`, `mysql2` in `errno`/`sqlState`/
52
+ * `sqlMessage`. Copied by allowlist rather than spread, so a driver that starts attaching
53
+ * the connection config (password included) to its errors cannot ship it to a client.
54
+ */
55
+ const DEBUG_ERROR_PROPS = [
56
+ 'code', 'errno', 'sqlState', 'sqlMessage',
57
+ 'constraint', 'detail', 'hint', 'table', 'column', 'schema', 'routine', 'position',
58
+ ];
59
+ function describeError(err) {
60
+ if (!(err instanceof Error))
61
+ return { value: String(err) };
62
+ const info = { name: err.name, message: err.message };
63
+ for (const prop of DEBUG_ERROR_PROPS) {
64
+ const value = err[prop];
65
+ if (value !== undefined)
66
+ info[prop] = value;
67
+ }
68
+ if (err.stack)
69
+ info.stack = err.stack;
70
+ return info;
71
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/bulk-upsert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEvD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAS/C,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,oGAoB3F;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,oGAa5E"}
1
+ {"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/bulk-upsert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEvD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAU/C,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,oGAuB3F;AAED,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,oGAa5E"}
@@ -1,11 +1,12 @@
1
1
  import { Type } from '@sinclair/typebox';
2
2
  import { primaryAsString } from '../../types.js';
3
+ import { writableFields } from '../write-access.js';
3
4
  import { pkSchema, applySchemaOverrides, attachWriteJoinSections, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
4
5
  export function BulkUpsertTableBody(dbTables, tableName, maxItems) {
5
6
  const tableConf = dbTables[tableName];
6
7
  const schema = tableConf.Schema;
7
8
  // additionalProperties:false makes the schema the real write whitelist (no mass assignment).
8
- const mainSchema = Type.Partial(Type.Object(applySchemaOverrides({ ...schema.fields }, tableConf), { additionalProperties: false }));
9
+ const mainSchema = Type.Partial(Type.Object(writableFields(applySchemaOverrides({ ...schema.fields }, tableConf), tableConf, schema), { additionalProperties: false }));
9
10
  const itemProperties = {
10
11
  main: mainSchema,
11
12
  };
@@ -0,0 +1,14 @@
1
+ import { type TObject } from '@sinclair/typebox';
2
+ import type { DbTables } from '../../types.js';
3
+ /**
4
+ * Response shape of `GET /rest/:table/:id`.
5
+ *
6
+ * Lives here rather than inline in the route so it is built from the same pieces as every
7
+ * other operation: it drifted while it was inline, documenting `readExclude`d fields the
8
+ * engine never selects.
9
+ *
10
+ * `Partial` because a record is returned column by column: a nullable column the driver
11
+ * reports as absent must not fail serialization.
12
+ */
13
+ export declare function GetTableResponse(dbTables: DbTables, tableName: string): TObject;
14
+ //# sourceMappingURL=get.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"get.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/get.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAEvD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE/C;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAK/E"}
@@ -0,0 +1,18 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import { readableFields } from './helpers.js';
3
+ /**
4
+ * Response shape of `GET /rest/:table/:id`.
5
+ *
6
+ * Lives here rather than inline in the route so it is built from the same pieces as every
7
+ * other operation: it drifted while it was inline, documenting `readExclude`d fields the
8
+ * engine never selects.
9
+ *
10
+ * `Partial` because a record is returned column by column: a nullable column the driver
11
+ * reports as absent must not fail serialization.
12
+ */
13
+ export function GetTableResponse(dbTables, tableName) {
14
+ const tableConf = dbTables[tableName];
15
+ return Type.Object({
16
+ main: Type.Partial(Type.Object(readableFields(tableConf.Schema, tableConf))),
17
+ });
18
+ }
@@ -5,11 +5,27 @@ import type { ITable, SchemaDefinition, DbTables, JoinDefinition } from '../../t
5
5
  * Falls back to `fallback` field if tableConf is undefined.
6
6
  */
7
7
  export declare function pkSchema(tableConf: ITable | undefined, schema: SchemaDefinition, fallback: string): Record<string, TSchema>;
8
+ /** The same schema without the Optional modifier, i.e. as a required property. */
9
+ export declare function asRequired(schema: TSchema): TSchema;
8
10
  /**
9
- * Apply schemaOverrides to a fields record.
10
- * Replaces matching field schemas with the override.
11
+ * Apply schemaOverrides to a fields record: each matching field becomes the declared schema,
12
+ * verbatim.
13
+ *
14
+ * Write bodies only. An override is a rule about what this API accepts **from now on**, and
15
+ * the rows already in the table were written before it existed — a column narrowed to
16
+ * `format: 'email'` today holds whatever was accepted last year, and one made mandatory today
17
+ * is `NULL` on every pre-existing row. Describing the response with it would publish a promise
18
+ * about data nobody can retroactively make true. Responses stay on the generated type.
19
+ *
20
+ * Verbatim, because on the request side the schema is the rule: an override without
21
+ * `Type.Optional` makes the field mandatory and one without `Nullable` rejects an explicit
22
+ * `null`, whatever the column allows. That is how a column the DB had to leave nullable is
23
+ * made mandatory going forward. Write the modifiers when you want them.
24
+ *
25
+ * Replace-only: an override naming a field the table does not have is ignored, so the
26
+ * declaration can never introduce a property the engine will not produce.
11
27
  */
12
- export declare function applySchemaOverrides(fields: Record<string, TSchema>, tableConf: ITable): Record<string, TSchema>;
28
+ export declare function applySchemaOverrides(fields: Record<string, TSchema>, tableConf: ITable | undefined): Record<string, TSchema>;
13
29
  type JoinFieldsBuilder = (join: JoinDefinition, secondaryConf: ITable | undefined) => Record<string, TSchema>;
14
30
  /**
15
31
  * Attach `secondaries` (and optionally `deletions`) sections to the given target object,
@@ -29,5 +45,7 @@ export declare function attachWriteJoinSections(target: Record<string, TSchema>,
29
45
  export declare const writeJoinBodyFields: JoinFieldsBuilder;
30
46
  /** JoinFieldsBuilder for response schemas: PK only. */
31
47
  export declare const writeJoinResponseFields: JoinFieldsBuilder;
48
+ /** Schema fields minus the ones hidden by `readExclude`. */
49
+ export declare function readableFields(schema: SchemaDefinition, tableConf: ITable | undefined): Record<string, TSchema>;
32
50
  export {};
33
51
  //# sourceMappingURL=helpers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAGzF;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ3H;AA6BD;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB;AAED,KAAK,iBAAiB,GAAG,CACvB,IAAI,EAAE,cAAc,EACpB,aAAa,EAAE,MAAM,GAAG,SAAS,KAC9B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAwB7B;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE;IAAE,aAAa,EAAE,OAAO,CAAC;IAAC,eAAe,EAAE,iBAAiB,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAE,GAC7F,IAAI,CA8BN;AAED,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,EAAE,iBACmB,CAAC;AAEtD,uDAAuD;AACvD,eAAO,MAAM,uBAAuB,EAAE,iBACG,CAAC"}
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAKzF;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQ3H;AAiCD,kFAAkF;AAClF,wBAAgB,UAAU,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAGnD;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB;AAED,KAAK,iBAAiB,GAAG,CACvB,IAAI,EAAE,cAAc,EACpB,aAAa,EAAE,MAAM,GAAG,SAAS,KAC9B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAwB7B;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE;IAAE,aAAa,EAAE,OAAO,CAAC;IAAC,eAAe,EAAE,iBAAiB,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAE,GAC7F,IAAI,CA8BN;AAED,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,EAAE,iBACmB,CAAC;AAEtD,uDAAuD;AACvD,eAAO,MAAM,uBAAuB,EAAE,iBACG,CAAC;AAE1C,4DAA4D;AAC5D,wBAAgB,cAAc,CAC5B,MAAM,EAAE,gBAAgB,EACxB,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAOzB"}
@@ -1,4 +1,6 @@
1
- import { Type } from '@sinclair/typebox';
1
+ import { Type, OptionalKind } from '@sinclair/typebox';
2
+ import { readableFieldNames } from '../read-access.js';
3
+ import { writableFields } from '../write-access.js';
2
4
  import { findSecondaryTableConf } from '../engine/write-helpers.js';
3
5
  /**
4
6
  * Build a PK-only schema for response shapes (single or composite PK).
@@ -19,7 +21,7 @@ export function pkSchema(tableConf, schema, fallback) {
19
21
  * - excludeFromCreation fields become Optional
20
22
  */
21
23
  function buildSecondaryFields(joinSchema, joinField, secondaryTableConf) {
22
- const fields = { ...joinSchema.fields };
24
+ const fields = writableFields({ ...joinSchema.fields }, secondaryTableConf, joinSchema);
23
25
  if (joinField in fields) {
24
26
  fields[joinField] = Type.Optional(fields[joinField]);
25
27
  }
@@ -32,12 +34,32 @@ function buildSecondaryFields(joinSchema, joinField, secondaryTableConf) {
32
34
  }
33
35
  return fields;
34
36
  }
37
+ /** The same schema without the Optional modifier, i.e. as a required property. */
38
+ export function asRequired(schema) {
39
+ if (!(OptionalKind in schema))
40
+ return schema;
41
+ return Type.Required(Type.Object({ value: schema })).properties.value;
42
+ }
35
43
  /**
36
- * Apply schemaOverrides to a fields record.
37
- * Replaces matching field schemas with the override.
44
+ * Apply schemaOverrides to a fields record: each matching field becomes the declared schema,
45
+ * verbatim.
46
+ *
47
+ * Write bodies only. An override is a rule about what this API accepts **from now on**, and
48
+ * the rows already in the table were written before it existed — a column narrowed to
49
+ * `format: 'email'` today holds whatever was accepted last year, and one made mandatory today
50
+ * is `NULL` on every pre-existing row. Describing the response with it would publish a promise
51
+ * about data nobody can retroactively make true. Responses stay on the generated type.
52
+ *
53
+ * Verbatim, because on the request side the schema is the rule: an override without
54
+ * `Type.Optional` makes the field mandatory and one without `Nullable` rejects an explicit
55
+ * `null`, whatever the column allows. That is how a column the DB had to leave nullable is
56
+ * made mandatory going forward. Write the modifiers when you want them.
57
+ *
58
+ * Replace-only: an override naming a field the table does not have is ignored, so the
59
+ * declaration can never introduce a property the engine will not produce.
38
60
  */
39
61
  export function applySchemaOverrides(fields, tableConf) {
40
- if (!tableConf.schemaOverrides)
62
+ if (!tableConf?.schemaOverrides)
41
63
  return fields;
42
64
  const result = { ...fields };
43
65
  for (const [field, schema] of Object.entries(tableConf.schemaOverrides)) {
@@ -88,3 +110,13 @@ export function attachWriteJoinSections(target, tableConf, dbTables, options) {
88
110
  export const writeJoinBodyFields = (j, sc) => buildSecondaryFields(j.joinSchema, j.joinField, sc);
89
111
  /** JoinFieldsBuilder for response schemas: PK only. */
90
112
  export const writeJoinResponseFields = (j, sc) => pkSchema(sc, j.joinSchema, j.joinField);
113
+ /** Schema fields minus the ones hidden by `readExclude`. */
114
+ export function readableFields(schema, tableConf) {
115
+ if (!tableConf?.readExclude?.length)
116
+ return { ...schema.fields };
117
+ const out = {};
118
+ for (const field of readableFieldNames(tableConf, schema)) {
119
+ out[field] = schema.fields[field];
120
+ }
121
+ return out;
122
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/insert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAS/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAyB9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
1
+ {"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/insert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAU/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CA+B9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
@@ -1,14 +1,18 @@
1
1
  import { Type } from '@sinclair/typebox';
2
2
  import { primaryAsString } from '../../types.js';
3
+ import { writableFields } from '../write-access.js';
3
4
  import { pkSchema, applySchemaOverrides, attachWriteJoinSections, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
4
5
  export function InsertTableBody(dbTables, tableName) {
5
6
  const tableConf = dbTables[tableName];
6
7
  const schema = tableConf.Schema;
7
- // Main: full validation, but excludeFromCreation fields become Optional
8
- const mainFields = applySchemaOverrides({ ...schema.fields }, tableConf);
8
+ // Main: full validation, but excludeFromCreation fields become Optional. Fields the
9
+ // database computes are not offered at all — sending one is rejected here (400) rather
10
+ // than by the driver (500).
11
+ const mainFields = writableFields(applySchemaOverrides({ ...schema.fields }, tableConf), tableConf, schema);
9
12
  if (tableConf.excludeFromCreation) {
10
13
  for (const field of tableConf.excludeFromCreation) {
11
- mainFields[field] = Type.Optional(mainFields[field]);
14
+ if (field in mainFields)
15
+ mainFields[field] = Type.Optional(mainFields[field]);
12
16
  }
13
17
  }
14
18
  const bodyProperties = {
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAIrE,OAAO,KAAK,EAAE,QAAQ,EAA4B,MAAM,gBAAgB,CAAC;AA+JzE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAyClF;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,eAAe,GAAE,MAAmC,GAAG,OAAO,CAe9F;AAED,qFAAqF;AACrF,eAAO,MAAM,sBAAsB,kDAAqB,CAAC;AAEzD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAyClF"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAIrE,OAAO,KAAK,EAAE,QAAQ,EAAU,MAAM,gBAAgB,CAAC;AAkJvD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAyClF;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,eAAe,GAAE,MAAmC,GAAG,OAAO,CAe9F;AAED,qFAAqF;AACrF,eAAO,MAAM,sBAAsB,kDAAqB,CAAC;AAEzD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CA2ClF"}
@@ -1,17 +1,7 @@
1
1
  import { Type } from '@sinclair/typebox';
2
2
  import { ALLOWED_METHODS, MAX_CONDITIONS, MAX_ORDER_BY_LENGTH } from '../condition-methods.js';
3
- import { readableFieldNames } from '../read-access.js';
3
+ import { readableFields } from './helpers.js';
4
4
  import { DEFAULT_MAX_ITEMS_PER_PAGE } from '../../types.js';
5
- /** Schema fields minus the ones hidden by `readExclude`. */
6
- function readableFields(schema, tableConf) {
7
- if (!tableConf?.readExclude?.length)
8
- return { ...schema.fields };
9
- const out = {};
10
- for (const field of readableFieldNames(tableConf, schema)) {
11
- out[field] = schema.fields[field];
12
- }
13
- return out;
14
- }
15
5
  /**
16
6
  * A `filters` map.
17
7
  *
@@ -192,6 +182,8 @@ export function SearchTableResponse(dbTables, tableName) {
192
182
  // Computed fields are present in main rows only when explicitly listed in
193
183
  // request body's selectComputed, so they're optional/Partial here.
194
184
  const computedTypes = computedFieldTypes(tableConf);
185
+ // Not narrowed by schemaOverrides: an override rules what this API accepts from now on,
186
+ // while the rows already stored predate it (see applySchemaOverrides).
195
187
  const mainReadable = readableFields(tableConf.Schema, tableConf);
196
188
  const mainItem = Object.keys(computedTypes).length > 0
197
189
  ? Type.Partial(Type.Object({ ...mainReadable, ...computedTypes }))
@@ -1 +1 @@
1
- {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/update.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAS/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAwB9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
1
+ {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/update.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAErE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAW/C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CA+B9E;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAalF"}
@@ -1,14 +1,18 @@
1
1
  import { Type } from '@sinclair/typebox';
2
2
  import { primaryAsString } from '../../types.js';
3
- import { pkSchema, applySchemaOverrides, attachWriteJoinSections, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
3
+ import { writableFields } from '../write-access.js';
4
+ import { pkSchema, applySchemaOverrides, asRequired, attachWriteJoinSections, writeJoinBodyFields, writeJoinResponseFields, } from './helpers.js';
4
5
  export function UpdateTableBody(dbTables, tableName) {
5
6
  const tableConf = dbTables[tableName];
6
7
  const schema = tableConf.Schema;
7
8
  // Main: PK required, all other fields optional (overrides applied before Optional wrap)
8
- const baseFields = applySchemaOverrides({ ...schema.fields }, tableConf);
9
+ const baseFields = writableFields(applySchemaOverrides({ ...schema.fields }, tableConf), tableConf, schema);
9
10
  const mainFields = {};
11
+ const pk = primaryAsString(tableConf.primary);
10
12
  for (const [key, value] of Object.entries(baseFields)) {
11
- mainFields[key] = key === primaryAsString(tableConf.primary) ? value : Type.Optional(value);
13
+ // The PK identifies the row being updated, so it is mandatory here even when the column
14
+ // is optional on insert (a generated id) and the override carried that optionality over.
15
+ mainFields[key] = key === pk ? asRequired(value) : Type.Optional(value);
12
16
  }
13
17
  const bodyProperties = {
14
18
  // additionalProperties:false makes the schema the real write whitelist — unknown keys are
@@ -1,4 +1,4 @@
1
- import type { FastifyRequest } from 'fastify';
1
+ import type { ApiRequest } from '../types/request.js';
2
2
  import { QueryClient } from './db.js';
3
3
  import { type DialectName } from './dialect.js';
4
4
  import type { DbTables, Queryable, FilterRecord, SearchCondition, Paginator, JoinGroupRequest, JoinRefFilter, JoinFetchRequest, SearchResult, GetResult, InsertResult, UpdateResult, DeleteResult, BulkUpsertItem, BulkUpsertResult, BulkDeleteResult, TenantId } from '../types.js';
@@ -30,7 +30,7 @@ export interface SqlApiUpdateParams {
30
30
  }
31
31
  export interface SqlApiOptions {
32
32
  dialect?: DialectName;
33
- getTenantId?: (request: FastifyRequest) => TenantId | TenantId[] | null | undefined | Promise<TenantId | TenantId[] | null | undefined>;
33
+ getTenantId?: (request: ApiRequest) => TenantId | TenantId[] | null | undefined | Promise<TenantId | TenantId[] | null | undefined>;
34
34
  }
35
35
  export declare class SqlApi {
36
36
  private dbTables;
@@ -39,13 +39,13 @@ export declare class SqlApi {
39
39
  constructor(dbOrPool: QueryClient | Queryable, dbTables: DbTables, options?: SqlApiOptions);
40
40
  private getTableConf;
41
41
  private getTenant;
42
- search(tableName: string, params?: SqlApiSearchParams, request?: FastifyRequest): Promise<SearchResult>;
43
- get(tableName: string, id: string | number, request?: FastifyRequest): Promise<GetResult>;
44
- insert(tableName: string, params: SqlApiInsertParams, request?: FastifyRequest): Promise<InsertResult>;
45
- update(tableName: string, params: SqlApiUpdateParams, request?: FastifyRequest): Promise<UpdateResult>;
46
- delete(tableName: string, id: string | number, request?: FastifyRequest): Promise<DeleteResult>;
47
- bulkUpsert(tableName: string, items: BulkUpsertItem[], request?: FastifyRequest): Promise<BulkUpsertResult[]>;
48
- bulkDelete(tableName: string, ids: (string | number)[], request?: FastifyRequest): Promise<BulkDeleteResult[]>;
42
+ search(tableName: string, params?: SqlApiSearchParams, request?: ApiRequest): Promise<SearchResult>;
43
+ get(tableName: string, id: string | number, request?: ApiRequest): Promise<GetResult>;
44
+ insert(tableName: string, params: SqlApiInsertParams, request?: ApiRequest): Promise<InsertResult>;
45
+ update(tableName: string, params: SqlApiUpdateParams, request?: ApiRequest): Promise<UpdateResult>;
46
+ delete(tableName: string, id: string | number, request?: ApiRequest): Promise<DeleteResult>;
47
+ bulkUpsert(tableName: string, items: BulkUpsertItem[], request?: ApiRequest): Promise<BulkUpsertResult[]>;
48
+ bulkDelete(tableName: string, ids: (string | number)[], request?: ApiRequest): Promise<BulkDeleteResult[]>;
49
49
  }
50
50
  export declare function createSqlApi(dbOrPool: QueryClient | Queryable, dbTables: DbTables, options?: SqlApiOptions): SqlApi;
51
51
  //# sourceMappingURL=sql-api.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sql-api.d.ts","sourceRoot":"","sources":["../../src/lib/sql-api.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sql-api.d.ts","sourceRoot":"","sources":["../../src/lib/sql-api.ts"],"names":[],"mappings":"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"}
@@ -63,12 +63,13 @@ export class SqlApi {
63
63
  selectComputed: params.selectComputed,
64
64
  maxRows: params.maxRows,
65
65
  tenant,
66
+ request,
66
67
  });
67
68
  }
68
69
  async get(tableName, id, request) {
69
70
  const tableConf = this.getTableConf(tableName);
70
71
  const tenant = await this.getTenant(tableName, request);
71
- return getEngine({ db: this.db, tableConf, id, tenant });
72
+ return getEngine({ db: this.db, tableConf, id, tenant, request });
72
73
  }
73
74
  async insert(tableName, params, request) {
74
75
  const tableConf = this.getTableConf(tableName);
@@ -1 +1 @@
1
- {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EACP,MAAM,aAAa,CAAC;AAarB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAOX;AAqFD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
1
+ {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EAEP,MAAM,aAAa,CAAC;AAcrB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAWX;AAgSD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
@@ -1,6 +1,7 @@
1
1
  import { ConditionBuilder } from 'node-condition-builder';
2
2
  import { Type } from '@sinclair/typebox';
3
3
  import { getDialect } from './dialect.js';
4
+ import { hasOwnField } from './read-access.js';
4
5
  // Quote an identifier using the given ConditionBuilder dialect (or the global
5
6
  // default when not provided). Needed so that DB columns with uppercase letters
6
7
  // (e.g. betterauth "userId") are preserved on PostgreSQL (which folds unquoted
@@ -49,9 +50,115 @@ export function defineTable(config) {
49
50
  validateAliasUniqueness(config.allowedWriteJoins, 'allowedWriteJoins');
50
51
  validateWriteJoinsUnrestricted(config.allowedWriteJoins);
51
52
  validateComputedFields(config);
53
+ validatePrimary(config);
52
54
  validateReadExclude(config);
55
+ validateWriteExclude(config);
56
+ validateTenantScope(config);
57
+ validateTenantColumnNames(config);
53
58
  return config;
54
59
  }
60
+ /**
61
+ * `tenantScope` has three forms and they do not mix: `{ column }`, `{ column, through }`, and
62
+ * `{ anyOf }`. A config naming more than one form is ambiguous rather than additive — an `anyOf`
63
+ * entry reached through a parent FK would need its own join per entry — and an empty `anyOf`
64
+ * would fail closed on every row, which looks like a broken deployment rather than a rule.
65
+ *
66
+ * The columns it names are **real DB column names**, not camelCase schema fields — the one place
67
+ * in `defineTable` where the convention flips. That is not an oversight: a scope is applied to
68
+ * the SQL, after the payload has already been converted, and `through.foreignField` belongs to a
69
+ * parent table that may have no config, no `colMap`, or no generated schema at all. It is the
70
+ * same category as `db.*` and `extendedCondition`: names that go straight to the database.
71
+ *
72
+ * So existence cannot be checked — a hand-written schema may legitimately omit the column. What
73
+ * *is* checked is the mistake that exception invites: naming the schema **field** whose real
74
+ * column is something else. Left alone that surfaces as `column "organizationId" does not exist`
75
+ * on every request to the table — an opaque 500 (ADR 0006), failing closed but saying nothing.
76
+ */
77
+ /**
78
+ * Reject a tenant column that is really a schema field name mapping to a different column.
79
+ *
80
+ * The check is deliberately narrow, and it self-adjusts: on a camelCase database `col()` is the
81
+ * identity, so a camelCase name raises nothing. A name the schema does not carry raises nothing
82
+ * either — that is the hand-written-schema case, and there is nothing to compare it against.
83
+ */
84
+ function assertNamesColumn(schema, name, where, table) {
85
+ if (!hasOwnField(schema.fields, name))
86
+ return;
87
+ const column = schema.col(name);
88
+ if (column === name)
89
+ return;
90
+ throw new Error(`defineTable: tenantScope ${where} on table '${table}' names '${name}', which is a ` +
91
+ `schema field, not a column: its column is '${column}'. tenantScope names real DB ` +
92
+ `columns — the scope is applied to the SQL, after the payload has been converted — so ` +
93
+ `write '${column}' here. Clients keep sending '${name}'.`);
94
+ }
95
+ function validateTenantScope(config) {
96
+ const scope = config.tenantScope;
97
+ if (!scope)
98
+ return;
99
+ const table = config.Schema.tableName;
100
+ const hasAnyOf = 'anyOf' in scope;
101
+ const hasColumn = 'column' in scope;
102
+ if (hasAnyOf && hasColumn) {
103
+ throw new Error(`defineTable: tenantScope on table '${table}' declares both 'anyOf' and 'column'. ` +
104
+ `Use one form: 'column' (owner column on this table), 'column' + 'through' (owner ` +
105
+ `reached via a FK), or 'anyOf' (several owner columns, visible to any of them).`);
106
+ }
107
+ if (hasAnyOf && 'through' in scope) {
108
+ throw new Error(`defineTable: tenantScope on table '${table}' combines 'anyOf' with 'through'. ` +
109
+ `An 'anyOf' entry reached through a parent is not supported — declare the scope on ` +
110
+ `the parent table instead.`);
111
+ }
112
+ if (hasAnyOf) {
113
+ const { anyOf } = scope;
114
+ if (!Array.isArray(anyOf) || !anyOf.length) {
115
+ throw new Error(`defineTable: tenantScope 'anyOf' on table '${table}' must be a non-empty array of ` +
116
+ `column names. An empty list would hide every row of the table.`);
117
+ }
118
+ if (anyOf.some((c) => typeof c !== 'string' || !c)) {
119
+ throw new Error(`defineTable: tenantScope 'anyOf' on table '${table}' must contain column names as ` +
120
+ `non-empty strings.`);
121
+ }
122
+ if (new Set(anyOf).size !== anyOf.length) {
123
+ throw new Error(`defineTable: tenantScope 'anyOf' on table '${table}' repeats a column. ` +
124
+ `Each owner column must appear once.`);
125
+ }
126
+ return;
127
+ }
128
+ if (!hasColumn || typeof scope.column !== 'string') {
129
+ throw new Error(`defineTable: tenantScope on table '${table}' declares neither 'column' nor 'anyOf'. ` +
130
+ `A scope with no owner column would filter nothing.`);
131
+ }
132
+ }
133
+ /**
134
+ * Every column a scope names, checked against the schema it belongs to. Runs after
135
+ * `validateTenantScope`, so the shape is already known to be one of the three valid forms.
136
+ */
137
+ function validateTenantColumnNames(config) {
138
+ const scope = config.tenantScope;
139
+ if (!scope)
140
+ return;
141
+ const table = config.Schema.tableName;
142
+ if ('anyOf' in scope) {
143
+ for (const entry of scope.anyOf) {
144
+ assertNamesColumn(config.Schema, entry, "'anyOf'", table);
145
+ }
146
+ return;
147
+ }
148
+ const direct = scope;
149
+ assertNamesColumn(config.Schema, direct.column, "'column'", table);
150
+ // `through.localField` is a column of THIS table; `through.foreignField` one of the parent's,
151
+ // which is why the parent schema travels in the scope.
152
+ const through = direct.through;
153
+ if (!through || typeof through !== 'object')
154
+ return;
155
+ if (typeof through.localField === 'string') {
156
+ assertNamesColumn(config.Schema, through.localField, "'through.localField'", table);
157
+ }
158
+ if (typeof through.foreignField === 'string' && typeof through.schema?.col === 'function') {
159
+ assertNamesColumn(through.schema, through.foreignField, "'through.foreignField'", table);
160
+ }
161
+ }
55
162
  /**
56
163
  * `fields` narrows a relation for reading; it has no meaning on a write join and would be
57
164
  * actively harmful there. Write paths resolve the secondary's upsert rule by looking the
@@ -85,6 +192,68 @@ function validateReadExclude(config) {
85
192
  }
86
193
  }
87
194
  }
195
+ /**
196
+ * Every operation but `search` addresses a row by its primary key, and a `primary` naming a
197
+ * field the schema does not have reaches the database as a column that may not exist — a raw
198
+ * SQL error, so a 500 (ADR 0006), on the first request instead of at startup. Views are where
199
+ * this bites: they carry no PRIMARY KEY constraint, so the generator writes a placeholder
200
+ * rather than inventing a key that would silently address the wrong rows.
201
+ *
202
+ * `search` is the deliberate exception: it reads `primary` only as the fallback for ordering,
203
+ * so a table that exposes nothing else and names its own `defaultOrder` never touches it. That
204
+ * is exactly the shape the generator emits for a view whose key it could not infer, and it is
205
+ * a working config — not a broken one waiting to be fixed.
206
+ */
207
+ function validatePrimary(config) {
208
+ const schemaFields = Object.keys(config.Schema.fields);
209
+ const pkFields = Array.isArray(config.primary) ? config.primary : [config.primary];
210
+ const table = config.Schema.tableName;
211
+ const missing = pkFields.filter((f) => !schemaFields.includes(f));
212
+ if (missing.length === 0)
213
+ return;
214
+ const searchOnly = config.operations?.length === 1 && config.operations[0] === 'search';
215
+ if (searchOnly) {
216
+ if (config.defaultOrder)
217
+ return;
218
+ throw new Error(`defineTable: table '${table}' exposes only 'search' with an unresolved primary key ` +
219
+ `('${missing[0]}'), so it must declare a 'defaultOrder' — otherwise the search orders ` +
220
+ `by that missing column.`);
221
+ }
222
+ const exposed = config.operations
223
+ ? `operations [${config.operations.join(', ')}]`
224
+ : 'every operation (no `operations` whitelist, so all routes are exposed)';
225
+ throw new Error(`defineTable: primary key field '${missing[0]}' is not a schema field on table ` +
226
+ `'${table}', and the table exposes ${exposed} — those address rows by the primary key. ` +
227
+ `Name a column that is unique in this relation, or restrict the table to ` +
228
+ `operations: ['search'] with an explicit defaultOrder.`);
229
+ }
230
+ /**
231
+ * `writeExclude` names schema fields that no write may carry. A field outside the schema is a
232
+ * typo; the primary key is refused because the update body identifies the row with it; and a
233
+ * field that is also `readExclude`d would be neither readable nor writable, which is what
234
+ * removing it from the Schema means — keeping it there only grows the surface for nothing.
235
+ */
236
+ function validateWriteExclude(config) {
237
+ const excluded = config.writeExclude;
238
+ if (!excluded?.length)
239
+ return;
240
+ const schemaFields = Object.keys(config.Schema.fields);
241
+ const pkFields = Array.isArray(config.primary) ? config.primary : [config.primary];
242
+ const table = config.Schema.tableName;
243
+ for (const field of excluded) {
244
+ if (!schemaFields.includes(field)) {
245
+ throw new Error(`defineTable: writeExclude field '${field}' is not a schema field on table '${table}'.`);
246
+ }
247
+ if (pkFields.includes(field)) {
248
+ throw new Error(`defineTable: writeExclude cannot cover the primary key field '${field}' on table ` +
249
+ `'${table}' — the update body identifies the row with it.`);
250
+ }
251
+ if (config.readExclude?.includes(field)) {
252
+ throw new Error(`defineTable: field '${field}' on table '${table}' is in both readExclude and ` +
253
+ `writeExclude, leaving it neither readable nor writable. Remove it from the Schema instead.`);
254
+ }
255
+ }
256
+ }
88
257
  function validateComputedFields(config) {
89
258
  const computed = config.computedFields;
90
259
  if (!computed)