tina4-nodejs 3.13.137 → 3.13.139

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 (255) hide show
  1. package/CLAUDE.md +33 -3
  2. package/COMMERCIAL-LICENSE.md +8 -0
  3. package/LICENSE +373 -0
  4. package/NOTICE +11 -0
  5. package/README.md +24 -2
  6. package/package.json +44 -6
  7. package/packages/cli/dist/bin.js +19979 -18102
  8. package/packages/cli/src/bin.ts +8 -0
  9. package/packages/cli/src/commands/build.ts +8 -0
  10. package/packages/cli/src/commands/generate.ts +8 -0
  11. package/packages/cli/src/commands/init.ts +8 -0
  12. package/packages/cli/src/commands/lint.ts +8 -0
  13. package/packages/cli/src/commands/migrate.ts +8 -0
  14. package/packages/cli/src/commands/migrateCreate.ts +8 -0
  15. package/packages/cli/src/commands/migrateRollback.ts +8 -0
  16. package/packages/cli/src/commands/migrateStatus.ts +8 -0
  17. package/packages/cli/src/commands/queue.ts +8 -0
  18. package/packages/cli/src/commands/routes.ts +8 -0
  19. package/packages/cli/src/commands/seed.ts +8 -0
  20. package/packages/cli/src/commands/serve.ts +11 -0
  21. package/packages/cli/src/commands/test.ts +8 -0
  22. package/packages/core/dist/index.js +19976 -18097
  23. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +8 -0
  24. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +8 -0
  25. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +8 -0
  26. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +8 -0
  27. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +8 -0
  28. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +8 -0
  29. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +8 -0
  30. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +8 -0
  31. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +8 -0
  32. package/packages/core/gallery/queue/src/lib/queueDb.ts +8 -0
  33. package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +8 -0
  34. package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +8 -0
  35. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +8 -0
  36. package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +8 -0
  37. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +8 -0
  38. package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +8 -0
  39. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +8 -0
  40. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +8 -0
  41. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +8 -0
  42. package/packages/core/gallery/templates/src/templates/gallery_page.twig +8 -0
  43. package/packages/core/public/THIRD-PARTY-NOTICES.md +55 -0
  44. package/packages/core/public/bundled-component-provenance.json +48 -0
  45. package/packages/core/public/js/tina4js.min.js +13 -3
  46. package/packages/core/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  47. package/packages/core/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  48. package/packages/core/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  49. package/packages/core/public/licenses/tina4-css-MIT.txt +21 -0
  50. package/packages/core/public/licenses/tina4-dev-admin-components.json +240 -0
  51. package/packages/core/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  52. package/packages/core/public/licenses/tina4-js-MIT.txt +20 -0
  53. package/packages/core/src/__feedback/widget.js +8 -0
  54. package/packages/core/src/_missing.ts +8 -0
  55. package/packages/core/src/ai.ts +8 -0
  56. package/packages/core/src/aiClient.ts +8 -0
  57. package/packages/core/src/api.ts +48 -7
  58. package/packages/core/src/auth.ts +94 -14
  59. package/packages/core/src/authGate.ts +35 -20
  60. package/packages/core/src/background.ts +8 -0
  61. package/packages/core/src/cache.ts +43 -4
  62. package/packages/core/src/constants.ts +8 -0
  63. package/packages/core/src/container.ts +8 -0
  64. package/packages/core/src/context/chunker.ts +8 -0
  65. package/packages/core/src/context/index.ts +8 -0
  66. package/packages/core/src/devAdmin.ts +185 -59
  67. package/packages/core/src/devMailbox.ts +8 -0
  68. package/packages/core/src/dispatchPipeline.ts +8 -0
  69. package/packages/core/src/docs.ts +8 -0
  70. package/packages/core/src/docsAutoDiscovery.ts +8 -0
  71. package/packages/core/src/docsParser.ts +8 -0
  72. package/packages/core/src/docsScanner.ts +44 -0
  73. package/packages/core/src/docsSignatures.ts +8 -0
  74. package/packages/core/src/dotenv.ts +8 -0
  75. package/packages/core/src/env.ts +8 -0
  76. package/packages/core/src/errorOverlay.ts +8 -0
  77. package/packages/core/src/events.ts +8 -0
  78. package/packages/core/src/fakeData.ts +8 -0
  79. package/packages/core/src/feedback.ts +8 -0
  80. package/packages/core/src/graphql.ts +108 -5
  81. package/packages/core/src/health.ts +8 -0
  82. package/packages/core/src/htmlElement.ts +8 -0
  83. package/packages/core/src/i18n.ts +8 -0
  84. package/packages/core/src/index.ts +9 -0
  85. package/packages/core/src/job.ts +16 -1
  86. package/packages/core/src/logger.ts +8 -0
  87. package/packages/core/src/mcp.test.ts +8 -0
  88. package/packages/core/src/mcp.ts +11 -1
  89. package/packages/core/src/messenger.ts +143 -86
  90. package/packages/core/src/metrics.ts +13 -0
  91. package/packages/core/src/middleware.ts +1181 -1097
  92. package/packages/core/src/mqtt.ts +8 -0
  93. package/packages/core/src/mqttMessage.ts +8 -0
  94. package/packages/core/src/optionalPackage.ts +48 -0
  95. package/packages/core/src/plan.ts +8 -0
  96. package/packages/core/src/portTakeover.ts +8 -0
  97. package/packages/core/src/projectIndex.ts +8 -0
  98. package/packages/core/src/projectIndexExtractors.ts +8 -0
  99. package/packages/core/src/projectIndexStorage.ts +8 -0
  100. package/packages/core/src/push.ts +24 -0
  101. package/packages/core/src/queue.ts +23 -2
  102. package/packages/core/src/queueBackends/kafkaBackend.ts +8 -0
  103. package/packages/core/src/queueBackends/liteBackend.ts +23 -0
  104. package/packages/core/src/queueBackends/mongoBackend.ts +61 -14
  105. package/packages/core/src/queueBackends/rabbitmqBackend.ts +8 -0
  106. package/packages/core/src/rateLimiter.ts +8 -0
  107. package/packages/core/src/request.ts +59 -10
  108. package/packages/core/src/response.ts +79 -5
  109. package/packages/core/src/routeDiscovery.ts +8 -0
  110. package/packages/core/src/router.ts +8 -0
  111. package/packages/core/src/server.ts +79 -25
  112. package/packages/core/src/service.ts +8 -0
  113. package/packages/core/src/session.ts +8 -0
  114. package/packages/core/src/sessionHandlers/childError.ts +8 -0
  115. package/packages/core/src/sessionHandlers/databaseHandler.ts +68 -3
  116. package/packages/core/src/sessionHandlers/memcachedHandler.ts +8 -0
  117. package/packages/core/src/sessionHandlers/mongoClient.ts +8 -0
  118. package/packages/core/src/sessionHandlers/mongoHandler.ts +8 -0
  119. package/packages/core/src/sessionHandlers/respClient.ts +8 -0
  120. package/packages/core/src/sessionHandlers/sqlClient.ts +8 -0
  121. package/packages/core/src/sessionHandlers/syncBridge.ts +8 -0
  122. package/packages/core/src/sessionHandlers/syncSocket.ts +8 -0
  123. package/packages/core/src/sessionHandlers/valkeyHandler.ts +8 -0
  124. package/packages/core/src/sso.ts +20 -4
  125. package/packages/core/src/ssoIdentity.ts +19 -0
  126. package/packages/core/src/ssrf.ts +221 -0
  127. package/packages/core/src/static.ts +8 -0
  128. package/packages/core/src/test.ts +8 -0
  129. package/packages/core/src/testClient.ts +11 -0
  130. package/packages/core/src/testing.ts +8 -0
  131. package/packages/core/src/transport.ts +369 -0
  132. package/packages/core/src/trustedProxy.ts +8 -0
  133. package/packages/core/src/types.ts +13 -0
  134. package/packages/core/src/validator.ts +8 -0
  135. package/packages/core/src/version.ts +8 -0
  136. package/packages/core/src/websocket.ts +12 -2
  137. package/packages/core/src/websocketBackplane.ts +49 -32
  138. package/packages/core/src/websocketConnection.ts +8 -0
  139. package/packages/core/src/wsdl.ts +197 -115
  140. package/packages/core/templates/errors/302.twig +8 -0
  141. package/packages/core/templates/errors/401.twig +8 -0
  142. package/packages/core/templates/errors/403.twig +8 -0
  143. package/packages/core/templates/errors/404.twig +8 -0
  144. package/packages/core/templates/errors/500.twig +8 -0
  145. package/packages/core/templates/errors/502.twig +8 -0
  146. package/packages/core/templates/errors/503.twig +8 -0
  147. package/packages/core/templates/errors/base.twig +8 -0
  148. package/packages/frond/dist/index.js +157 -17
  149. package/packages/frond/src/engine.ts +186 -17
  150. package/packages/frond/src/index.ts +8 -0
  151. package/packages/orm/dist/index.js +8641 -6765
  152. package/packages/orm/src/adapters/firebird.ts +50 -3
  153. package/packages/orm/src/adapters/mongodb.ts +8 -0
  154. package/packages/orm/src/adapters/mssql.ts +29 -7
  155. package/packages/orm/src/adapters/mysql.ts +17 -1
  156. package/packages/orm/src/adapters/odbc.ts +11 -0
  157. package/packages/orm/src/adapters/pg-types.d.ts +8 -0
  158. package/packages/orm/src/adapters/postgres.ts +34 -8
  159. package/packages/orm/src/adapters/sqlDialect.ts +57 -0
  160. package/packages/orm/src/adapters/sqlite.ts +26 -0
  161. package/packages/orm/src/autoCrud.ts +78 -36
  162. package/packages/orm/src/baseModel.ts +61 -29
  163. package/packages/orm/src/cachedDatabase.ts +67 -1
  164. package/packages/orm/src/connectTimeout.ts +8 -0
  165. package/packages/orm/src/database.ts +649 -401
  166. package/packages/orm/src/databaseResult.ts +8 -0
  167. package/packages/orm/src/databaseUrl.ts +8 -0
  168. package/packages/orm/src/docstore.ts +23 -1
  169. package/packages/orm/src/fakeData.ts +8 -0
  170. package/packages/orm/src/graph/adapters/arango.ts +8 -0
  171. package/packages/orm/src/graph/adapters/bolt.ts +8 -0
  172. package/packages/orm/src/graph/adapters/ultipa.ts +8 -0
  173. package/packages/orm/src/graph/connectTimeout.ts +8 -0
  174. package/packages/orm/src/graph/errors.ts +8 -0
  175. package/packages/orm/src/graph/graphAdapter.ts +8 -0
  176. package/packages/orm/src/graph/graphDatabase.ts +8 -0
  177. package/packages/orm/src/graph/graphUrl.ts +10 -1
  178. package/packages/orm/src/graph/shapes.ts +8 -0
  179. package/packages/orm/src/index.ts +147 -139
  180. package/packages/orm/src/migration.ts +8 -0
  181. package/packages/orm/src/model.ts +8 -0
  182. package/packages/orm/src/modelCollection.ts +8 -0
  183. package/packages/orm/src/point.ts +8 -0
  184. package/packages/orm/src/query.ts +149 -33
  185. package/packages/orm/src/queryBuilder.ts +8 -0
  186. package/packages/orm/src/realtime/index.ts +8 -0
  187. package/packages/orm/src/realtime/models/attachment.ts +8 -0
  188. package/packages/orm/src/realtime/models/channel.ts +8 -0
  189. package/packages/orm/src/realtime/models/channelMember.ts +8 -0
  190. package/packages/orm/src/realtime/models/message.ts +8 -0
  191. package/packages/orm/src/realtime/models/workspace.ts +8 -0
  192. package/packages/orm/src/realtime/realtime.ts +8 -0
  193. package/packages/orm/src/realtime/storage.ts +43 -12
  194. package/packages/orm/src/seeder.ts +8 -0
  195. package/packages/orm/src/seederTable.ts +8 -0
  196. package/packages/orm/src/seederTypes.ts +8 -0
  197. package/packages/orm/src/sqlTranslator.ts +185 -8
  198. package/packages/orm/src/types.ts +15 -0
  199. package/packages/orm/src/validation.ts +8 -0
  200. package/packages/swagger/src/generator.ts +8 -0
  201. package/packages/swagger/src/index.ts +8 -0
  202. package/packages/swagger/src/ui.ts +8 -0
  203. package/types/core/src/api.d.ts +6 -0
  204. package/types/core/src/auth.d.ts +30 -0
  205. package/types/core/src/background.d.ts +0 -16
  206. package/types/core/src/cache.d.ts +6 -0
  207. package/types/core/src/devAdmin.d.ts +18 -12
  208. package/types/core/src/docs.d.ts +0 -17
  209. package/types/core/src/errorOverlay.d.ts +0 -24
  210. package/types/core/src/events.d.ts +0 -12
  211. package/types/core/src/feedback.d.ts +0 -23
  212. package/types/core/src/graphql.d.ts +25 -21
  213. package/types/core/src/htmlElement.d.ts +0 -15
  214. package/types/core/src/index.d.ts +1 -0
  215. package/types/core/src/job.d.ts +1 -0
  216. package/types/core/src/messenger.d.ts +12 -1
  217. package/types/core/src/metrics.d.ts +2 -0
  218. package/types/core/src/middleware.d.ts +45 -1
  219. package/types/core/src/mqtt.d.ts +0 -33
  220. package/types/core/src/optionalPackage.d.ts +25 -0
  221. package/types/core/src/plan.d.ts +0 -10
  222. package/types/core/src/projectIndex.d.ts +0 -10
  223. package/types/core/src/push.d.ts +5 -0
  224. package/types/core/src/queue.d.ts +7 -1
  225. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  226. package/types/core/src/queueBackends/mongoBackend.d.ts +10 -1
  227. package/types/core/src/request.d.ts +12 -0
  228. package/types/core/src/response.d.ts +13 -0
  229. package/types/core/src/server.d.ts +19 -0
  230. package/types/core/src/sessionHandlers/childError.d.ts +0 -15
  231. package/types/core/src/sessionHandlers/databaseHandler.d.ts +25 -1
  232. package/types/core/src/sso.d.ts +1 -0
  233. package/types/core/src/ssoIdentity.d.ts +10 -0
  234. package/types/core/src/ssrf.d.ts +19 -0
  235. package/types/core/src/transport.d.ts +28 -0
  236. package/types/core/src/trustedProxy.d.ts +0 -16
  237. package/types/core/src/types.d.ts +5 -0
  238. package/types/core/src/websocketBackplane.d.ts +1 -0
  239. package/types/core/src/wsdl.d.ts +8 -22
  240. package/types/frond/src/engine.d.ts +7 -1
  241. package/types/orm/src/adapters/firebird.d.ts +12 -0
  242. package/types/orm/src/adapters/mssql.d.ts +11 -0
  243. package/types/orm/src/adapters/mysql.d.ts +2 -0
  244. package/types/orm/src/adapters/postgres.d.ts +11 -0
  245. package/types/orm/src/adapters/sqlDialect.d.ts +25 -0
  246. package/types/orm/src/adapters/sqlite.d.ts +7 -0
  247. package/types/orm/src/baseModel.d.ts +5 -0
  248. package/types/orm/src/cachedDatabase.d.ts +27 -0
  249. package/types/orm/src/database.d.ts +46 -7
  250. package/types/orm/src/graph/graphUrl.d.ts +0 -8
  251. package/types/orm/src/index.d.ts +1 -1
  252. package/types/orm/src/query.d.ts +48 -2
  253. package/types/orm/src/realtime/storage.d.ts +2 -1
  254. package/types/orm/src/sqlTranslator.d.ts +70 -18
  255. package/types/orm/src/types.d.ts +6 -0
@@ -1,9 +1,17 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  import type { RouteDefinition, Tina4Request, Tina4Response } from "../../core/src/index.js";
2
10
  import type { DiscoveredModel } from "./model.js";
3
11
  import type { FieldDefinition } from "./types.js";
4
- import { getAdapter, adapterQuery, adapterExecute } from "./database.js";
12
+ import { getAdapter, getNamedAdapter, adapterQuery, adapterExecute, adapterFetch, quoteIdentifier } from "./database.js";
5
13
  import { DatabaseResult } from "./databaseResult.js";
6
- import { buildQuery, parseQueryString } from "./query.js";
14
+ import { buildQuery, parseQueryString, resolveField, resolveFieldColumn, UnknownFieldError, InvalidQueryParameterError } from "./query.js";
7
15
  import { validate } from "./validation.js";
8
16
 
9
17
  /**
@@ -23,6 +31,7 @@ import { validate } from "./validation.js";
23
31
  */
24
32
  function allowListedBody(
25
33
  fields: Record<string, FieldDefinition>,
34
+ columnOf: (field: string) => string,
26
35
  pkField: string,
27
36
  body: Record<string, unknown> | null | undefined,
28
37
  isCreate: boolean,
@@ -31,10 +40,14 @@ function allowListedBody(
31
40
  const stripPk = isCreate ? pkDef?.autoIncrement === true : true;
32
41
  const allowed: Record<string, unknown> = {};
33
42
  for (const [key, value] of Object.entries(body ?? {})) {
34
- if (!(key in fields)) continue;
35
- if (key === "is_deleted") continue;
36
- if (stripPk && key === pkField) continue;
37
- allowed[key] = value;
43
+ // ADR-0069 (G1): the SAME resolver the list route uses - a declared field
44
+ // by its property or its column; anything else (including an inherited
45
+ // Object property such as "constructor") is dropped.
46
+ const field = resolveField(fields, columnOf, key);
47
+ if (field === null) continue;
48
+ if (field === "is_deleted") continue;
49
+ if (stripPk && field === pkField) continue;
50
+ allowed[field] = value;
38
51
  }
39
52
  return allowed;
40
53
  }
@@ -154,7 +167,11 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
154
167
  const writeSecurity = options.public === true ? { secure: false as const } : {};
155
168
 
156
169
  for (const { definition } of models) {
157
- const { tableName, fields, softDelete, tableFilter, fieldMapping } = definition;
170
+ const { tableName, fields, softDelete, tableFilter, fieldMapping, dbName } = definition;
171
+
172
+ // The connection the model is bound to (`static _db = "name"`), never the
173
+ // global default behind its back - the same resolution BaseModel.getDb() uses.
174
+ const modelAdapter = () => (dbName ? getNamedAdapter(dbName) : getAdapter());
158
175
  const basePath = `/api/${tableName}`;
159
176
  const mapping = fieldMapping ?? {};
160
177
 
@@ -168,7 +185,9 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
168
185
  // Build extra WHERE conditions for soft delete and table filter
169
186
  const extraConditions: string[] = [];
170
187
  if (softDelete) {
171
- extraConditions.push(`"is_deleted" = 0`);
188
+ // Bare, like BaseModel's soft-delete filter: resolves on every engine
189
+ // (Firebird folds it to IS_DELETED, the name its DDL created).
190
+ extraConditions.push(`is_deleted = 0`);
172
191
  }
173
192
  if (tableFilter) {
174
193
  extraConditions.push(tableFilter);
@@ -183,23 +202,42 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
183
202
  tags: [tableName],
184
203
  },
185
204
  handler: async (req: Tina4Request, res: Tina4Response) => {
186
- const adapter = getAdapter();
205
+ const adapter = modelAdapter();
206
+ const q = (name: string): string => quoteIdentifier(adapter, name);
187
207
 
188
- // Parse query params for filtering / sorting / pagination
189
- const qp = parseQueryString(req.query ?? {});
208
+ // Parse query params for filtering / sorting / pagination.
190
209
  // limit/offset/page are the CLAMPED/CAPPED values buildQuery actually used
191
210
  // for the SQL (PAGE-DEC-01: page >= 1, per-page <= DEFAULT_ROW_CAP) — read
192
211
  // them back here instead of recomputing from the raw qp, so the envelope
193
212
  // can never drift from the query that ran.
194
- const { sql, countSql, params, limit, offset } = buildQuery(tableName, qp, extraConditions);
213
+ //
214
+ // Filter keys and sort fields resolve against the model's DECLARED fields
215
+ // (ADR-0069); an unknown one is a 400 before any SQL runs.
216
+ // A wrong-shaped value (a list/map where one value belongs) is a 400 too.
217
+ let built: ReturnType<typeof buildQuery>;
218
+ try {
219
+ const qp = parseQueryString(req.query ?? {});
220
+ built = buildQuery(tableName, qp, extraConditions, q, (key) => resolveFieldColumn(fields, getDbCol, key));
221
+ } catch (err) {
222
+ if (err instanceof UnknownFieldError) {
223
+ res.error("UNKNOWN_FIELD", err.message, 400);
224
+ return;
225
+ }
226
+ if (err instanceof InvalidQueryParameterError) {
227
+ res.error("INVALID_QUERY_PARAMETER", err.message, 400);
228
+ return;
229
+ }
230
+ throw err;
231
+ }
232
+ const { pageSql, countSql, filterParams, limit, offset } = built;
195
233
 
196
- // params includes limit and offset at the end; countSql doesn't need them
197
- const countParams = params.slice(0, -2);
198
- const rows = await adapterQuery(adapter, sql, params);
234
+ // adapterFetch pages in the engine's own syntax (LIMIT/OFFSET, OFFSET
235
+ // FETCH on MSSQL, ROWS on Firebird) - the same path BaseModel reads use.
236
+ const rows = await adapterFetch(adapter, pageSql, filterParams, limit, offset);
199
237
 
200
238
  // total is the TRUE total for the filter (a COUNT probe), NEVER the number
201
239
  // of rows this page returned (ADR-0043).
202
- const countRow = await adapterQuery(adapter, countSql, countParams);
240
+ const countRow = await adapterQuery(adapter, countSql, filterParams);
203
241
  const total = Number(countRow[0]?.total ?? 0);
204
242
 
205
243
  // The REST list envelope IS the canonical paginate envelope: exactly the
@@ -220,11 +258,12 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
220
258
  tags: [tableName],
221
259
  },
222
260
  handler: async (req: Tina4Request, res: Tina4Response) => {
223
- const adapter = getAdapter();
261
+ const adapter = modelAdapter();
262
+ const q = (name: string): string => quoteIdentifier(adapter, name);
224
263
 
225
- const conditions = [`"${pkColumn}" = ?`, ...extraConditions];
264
+ const conditions = [`${q(pkColumn)} = ?`, ...extraConditions];
226
265
  const rows = await adapterQuery(adapter,
227
- `SELECT * FROM "${tableName}" WHERE ${conditions.join(" AND ")}`,
266
+ `SELECT * FROM ${q(tableName)} WHERE ${conditions.join(" AND ")}`,
228
267
  [req.params.id],
229
268
  );
230
269
 
@@ -247,12 +286,13 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
247
286
  tags: [tableName],
248
287
  },
249
288
  handler: async (req: Tina4Request, res: Tina4Response) => {
250
- const adapter = getAdapter();
289
+ const adapter = modelAdapter();
290
+ const q = (name: string): string => quoteIdentifier(adapter, name);
251
291
  const rawBody = req.body as Record<string, unknown>;
252
292
 
253
293
  // CRUD-MASS-ASSIGNMENT: allow-list before anything downstream (both
254
294
  // validation and persistence) ever sees the body.
255
- const body = allowListedBody(fields, pkField, rawBody, true);
295
+ const body = allowListedBody(fields, getDbCol, pkField, rawBody, true);
256
296
 
257
297
  // Validate against field definitions
258
298
  const errors = validate(body, fields);
@@ -276,8 +316,8 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
276
316
  // tolerates RETURNING but we still prefer its lastId below.
277
317
  const isSqlite = adapter.constructor.name === "SQLiteAdapter";
278
318
  const insertSql =
279
- `INSERT INTO "${tableName}" (${columns.map((c) => `"${c}"`).join(", ")}) VALUES (${placeholders})` +
280
- (isSqlite ? "" : ` RETURNING "${pkColumn}"`);
319
+ `INSERT INTO ${q(tableName)} (${columns.map((c) => q(c)).join(", ")}) VALUES (${placeholders})` +
320
+ (isSqlite ? "" : ` RETURNING ${q(pkColumn)}`);
281
321
 
282
322
  const insertResult = await adapterExecute(adapter, insertSql, values);
283
323
 
@@ -295,7 +335,7 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
295
335
 
296
336
  // Fetch the created record to include auto-generated fields (e.g. id)
297
337
  const created = await adapterQuery(adapter,
298
- `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = ?`,
338
+ `SELECT * FROM ${q(tableName)} WHERE ${q(pkColumn)} = ?`,
299
339
  [lastId],
300
340
  );
301
341
 
@@ -313,14 +353,15 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
313
353
  tags: [tableName],
314
354
  },
315
355
  handler: async (req: Tina4Request, res: Tina4Response) => {
316
- const adapter = getAdapter();
356
+ const adapter = modelAdapter();
357
+ const q = (name: string): string => quoteIdentifier(adapter, name);
317
358
  const rawBody = req.body as Record<string, unknown>;
318
359
 
319
360
  // CRUD-MASS-ASSIGNMENT: allow-list -- the row is addressed by the URL
320
361
  // {id} alone, so a body PK is stripped (never lets the write rename
321
362
  // the row's own identity column or, on a mis-wired WHERE, redirect
322
363
  // to a different row); is_deleted is guarded the same as create.
323
- const body = allowListedBody(fields, pkField, rawBody, false);
364
+ const body = allowListedBody(fields, getDbCol, pkField, rawBody, false);
324
365
 
325
366
  // Feature 19 (VALID-NODE-PUT-NOVALIDATE): the update path validates the
326
367
  // request body too — previously only POST did, so a PUT could write
@@ -334,9 +375,9 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
334
375
  return;
335
376
  }
336
377
 
337
- const conditions = [`"${pkColumn}" = ?`, ...extraConditions];
378
+ const conditions = [`${q(pkColumn)} = ?`, ...extraConditions];
338
379
  const existing = await adapterQuery(adapter,
339
- `SELECT * FROM "${tableName}" WHERE ${conditions.join(" AND ")}`,
380
+ `SELECT * FROM ${q(tableName)} WHERE ${conditions.join(" AND ")}`,
340
381
  [req.params.id],
341
382
  );
342
383
  if (existing.length === 0) {
@@ -359,17 +400,17 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
359
400
  }
360
401
 
361
402
  const setClauses = Object.keys(dbBody)
362
- .map((col) => `"${col}" = ?`)
403
+ .map((col) => `${q(col)} = ?`)
363
404
  .join(", ");
364
405
  const values = [...Object.values(dbBody), req.params.id];
365
406
 
366
407
  await adapterExecute(adapter,
367
- `UPDATE "${tableName}" SET ${setClauses} WHERE "${pkColumn}" = ?`,
408
+ `UPDATE ${q(tableName)} SET ${setClauses} WHERE ${q(pkColumn)} = ?`,
368
409
  values,
369
410
  );
370
411
 
371
412
  const updated = await adapterQuery(adapter,
372
- `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = ?`,
413
+ `SELECT * FROM ${q(tableName)} WHERE ${q(pkColumn)} = ?`,
373
414
  [req.params.id],
374
415
  );
375
416
 
@@ -387,11 +428,12 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
387
428
  tags: [tableName],
388
429
  },
389
430
  handler: async (req: Tina4Request, res: Tina4Response) => {
390
- const adapter = getAdapter();
431
+ const adapter = modelAdapter();
432
+ const q = (name: string): string => quoteIdentifier(adapter, name);
391
433
 
392
- const conditions = [`"${pkColumn}" = ?`, ...extraConditions];
434
+ const conditions = [`${q(pkColumn)} = ?`, ...extraConditions];
393
435
  const existing = await adapterQuery(adapter,
394
- `SELECT * FROM "${tableName}" WHERE ${conditions.join(" AND ")}`,
436
+ `SELECT * FROM ${q(tableName)} WHERE ${conditions.join(" AND ")}`,
395
437
  [req.params.id],
396
438
  );
397
439
  if (existing.length === 0) {
@@ -401,13 +443,13 @@ export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudO
401
443
 
402
444
  if (softDelete) {
403
445
  await adapterExecute(adapter,
404
- `UPDATE "${tableName}" SET is_deleted = 1 WHERE "${pkColumn}" = ?`,
446
+ `UPDATE ${q(tableName)} SET is_deleted = 1 WHERE ${q(pkColumn)} = ?`,
405
447
  [req.params.id],
406
448
  );
407
449
  res.json({ message: "Deleted (soft)", data: existing[0] });
408
450
  } else {
409
451
  await adapterExecute(adapter,
410
- `DELETE FROM "${tableName}" WHERE "${pkColumn}" = ?`,
452
+ `DELETE FROM ${q(tableName)} WHERE ${q(pkColumn)} = ?`,
411
453
  [req.params.id],
412
454
  );
413
455
  res.json({ message: "Deleted", data: existing[0] });
@@ -1,13 +1,22 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  import {
2
10
  getAdapter, getNamedAdapter, setAdapter, parseDatabaseUrl,
3
11
  adapterQuery, adapterFetch, adapterExecute, adapterFetchOne,
4
12
  adapterStartTransaction, adapterCommit, adapterRollback,
5
13
  adapterTableExists, adapterCreateTable, extractLastInsertId,
6
- probeTotal, DEFAULT_ROW_CAP,
14
+ probeTotal, DEFAULT_ROW_CAP, quoteIdentifier,
7
15
  } from "./database.js";
8
16
  import { ModelCollection } from "./modelCollection.js";
9
17
  import { validate as validateFields } from "./validation.js";
10
18
  import { QueryBuilder } from "./queryBuilder.js";
19
+ import { resolveFieldColumn } from "./query.js";
11
20
  import { SQLiteAdapter } from "./adapters/sqlite.js";
12
21
  import { QueryCache, SQLTranslator } from "./sqlTranslator.js";
13
22
  import { Log } from "../../core/src/index.js";
@@ -379,6 +388,14 @@ export class BaseModel {
379
388
  * SQLite URLs are initialised synchronously. Other engines require initDatabase()
380
389
  * to be called before first use.
381
390
  */
391
+ /**
392
+ * Quote a table/column name in the dialect of the adapter this model runs on
393
+ * (MySQL backticks, Firebird upper-cased, `"name"` elsewhere).
394
+ */
395
+ protected static quoteName(name: string): string {
396
+ return quoteIdentifier(this.getDb(), name);
397
+ }
398
+
382
399
  protected static getDb(): DatabaseAdapter {
383
400
  if (this._db) {
384
401
  return getNamedAdapter(this._db);
@@ -497,7 +514,7 @@ export class BaseModel {
497
514
  static async findById<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, id: unknown, include?: string[]): Promise<T | null> {
498
515
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
499
516
  const pkCol = ModelClass.getPkColumn();
500
- let sql = `SELECT * FROM "${ModelClass.tableName}" WHERE "${pkCol}" = ?`;
517
+ let sql = `SELECT * FROM ${ModelClass.quoteName(ModelClass.tableName)} WHERE ${ModelClass.quoteName(pkCol)} = ?`;
501
518
 
502
519
  if (ModelClass.softDelete) {
503
520
  sql += ` AND is_deleted = 0`;
@@ -598,8 +615,12 @@ export class BaseModel {
598
615
 
599
616
  if (filter) {
600
617
  for (const [key, value] of Object.entries(filter)) {
601
- const col = ModelClass.getDbColumn(key) ?? key;
602
- conditions.push(`"${col}" = ?`);
618
+ // ADR-0069: a filter key must be a declared field (or its column).
619
+ const col = resolveFieldColumn(ModelClass.fields, (field) => ModelClass.getDbColumn(field), key);
620
+ if (col === null) {
621
+ throw new Error(`Unknown filter field '${key}' for model ${ModelClass.name}`);
622
+ }
623
+ conditions.push(`${ModelClass.quoteName(col)} = ?`);
603
624
  params.push(value);
604
625
  }
605
626
  }
@@ -608,7 +629,7 @@ export class BaseModel {
608
629
  conditions.push("is_deleted = 0");
609
630
  }
610
631
 
611
- let sql = `SELECT * FROM "${ModelClass.tableName}"`;
632
+ let sql = `SELECT * FROM ${ModelClass.quoteName(ModelClass.tableName)}`;
612
633
  if (conditions.length > 0) {
613
634
  sql += ` WHERE ${conditions.join(" AND ")}`;
614
635
  }
@@ -648,7 +669,7 @@ export class BaseModel {
648
669
  const pkCol = ModelClass.getPkColumn();
649
670
  const pkValue = (this as any)[pkProp];
650
671
  if (pkValue === undefined || pkValue === null) return false;
651
- sql = `SELECT * FROM "${table}" WHERE "${pkCol}" = ?`;
672
+ sql = `SELECT * FROM ${ModelClass.quoteName(table)} WHERE ${ModelClass.quoteName(pkCol)} = ?`;
652
673
  params = [pkValue];
653
674
  } else {
654
675
  sql = `SELECT * FROM ${table} WHERE ${filter}`;
@@ -712,7 +733,7 @@ export class BaseModel {
712
733
  const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
713
734
  // No LIMIT/OFFSET embedded: _collect's adapterFetch applies them to the page
714
735
  // and the COUNT probe wraps the un-limited SQL so the total is the whole set.
715
- const sql = `SELECT * FROM "${ModelClass.tableName}"${whereClause}${orderClause}`;
736
+ const sql = `SELECT * FROM ${ModelClass.quoteName(ModelClass.tableName)}${whereClause}${orderClause}`;
716
737
 
717
738
  // No bind parameters: the only conditions left are the framework's own
718
739
  // softDelete / tableFilter literals. A caller-supplied filter belongs on
@@ -754,7 +775,7 @@ export class BaseModel {
754
775
  const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
755
776
  // No LIMIT/OFFSET embedded — _collect applies them to the page and probes
756
777
  // the total (ADR-0064). orderBy affects the page only; COUNT is order-free.
757
- const sql = `SELECT * FROM "${ModelClass.tableName}" WHERE ${parts.join(" AND ")}${orderClause}`;
778
+ const sql = `SELECT * FROM ${ModelClass.quoteName(ModelClass.tableName)} WHERE ${parts.join(" AND ")}${orderClause}`;
758
779
 
759
780
  return ModelClass._collect<T>(sql, params, limit, offset, include);
760
781
  }
@@ -840,13 +861,13 @@ export class BaseModel {
840
861
  try {
841
862
  if (ModelClass.softDelete) {
842
863
  await adapterExecute(db,
843
- `UPDATE "${ModelClass.tableName}" SET is_deleted = 1 WHERE ${this.pkWhere().sql}`,
864
+ `UPDATE ${ModelClass.quoteName(ModelClass.tableName)} SET is_deleted = 1 WHERE ${this.pkWhere().sql}`,
844
865
  this.pkWhere().params,
845
866
  );
846
867
  this.is_deleted = 1;
847
868
  } else {
848
869
  await adapterExecute(db,
849
- `DELETE FROM "${ModelClass.tableName}" WHERE ${this.pkWhere().sql}`,
870
+ `DELETE FROM ${ModelClass.quoteName(ModelClass.tableName)} WHERE ${this.pkWhere().sql}`,
850
871
  this.pkWhere().params,
851
872
  );
852
873
  }
@@ -1136,7 +1157,7 @@ export class BaseModel {
1136
1157
  await adapterStartTransaction(db);
1137
1158
  try {
1138
1159
  await adapterExecute(db,
1139
- `DELETE FROM "${ModelClass.tableName}" WHERE ${this.pkWhere().sql}`,
1160
+ `DELETE FROM ${ModelClass.quoteName(ModelClass.tableName)} WHERE ${this.pkWhere().sql}`,
1140
1161
  this.pkWhere().params,
1141
1162
  );
1142
1163
  await adapterCommit(db);
@@ -1169,7 +1190,7 @@ export class BaseModel {
1169
1190
  await adapterStartTransaction(db);
1170
1191
  try {
1171
1192
  await adapterExecute(db,
1172
- `UPDATE "${ModelClass.tableName}" SET is_deleted = 0 WHERE ${this.pkWhere().sql}`,
1193
+ `UPDATE ${ModelClass.quoteName(ModelClass.tableName)} SET is_deleted = 0 WHERE ${this.pkWhere().sql}`,
1173
1194
  this.pkWhere().params,
1174
1195
  );
1175
1196
  await adapterCommit(db);
@@ -1203,7 +1224,7 @@ export class BaseModel {
1203
1224
  parts.push(conditions);
1204
1225
  }
1205
1226
 
1206
- let sql = `SELECT * FROM "${ModelClass.tableName}"`;
1227
+ let sql = `SELECT * FROM ${ModelClass.quoteName(ModelClass.tableName)}`;
1207
1228
  if (parts.length > 0) {
1208
1229
  sql += ` WHERE ${parts.join(" AND ")}`;
1209
1230
  }
@@ -1230,7 +1251,7 @@ export class BaseModel {
1230
1251
  parts.push(conditions);
1231
1252
  }
1232
1253
  const whereClause = parts.length > 0 ? ` WHERE ${parts.join(" AND ")}` : "";
1233
- const sql = `SELECT COUNT(*) as cnt FROM "${this.tableName}"${whereClause}`;
1254
+ const sql = `SELECT COUNT(*) as cnt FROM ${this.quoteName(this.tableName)}${whereClause}`;
1234
1255
  const rows = await adapterQuery(db, sql, params);
1235
1256
  if (rows.length === 0) return 0;
1236
1257
  // PostgreSQL returns COUNT(*) as a bigint, which the `pg` driver hands
@@ -1277,7 +1298,7 @@ export class BaseModel {
1277
1298
  }
1278
1299
 
1279
1300
  const db = relatedClass.getDb();
1280
- let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${foreignKey}" = ?`;
1301
+ let sql = `SELECT * FROM ${quoteIdentifier(db, relatedClass.tableName)} WHERE ${quoteIdentifier(db, foreignKey)} = ?`;
1281
1302
  if (relatedClass.softDelete) {
1282
1303
  sql += ` AND is_deleted = 0`;
1283
1304
  }
@@ -1325,12 +1346,12 @@ export class BaseModel {
1325
1346
 
1326
1347
  const db = relatedClass.getDb();
1327
1348
  const orderCol = relatedClass.getPkColumn();
1328
- let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${foreignKey}" = ?`;
1349
+ let sql = `SELECT * FROM ${quoteIdentifier(db, relatedClass.tableName)} WHERE ${quoteIdentifier(db, foreignKey)} = ?`;
1329
1350
  if (relatedClass.softDelete) {
1330
1351
  sql += ` AND is_deleted = 0`;
1331
1352
  }
1332
1353
  // Order by the child PK for a stable read (parity with the lazy accessor).
1333
- sql += ` ORDER BY "${orderCol}"`;
1354
+ sql += ` ORDER BY ${quoteIdentifier(db, orderCol)}`;
1334
1355
  // IMPREL-PY-CAP parity: with no explicit limit, return the WHOLE set (like
1335
1356
  // the lazy accessor, which is uncapped) instead of a silent 100-row cap. An
1336
1357
  // explicit limit still pages (explicit, never silent).
@@ -1369,7 +1390,7 @@ export class BaseModel {
1369
1390
 
1370
1391
  const db = relatedClass.getDb();
1371
1392
  const relatedPkCol = relatedClass.getPkColumn();
1372
- let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${relatedPkCol}" = ?`;
1393
+ let sql = `SELECT * FROM ${quoteIdentifier(db, relatedClass.tableName)} WHERE ${quoteIdentifier(db, relatedPkCol)} = ?`;
1373
1394
  if (relatedClass.softDelete) {
1374
1395
  sql += ` AND is_deleted = 0`;
1375
1396
  }
@@ -1504,9 +1525,9 @@ export class BaseModel {
1504
1525
  if (pkValue === undefined || pkValue === null) return [];
1505
1526
  const db = relatedClass.getDb();
1506
1527
  const orderCol = relatedClass.getPkColumn();
1507
- let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${foreignKey}" = ?`;
1528
+ let sql = `SELECT * FROM ${quoteIdentifier(db, relatedClass.tableName)} WHERE ${quoteIdentifier(db, foreignKey)} = ?`;
1508
1529
  if (relatedClass.softDelete) sql += ` AND is_deleted = 0`;
1509
- sql += ` ORDER BY "${orderCol}"`;
1530
+ sql += ` ORDER BY ${quoteIdentifier(db, orderCol)}`;
1510
1531
  const rows = await adapterQuery(db, sql, [pkValue]);
1511
1532
  return rows.map((row) => new (relatedClass as unknown as new (d: Record<string, unknown>) => BaseModel)(row as Record<string, unknown>));
1512
1533
  }
@@ -1606,11 +1627,11 @@ function validateBeforeSave(instance: BaseModel, model: typeof BaseModel, isUpda
1606
1627
  async function executeModelUpdate(instance: BaseModel, model: typeof BaseModel, db: DatabaseAdapter): Promise<void> {
1607
1628
  const fields = Object.entries(model.fields).filter(([name, def]) => !def.primaryKey && instance[name] !== undefined);
1608
1629
  if (fields.length === 0) return;
1609
- const setClause = fields.map(([name]) => `"${model.getDbColumn(name)}" = ?`).join(", ");
1630
+ const setClause = fields.map(([name]) => `${quoteIdentifier(db, model.getDbColumn(name))} = ?`).join(", ");
1610
1631
  const values = fields.map(([name, def]) => toDbFieldValue(def, instance[name]));
1611
1632
  const where = savePrimaryKeyWhere(instance, model);
1612
1633
  values.push(...where.params);
1613
- await adapterExecute(db, `UPDATE "${model.tableName}" SET ${setClause} WHERE ${where.sql}`, values);
1634
+ await adapterExecute(db, `UPDATE ${quoteIdentifier(db, model.tableName)} SET ${setClause} WHERE ${where.sql}`, values);
1614
1635
  }
1615
1636
 
1616
1637
  function buildInsertStatement(
@@ -1623,17 +1644,28 @@ function buildInsertStatement(
1623
1644
  const fields = Object.entries(model.fields).filter(
1624
1645
  ([name, def]) => !(def.primaryKey && def.autoIncrement) && instance[name] !== undefined,
1625
1646
  );
1626
- const returning = pkField?.autoIncrement && db.constructor.name !== "SQLiteAdapter" ? ` RETURNING "${pkCol}"` : "";
1647
+ // The generated key comes back three ways: PostgreSQL and Firebird take a
1648
+ // trailing RETURNING, MSSQL an OUTPUT INSERTED clause before VALUES, and
1649
+ // SQLite / MySQL report it through lastInsertId(). MySQL and MSSQL reject a
1650
+ // RETURNING outright, so an auto-increment save() used to fail on both. The
1651
+ // engine comes from getDatabaseType(): the ORM holds the CachedDatabaseAdapter
1652
+ // wrapper, whose constructor.name never names the engine.
1653
+ const engine = db.getDatabaseType?.() ?? "";
1654
+ const wantsKey = Boolean(pkField?.autoIncrement);
1655
+ const returning = wantsKey && (engine === "postgres" || engine === "firebird")
1656
+ ? ` RETURNING ${quoteIdentifier(db, pkCol)}` : "";
1657
+ const output = wantsKey && engine === "mssql" ? ` OUTPUT INSERTED.${quoteIdentifier(db, pkCol)}` : "";
1658
+ const table = quoteIdentifier(db, model.tableName);
1627
1659
  if (fields.length === 0) {
1628
- const emptyInsert = db.constructor.name === "MysqlAdapter"
1629
- ? `INSERT INTO "${model.tableName}" () VALUES ()`
1630
- : `INSERT INTO "${model.tableName}" DEFAULT VALUES`;
1660
+ const emptyInsert = engine === "mysql"
1661
+ ? `INSERT INTO ${table} () VALUES ()`
1662
+ : `INSERT INTO ${table}${output} DEFAULT VALUES`;
1631
1663
  return { sql: emptyInsert + returning, values: [] };
1632
1664
  }
1633
- const columns = fields.map(([name]) => `"${model.getDbColumn(name)}"`).join(", ");
1665
+ const columns = fields.map(([name]) => quoteIdentifier(db, model.getDbColumn(name))).join(", ");
1634
1666
  const placeholders = fields.map(() => "?").join(", ");
1635
1667
  const values = fields.map(([name, def]) => toDbFieldValue(def, instance[name]));
1636
- return { sql: `INSERT INTO "${model.tableName}" (${columns}) VALUES (${placeholders})${returning}`, values };
1668
+ return { sql: `INSERT INTO ${table} (${columns})${output} VALUES (${placeholders})${returning}`, values };
1637
1669
  }
1638
1670
 
1639
1671
  function applyInsertedId(
@@ -1804,7 +1836,7 @@ async function eagerQueryRelated(
1804
1836
  const related: BaseModel[] = [];
1805
1837
  for (const chunk of _chunk(values, EAGER_IN_CHUNK)) {
1806
1838
  const placeholders = chunk.map(() => "?").join(",");
1807
- let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${column}" IN (${placeholders})`;
1839
+ let sql = `SELECT * FROM ${quoteIdentifier(db, relatedClass.tableName)} WHERE ${quoteIdentifier(db, column)} IN (${placeholders})`;
1808
1840
  if (relatedClass.softDelete) sql += " AND is_deleted = 0";
1809
1841
  const rows = await adapterQuery(db, sql, chunk);
1810
1842
  for (const row of rows) related.push(new relatedClass(row as Record<string, unknown>));
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Tina4 Cached Database — Transparent query cache decorator for DatabaseAdapter.
3
11
  *
@@ -32,7 +40,8 @@
32
40
  * db.cacheStats(); // { enabled, mode, hits, misses, size, ttl }
33
41
  */
34
42
 
35
- import { QueryCache } from "./sqlTranslator.js";
43
+ import { QueryCache, SQLTranslator } from "./sqlTranslator.js";
44
+ import { quoteIdentifierAnsi } from "./adapters/sqlDialect.js";
36
45
  import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "./types.js";
37
46
  import type { CacheBackend } from "../../core/src/index.js";
38
47
 
@@ -309,6 +318,25 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
309
318
  if (b) { try { await b.set(key, { row }, this.ttl); } catch { /* best effort */ } }
310
319
  }
311
320
 
321
+ /**
322
+ * A write that arrives through a READ method (fetch/fetchOne/query of an
323
+ * `INSERT ... RETURNING`) is still a write: its result must never be cached
324
+ * (a repeat would answer from the cache and never insert) and it flushes the
325
+ * cache like every other write (tina4-python#133 contract).
326
+ */
327
+ private isWriteRead(sql: string): boolean {
328
+ return this.enabled && SQLTranslator.isWriteStatement(sql);
329
+ }
330
+
331
+ /** Run a write that came through a read method, then flush (never store). */
332
+ private runWrite<R>(run: () => R): R {
333
+ try { return run(); } finally { this.invalidate(); }
334
+ }
335
+
336
+ private async runWriteAsync<R>(run: () => Promise<R>): Promise<R> {
337
+ try { return await run(); } finally { await this.invalidateAsync(); }
338
+ }
339
+
312
340
  // ── DatabaseAdapter interface — writes flush, reads cache ──
313
341
 
314
342
  /** ADR-0044 required capability — delegates to the wrapped adapter. */
@@ -316,11 +344,28 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
316
344
  return this.adapter.connect?.();
317
345
  }
318
346
 
347
+ /**
348
+ * The derived-table alias the COUNT probe needs, forwarded from the wrapped
349
+ * adapter. SQL Server and MySQL REQUIRE `FROM (<sql>) AS _count_query`; the
350
+ * probe reads this property off whatever adapter it is handed, and
351
+ * Database.create() hands it THIS wrapper. Without the forward the probe ran
352
+ * unaliased, the engine rejected it, the error was swallowed, and
353
+ * `result.count` silently became the page length on those two engines.
354
+ */
355
+ get countSubqueryAlias(): string | undefined {
356
+ return (this.adapter as { countSubqueryAlias?: string } | null)?.countSubqueryAlias;
357
+ }
358
+
319
359
  /** ADR-0044 required capability — delegates to the wrapped adapter. */
320
360
  getDatabaseType(): string {
321
361
  return this.adapter.getDatabaseType();
322
362
  }
323
363
 
364
+ /** Identifier quoting is the wrapped engine's, never the wrapper's. */
365
+ quoteIdentifier(name: string): string {
366
+ return this.adapter.quoteIdentifier ? this.adapter.quoteIdentifier(name) : quoteIdentifierAnsi(name);
367
+ }
368
+
324
369
  /**
325
370
  * ADR-0044 required capability — a native boolean, readable and writable.
326
371
  * A getter/setter pair (not a plain field) so it genuinely delegates to the
@@ -347,6 +392,21 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
347
392
  return this.adapter.execute(sql, params);
348
393
  }
349
394
 
395
+ /**
396
+ * Forward an adapter's row-returning execute (SQLite, Firebird), for
397
+ * Database.execute(). Resolves `undefined` when the wrapped adapter has none
398
+ * - WITHOUT running anything - so the caller falls back to execute().
399
+ */
400
+ async executeRowsAsync(sql: string, params?: unknown[]): Promise<unknown[] | undefined> {
401
+ const inner = this.adapter as unknown as {
402
+ executeRowsAsync?: (s: string, p?: unknown[]) => Promise<unknown[]>;
403
+ executeRows?: (s: string, p?: unknown[]) => unknown[];
404
+ };
405
+ if (!inner.executeRowsAsync && !inner.executeRows) return undefined;
406
+ if (this.enabled) await this.invalidateAsync();
407
+ return inner.executeRowsAsync ? inner.executeRowsAsync(sql, params) : inner.executeRows!(sql, params);
408
+ }
409
+
350
410
  executeMany(sql: string, paramsList: unknown[][]): import("./types.js").DatabaseResult | { totalAffected: number; lastId?: number | bigint } {
351
411
  if (this.enabled) this.invalidate();
352
412
  return this.adapter.executeMany(sql, paramsList);
@@ -357,6 +417,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
357
417
  // so caching here is what makes ORM reads dedupe — matching the Python master
358
418
  // where every ORM read flows through the cached db.fetch(). Same store, same
359
419
  // counters, flushed on writes.
420
+ if (this.isWriteRead(sql)) return this.runWrite(() => this.adapter.query<T>(sql, params));
360
421
  if (this.enabled) {
361
422
  const key = QueryCache.queryKey(sql + ":Q", params as unknown[] | undefined, this.identity);
362
423
  const cached = this.cache.get<T[]>(key);
@@ -377,6 +438,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
377
438
  // store, run straight against the underlying adapter (mirrors the Python
378
439
  // master's `no_cache`). Counters are left untouched so a bypass read isn't
379
440
  // misreported as a hit or a miss.
441
+ if (this.isWriteRead(sql)) return this.runWrite(() => this.adapter.fetch<T>(sql, params, limit, skip));
380
442
  if (this.enabled && !noCache) {
381
443
  const key = QueryCache.queryKey(sql + `:L${limit}:S${skip}`, params as unknown[] | undefined, this.identity);
382
444
  const cached = this.cache.get<T[]>(key);
@@ -393,6 +455,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
393
455
  }
394
456
 
395
457
  fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[], noCache?: boolean): T | null {
458
+ if (this.isWriteRead(sql)) return this.runWrite(() => this.adapter.fetchOne<T>(sql, params));
396
459
  if (this.enabled && !noCache) {
397
460
  const key = QueryCache.queryKey(sql + ":ONE", params as unknown[] | undefined, this.identity);
398
461
  const cached = this.cache.get<T | null>(key);
@@ -488,6 +551,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
488
551
  : this.adapter.fetch<T>(sql, params, limit, skip);
489
552
  // `noCache` bypasses both cache layers for this one call — no lookup, no
490
553
  // store, run directly (mirrors the Python master's `no_cache`).
554
+ if (this.isWriteRead(sql)) return this.runWriteAsync(run);
491
555
  if (this.enabled && !noCache) {
492
556
  const key = QueryCache.queryKey(sql + `:L${limit}:S${skip}`, params as unknown[] | undefined, this.identity);
493
557
  // Persistent distributed backend is AUTHORITATIVE (mirrors Python, where a
@@ -520,6 +584,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
520
584
  ? await (this.adapter as any).fetchOneAsync(sql, params)
521
585
  : this.adapter.fetchOne<T>(sql, params);
522
586
  // `noCache` bypasses both cache layers for this one call (see fetchAsync).
587
+ if (this.isWriteRead(sql)) return this.runWriteAsync(run);
523
588
  if (this.enabled && !noCache) {
524
589
  const key = QueryCache.queryKey(sql + ":ONE", params as unknown[] | undefined, this.identity);
525
590
  if (this.usesPersistentBackend()) {
@@ -547,6 +612,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
547
612
  const run = async (): Promise<T[]> => (this.adapter as any).queryAsync
548
613
  ? await (this.adapter as any).queryAsync(sql, params)
549
614
  : this.adapter.query<T>(sql, params);
615
+ if (this.isWriteRead(sql)) return this.runWriteAsync(run);
550
616
  if (this.enabled) {
551
617
  const key = QueryCache.queryKey(sql + ":Q", params as unknown[] | undefined, this.identity);
552
618
  if (this.usesPersistentBackend()) {
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * TINA4_DATABASE_CONNECT_TIMEOUT - bound a database connect attempt.
3
11
  *