tina4-nodejs 3.13.92 → 3.13.95

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 (193) hide show
  1. package/CLAUDE.md +170 -28
  2. package/README.md +2 -2
  3. package/package.json +13 -9
  4. package/packages/cli/dist/bin.js +33126 -30055
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +33062 -29908
  8. package/packages/core/src/ai.ts +7 -1
  9. package/packages/core/src/auth.ts +191 -39
  10. package/packages/core/src/background.ts +19 -19
  11. package/packages/core/src/cache.ts +492 -49
  12. package/packages/core/src/devAdmin.ts +79 -32
  13. package/packages/core/src/devMailbox.ts +20 -44
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +7 -6
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +81 -13
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +6 -9
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +751 -414
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  35. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  36. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
  37. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  38. package/packages/core/src/sessionHandlers/respClient.ts +16 -143
  39. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  40. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  41. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  42. package/packages/core/src/testClient.ts +18 -5
  43. package/packages/core/src/trustedProxy.ts +249 -0
  44. package/packages/core/src/types.ts +29 -5
  45. package/packages/core/src/websocket.ts +66 -0
  46. package/packages/frond/dist/index.js +74 -31
  47. package/packages/frond/src/engine.ts +99 -33
  48. package/packages/orm/dist/index.js +26554 -23400
  49. package/packages/orm/src/adapters/firebird.ts +183 -56
  50. package/packages/orm/src/adapters/mongodb.ts +25 -4
  51. package/packages/orm/src/adapters/mssql.ts +114 -29
  52. package/packages/orm/src/adapters/mysql.ts +103 -40
  53. package/packages/orm/src/adapters/odbc.ts +44 -21
  54. package/packages/orm/src/adapters/postgres.ts +118 -26
  55. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  56. package/packages/orm/src/adapters/sqlite.ts +64 -25
  57. package/packages/orm/src/baseModel.ts +135 -40
  58. package/packages/orm/src/cachedDatabase.ts +43 -19
  59. package/packages/orm/src/connectTimeout.ts +265 -0
  60. package/packages/orm/src/database.ts +338 -198
  61. package/packages/orm/src/databaseResult.ts +65 -13
  62. package/packages/orm/src/databaseUrl.ts +484 -0
  63. package/packages/orm/src/docstore.ts +386 -145
  64. package/packages/orm/src/index.ts +13 -3
  65. package/packages/orm/src/migration.ts +18 -3
  66. package/packages/orm/src/queryBuilder.ts +38 -4
  67. package/packages/orm/src/sqlTranslator.ts +310 -4
  68. package/packages/orm/src/types.ts +15 -4
  69. package/types/cli/src/bin.d.ts +92 -0
  70. package/types/cli/src/commands/build.d.ts +2 -0
  71. package/types/cli/src/commands/generate.d.ts +47 -0
  72. package/types/cli/src/commands/init.d.ts +1 -0
  73. package/types/cli/src/commands/metrics.d.ts +6 -0
  74. package/types/cli/src/commands/migrate.d.ts +1 -0
  75. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  76. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  77. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  78. package/types/cli/src/commands/queue.d.ts +20 -0
  79. package/types/cli/src/commands/routes.d.ts +1 -0
  80. package/types/cli/src/commands/seed.d.ts +1 -0
  81. package/types/cli/src/commands/serve.d.ts +6 -0
  82. package/types/cli/src/commands/test.d.ts +1 -0
  83. package/types/core/src/ai.d.ts +64 -0
  84. package/types/core/src/api.d.ts +262 -0
  85. package/types/core/src/auth.d.ts +177 -0
  86. package/types/core/src/authGate.d.ts +20 -0
  87. package/types/core/src/background.d.ts +34 -0
  88. package/types/core/src/cache.d.ts +163 -0
  89. package/types/core/src/constants.d.ts +38 -0
  90. package/types/core/src/container.d.ts +44 -0
  91. package/types/core/src/context/chunker.d.ts +31 -0
  92. package/types/core/src/context/index.d.ts +93 -0
  93. package/types/core/src/devAdmin.d.ts +179 -0
  94. package/types/core/src/devMailbox.d.ts +54 -0
  95. package/types/core/src/dispatchPipeline.d.ts +117 -0
  96. package/types/core/src/docs.d.ts +141 -0
  97. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  98. package/types/core/src/dotenv.d.ts +87 -0
  99. package/types/core/src/env.d.ts +28 -0
  100. package/types/core/src/errorOverlay.d.ts +36 -0
  101. package/types/core/src/events.d.ts +75 -0
  102. package/types/core/src/fakeData.d.ts +55 -0
  103. package/types/core/src/feedback.d.ts +90 -0
  104. package/types/core/src/graphql.d.ts +207 -0
  105. package/types/core/src/health.d.ts +22 -0
  106. package/types/core/src/htmlElement.d.ts +75 -0
  107. package/types/core/src/i18n.d.ts +37 -0
  108. package/types/core/src/index.d.ts +92 -0
  109. package/types/core/src/job.d.ts +39 -0
  110. package/types/core/src/logger.d.ts +200 -0
  111. package/types/core/src/mcp.d.ts +248 -0
  112. package/types/core/src/messenger.d.ts +191 -0
  113. package/types/core/src/metrics.d.ts +41 -0
  114. package/types/core/src/middleware.d.ts +330 -0
  115. package/types/core/src/mqtt.d.ts +257 -0
  116. package/types/core/src/mqttMessage.d.ts +67 -0
  117. package/types/core/src/plan.d.ts +96 -0
  118. package/types/core/src/projectIndex.d.ts +56 -0
  119. package/types/core/src/queue.d.ts +268 -0
  120. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  121. package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
  122. package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
  123. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  124. package/types/core/src/rateLimiter.d.ts +49 -0
  125. package/types/core/src/request.d.ts +25 -0
  126. package/types/core/src/response.d.ts +28 -0
  127. package/types/core/src/routeDiscovery.d.ts +12 -0
  128. package/types/core/src/router.d.ts +366 -0
  129. package/types/core/src/scss.d.ts +19 -0
  130. package/types/core/src/server.d.ts +146 -0
  131. package/types/core/src/service.d.ts +115 -0
  132. package/types/core/src/session.d.ts +341 -0
  133. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  134. package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
  135. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  136. package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
  137. package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
  138. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  139. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  140. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  141. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  142. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  143. package/types/core/src/static.d.ts +2 -0
  144. package/types/core/src/test.d.ts +94 -0
  145. package/types/core/src/testClient.d.ts +36 -0
  146. package/types/core/src/testing.d.ts +58 -0
  147. package/types/core/src/trustedProxy.d.ts +44 -0
  148. package/types/core/src/types.d.ts +242 -0
  149. package/types/core/src/validator.d.ts +52 -0
  150. package/types/core/src/websocket.d.ts +402 -0
  151. package/types/core/src/websocketBackplane.d.ts +166 -0
  152. package/types/core/src/websocketConnection.d.ts +54 -0
  153. package/types/core/src/wsdl.d.ts +101 -0
  154. package/types/frond/src/engine.d.ts +263 -0
  155. package/types/frond/src/index.d.ts +2 -0
  156. package/types/orm/src/adapters/firebird.d.ts +183 -0
  157. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  158. package/types/orm/src/adapters/mssql.d.ts +77 -0
  159. package/types/orm/src/adapters/mysql.d.ts +67 -0
  160. package/types/orm/src/adapters/odbc.d.ts +94 -0
  161. package/types/orm/src/adapters/postgres.d.ts +86 -0
  162. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  163. package/types/orm/src/adapters/sqlite.d.ts +68 -0
  164. package/types/orm/src/autoCrud.d.ts +73 -0
  165. package/types/orm/src/baseModel.d.ts +427 -0
  166. package/types/orm/src/cachedDatabase.d.ts +190 -0
  167. package/types/orm/src/connectTimeout.d.ts +100 -0
  168. package/types/orm/src/database.d.ts +655 -0
  169. package/types/orm/src/databaseResult.d.ts +109 -0
  170. package/types/orm/src/databaseUrl.d.ts +125 -0
  171. package/types/orm/src/docstore.d.ts +241 -0
  172. package/types/orm/src/fakeData.d.ts +22 -0
  173. package/types/orm/src/index.d.ts +43 -0
  174. package/types/orm/src/migration.d.ts +275 -0
  175. package/types/orm/src/model.d.ts +7 -0
  176. package/types/orm/src/query.d.ts +14 -0
  177. package/types/orm/src/queryBuilder.d.ts +193 -0
  178. package/types/orm/src/realtime/index.d.ts +7 -0
  179. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  180. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  181. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  182. package/types/orm/src/realtime/models/message.d.ts +36 -0
  183. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  184. package/types/orm/src/realtime/realtime.d.ts +24 -0
  185. package/types/orm/src/realtime/storage.d.ts +61 -0
  186. package/types/orm/src/seeder.d.ts +118 -0
  187. package/types/orm/src/sqlTranslator.d.ts +258 -0
  188. package/types/orm/src/types.d.ts +148 -0
  189. package/types/orm/src/validation.d.ts +6 -0
  190. package/types/swagger/src/generator.d.ts +46 -0
  191. package/types/swagger/src/index.d.ts +2 -0
  192. package/types/swagger/src/ui.d.ts +11 -0
  193. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
@@ -0,0 +1,120 @@
1
+ /**
2
+ * One CRUD SQL builder for every engine, instead of one per adapter.
3
+ *
4
+ * Feature 3's last open item, the 4.3x LOC finding: `insert`/`update`/`delete`
5
+ * built their SQL independently in all seven adapters. Building
6
+ * `INSERT INTO x (a, b) VALUES (?, ?)` is not engine-specific work - Ruby has
7
+ * always done it once - and the seven copies differed in exactly two ways:
8
+ *
9
+ * IDENTIFIER QUOTING "col" | `col` | [col] | Firebird's fbQuote
10
+ * PARAMETER MARKER ? | $1 | @p1
11
+ *
12
+ * Both are captured in a `Dialect` below, so the builders are shared and each
13
+ * adapter declares only what genuinely differs about its engine.
14
+ *
15
+ * These functions build STRINGS and nothing else. Execution and result
16
+ * extraction stay in the adapters on purpose: those really are per-driver
17
+ * (`client.query` vs `lastInsertRowid` vs a Firebird transaction handle), and
18
+ * folding them in here would trade a real duplication for a fake abstraction.
19
+ *
20
+ * MongoDB has no entry: it does not build SQL at all.
21
+ */
22
+
23
+ /** How one engine spells identifiers and parameter markers. */
24
+ export interface Dialect {
25
+ /** Quote a table or column name for this engine. */
26
+ quote(name: string): string;
27
+ /**
28
+ * The parameter marker for the 1-based position `index`. Engines with
29
+ * positional markers ($1, @p1) use the index; the rest ignore it.
30
+ */
31
+ marker(index: number): string;
32
+ }
33
+
34
+ const doubleQuote = (name: string): string => `"${name}"`;
35
+ const questionMark = (): string => "?";
36
+
37
+ /** SQLite, and ODBC which follows the SQL standard spelling. */
38
+ export const ANSI_DIALECT: Dialect = { quote: doubleQuote, marker: questionMark };
39
+
40
+ /** PostgreSQL: standard quoting, positional $N markers. */
41
+ export const POSTGRES_DIALECT: Dialect = {
42
+ quote: doubleQuote,
43
+ marker: (index) => `$${index}`,
44
+ };
45
+
46
+ /** MySQL: backtick quoting. */
47
+ export const MYSQL_DIALECT: Dialect = {
48
+ quote: (name) => `\`${name}\``,
49
+ marker: questionMark,
50
+ };
51
+
52
+ /** MSSQL: bracket quoting, named @pN markers. */
53
+ export const MSSQL_DIALECT: Dialect = {
54
+ quote: (name) => `[${name}]`,
55
+ marker: (index) => `@p${index}`,
56
+ };
57
+
58
+ /**
59
+ * Firebird quotes only when it has to: an unquoted identifier is folded to
60
+ * UPPER CASE, so quoting a lower-case name would make it unfindable. The
61
+ * adapter owns that rule and passes its own quoter in.
62
+ */
63
+ export function firebirdDialect(fbQuote: (name: string) => string): Dialect {
64
+ return { quote: fbQuote, marker: questionMark };
65
+ }
66
+
67
+ /**
68
+ * `INSERT INTO <table> (<cols>) VALUES (<markers>)`.
69
+ *
70
+ * @param suffix Appended verbatim - PostgreSQL passes " RETURNING *" and MSSQL
71
+ * its SCOPE_IDENTITY() probe, the genuinely engine-specific parts.
72
+ * @param startAt Position of the FIRST marker. PostgreSQL numbers its `$N` from
73
+ * 1; MSSQL names its `@pN` from 0 and BINDS by that same name, so
74
+ * shifting it would produce SQL whose parameters do not exist.
75
+ * Engines using `?` ignore this.
76
+ */
77
+ export function buildInsert(
78
+ dialect: Dialect,
79
+ table: string,
80
+ keys: string[],
81
+ suffix = "",
82
+ startAt = 1,
83
+ ): string {
84
+ const columns = keys.map((k) => dialect.quote(k)).join(", ");
85
+ const placeholders = keys.map((_, i) => dialect.marker(startAt + i)).join(", ");
86
+ return `INSERT INTO ${dialect.quote(table)} (${columns}) VALUES (${placeholders})${suffix}`;
87
+ }
88
+
89
+ /**
90
+ * The `SET a = ?, b = ?` fragment of an UPDATE.
91
+ *
92
+ * @param startAt 1-based position of the FIRST marker. An UPDATE's WHERE
93
+ * clause continues the numbering after the SET values, so a
94
+ * positional engine ($N, @pN) must not restart at 1.
95
+ */
96
+ export function buildSetClause(
97
+ dialect: Dialect,
98
+ keys: string[],
99
+ startAt = 1,
100
+ ): string {
101
+ return keys
102
+ .map((k, i) => `${dialect.quote(k)} = ${dialect.marker(startAt + i)}`)
103
+ .join(", ");
104
+ }
105
+
106
+ /**
107
+ * The `a = ? AND b = ?` fragment for an object filter.
108
+ *
109
+ * @param startAt 1-based position of the first marker, for the same reason as
110
+ * buildSetClause.
111
+ */
112
+ export function buildWhereClause(
113
+ dialect: Dialect,
114
+ keys: string[],
115
+ startAt = 1,
116
+ ): string {
117
+ return keys
118
+ .map((k, i) => `${dialect.quote(k)} = ${dialect.marker(startAt + i)}`)
119
+ .join(" AND ");
120
+ }
@@ -1,4 +1,5 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
+ import { ANSI_DIALECT, buildInsert, buildSetClause, buildWhereClause } from "./sqlDialect.js";
2
3
  import { mkdirSync } from "node:fs";
3
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
4
5
  import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "../types.js";
@@ -99,6 +100,18 @@ export class SQLiteAdapter implements DatabaseAdapter {
99
100
  private db: DatabaseSync;
100
101
  private _lastInsertId: number | bigint | null = null;
101
102
 
103
+ /**
104
+ * TINA4_DATABASE_CONNECT_TIMEOUT DOES NOT APPLY HERE, deliberately.
105
+ *
106
+ * There is no connect() to bound: `node:sqlite` opens the file in this
107
+ * SYNCHRONOUS constructor, and a synchronous call cannot be interrupted by a
108
+ * timer on the same thread - the event loop only gets to run the timer after
109
+ * `new DatabaseSync()` has already returned. There is also no host and no port
110
+ * to name in a timeout error. The one case that could still block is a local
111
+ * file on a wedged network mount, which is a kernel-level stall no JS bound
112
+ * can reach. Stated here so the exclusion reads as a decision rather than an
113
+ * adapter somebody forgot.
114
+ */
102
115
  constructor(dbPath: string) {
103
116
  const resolved = resolveSqlitePath(dbPath);
104
117
  this.db = new DatabaseSync(resolved);
@@ -145,17 +158,13 @@ export class SQLiteAdapter implements DatabaseAdapter {
145
158
  }
146
159
 
147
160
  fetch<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): T[] {
148
- let effectiveSql = sql;
149
- if (limit !== undefined) {
150
- // Skip appending LIMIT when the SQL already contains one (dedup)
151
- const sqlBeforeComment = sql.toUpperCase().split("--")[0];
152
- if (!sqlBeforeComment.includes("LIMIT")) {
153
- effectiveSql += ` LIMIT ${limit}`;
154
- if (skip !== undefined && skip > 0) {
155
- effectiveSql += ` OFFSET ${skip}`;
156
- }
157
- }
158
- }
161
+ // SQLTranslator.appendLimit owns BOTH halves of this decision: it scrubs
162
+ // literals and comments before asking "does the caller already have a
163
+ // LIMIT?", and it appends on a new line so the clause can never land inside
164
+ // a trailing `--` comment. The old inline version did neither, so
165
+ // `WHERE label != 'LIMIT'` and a trailing `-- LIMIT 5` each returned the
166
+ // WHOLE TABLE instead of the 100-row cap.
167
+ const effectiveSql = SQLTranslator.appendLimit(sql, limit, skip);
159
168
  return this.query<T>(effectiveSql, params);
160
169
  }
161
170
 
@@ -169,16 +178,14 @@ export class SQLiteAdapter implements DatabaseAdapter {
169
178
  if (Array.isArray(data)) {
170
179
  if (data.length === 0) return { success: true, affectedRows: 0 };
171
180
  const keys = Object.keys(data[0]);
172
- const placeholders = keys.map(() => "?").join(", ");
173
- const sql = `INSERT INTO "${table}" ("${keys.join('", "')}") VALUES (${placeholders})`;
181
+ const sql = buildInsert(ANSI_DIALECT, table, keys);
174
182
  const paramsList = data.map((row) => keys.map((k) => row[k]));
175
183
  const result = this.executeMany(sql, paramsList);
176
184
  return { success: true, affectedRows: result.totalAffected, lastId: result.lastId };
177
185
  }
178
186
 
179
187
  const keys = Object.keys(data);
180
- const placeholders = keys.map(() => "?").join(", ");
181
- const sql = `INSERT INTO "${table}" ("${keys.join('", "')}") VALUES (${placeholders})`;
188
+ const sql = buildInsert(ANSI_DIALECT, table, keys);
182
189
  const values = Object.values(data);
183
190
 
184
191
  try {
@@ -190,10 +197,30 @@ export class SQLiteAdapter implements DatabaseAdapter {
190
197
  }
191
198
  }
192
199
 
193
- update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
194
- const setClauses = Object.keys(data).map((k) => `"${k}" = ?`).join(", ");
195
- const whereClauses = Object.keys(filter).map((k) => `"${k}" = ?`).join(" AND ");
196
- const sql = `UPDATE "${table}" SET ${setClauses} WHERE ${whereClauses}`;
200
+ update(table: string, data: Record<string, unknown>, filter: Record<string, unknown> | string, params?: unknown[]): DatabaseResult {
201
+ const setClauses = buildSetClause(ANSI_DIALECT, Object.keys(data));
202
+
203
+ // A raw WHERE fragment + params is half the write_path contract's filter
204
+ // form ("a string filter with params works the same as a hash filter").
205
+ // Without this branch Object.keys("id = ?") yields the STRING INDICES
206
+ // ["0","1",...], producing `WHERE "0" = ? AND "1" = ?` and SQLite reports
207
+ // `no such column: "0"`. delete() below already carried this branch and
208
+ // update() did not — the same gap 3.13.94 closed in the postgres/mysql/
209
+ // mssql/firebird adapters, still open here on the DEFAULT engine.
210
+ if (typeof filter === "string") {
211
+ const where = filter ? ` WHERE ${filter}` : "";
212
+ const sql = `UPDATE ${ANSI_DIALECT.quote(table)} SET ${setClauses}${where}`;
213
+ const values = [...Object.values(data), ...(params ?? [])];
214
+ try {
215
+ const result = this.db.prepare(sql).run(...toSqlParams(values));
216
+ return { success: true, affectedRows: Number(result.changes) };
217
+ } catch (e) {
218
+ return { success: false, affectedRows: 0, error: (e as Error).message };
219
+ }
220
+ }
221
+
222
+ const whereClauses = buildWhereClause(ANSI_DIALECT, Object.keys(filter));
223
+ const sql = `UPDATE ${ANSI_DIALECT.quote(table)} SET ${setClauses} WHERE ${whereClauses}`;
197
224
  const values = [...Object.values(data), ...Object.values(filter)];
198
225
 
199
226
  try {
@@ -224,8 +251,8 @@ export class SQLiteAdapter implements DatabaseAdapter {
224
251
  }
225
252
  }
226
253
 
227
- const whereClauses = Object.keys(filter).map((k) => `"${k}" = ?`).join(" AND ");
228
- const sql = `DELETE FROM "${table}" WHERE ${whereClauses}`;
254
+ const whereClauses = buildWhereClause(ANSI_DIALECT, Object.keys(filter));
255
+ const sql = `DELETE FROM ${ANSI_DIALECT.quote(table)} WHERE ${whereClauses}`;
229
256
  const values = Object.values(filter);
230
257
 
231
258
  try {
@@ -260,14 +287,14 @@ export class SQLiteAdapter implements DatabaseAdapter {
260
287
  this._inTransaction = false;
261
288
  }
262
289
 
263
- tables(): string[] {
290
+ getTables(): string[] {
264
291
  const rows = this.query<{ name: string }>(
265
292
  "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
266
293
  );
267
294
  return rows.map((r) => r.name);
268
295
  }
269
296
 
270
- columns(table: string): ColumnInfo[] {
297
+ getColumns(table: string): ColumnInfo[] {
271
298
  // v3.13.14 (#48): a SQLite "schema" is an ATTACH alias ("extra.widget").
272
299
  // PRAGMA accepts a schema prefix when both parts are plain identifiers.
273
300
  const [schema, tbl] = SQLTranslator.splitSchema(table);
@@ -279,7 +306,10 @@ export class SQLiteAdapter implements DatabaseAdapter {
279
306
  name: string; type: string; notnull: number; dflt_value: unknown; pk: number;
280
307
  }>;
281
308
  return rows.map((r) => ({
282
- name: r.name, type: r.type, nullable: r.notnull === 0, default: r.dflt_value, primaryKey: r.pk === 1,
309
+ // PRAGMA table_info reports `pk` as the 1-BASED POSITION within the primary
310
+ // key, not a boolean: a composite key gives pk=1, pk=2, ... Testing `=== 1`
311
+ // reported only the first column of a composite key.
312
+ name: r.name, type: r.type, nullable: r.notnull === 0, default: r.dflt_value, primaryKey: Number(r.pk) > 0,
283
313
  }));
284
314
  }
285
315
 
@@ -360,10 +390,16 @@ export class SQLiteAdapter implements DatabaseAdapter {
360
390
 
361
391
  createTable(name: string, columns: Record<string, FieldDefinition>): void {
362
392
  const colDefs: string[] = [];
393
+ // A COMPOSITE key is declared ONCE, at table level (below). An inline
394
+ // PRIMARY KEY per column is invalid DDL - SQLite rejects it outright with
395
+ // "table X has more than one primary key", so a composite-key model could
396
+ // not create its own table at all.
397
+ const pkCols = Object.entries(columns).filter(([, d]) => d.primaryKey).map(([c]) => c);
398
+ const composite = pkCols.length > 1;
363
399
  for (const [colName, def] of Object.entries(columns)) {
364
400
  const sqlType = fieldTypeToSQLite(def.type);
365
401
  const parts = [`"${colName}" ${sqlType}`];
366
- if (def.primaryKey) parts.push("PRIMARY KEY");
402
+ if (def.primaryKey && !composite) parts.push("PRIMARY KEY");
367
403
  if (def.autoIncrement) parts.push("AUTOINCREMENT");
368
404
  if (def.required && !def.primaryKey) parts.push("NOT NULL");
369
405
  // A json column carries no DDL DEFAULT (parity with the Python master): an
@@ -372,6 +408,9 @@ export class SQLiteAdapter implements DatabaseAdapter {
372
408
  if (def.type !== "json" && def.default === "now") parts.push("DEFAULT CURRENT_TIMESTAMP");
373
409
  colDefs.push(parts.join(" "));
374
410
  }
411
+ if (composite) {
412
+ colDefs.push(`PRIMARY KEY (${pkCols.map((c) => `"${c}"`).join(", ")})`);
413
+ }
375
414
  this.db.exec(`CREATE TABLE IF NOT EXISTS "${name}" (${colDefs.join(", ")})`);
376
415
  }
377
416
 
@@ -3,11 +3,12 @@ import {
3
3
  adapterQuery, adapterFetch, adapterExecute, adapterFetchOne,
4
4
  adapterStartTransaction, adapterCommit, adapterRollback,
5
5
  adapterTableExists, adapterCreateTable, extractLastInsertId,
6
+ DEFAULT_ROW_CAP,
6
7
  } from "./database.js";
7
8
  import { validate as validateFields } from "./validation.js";
8
9
  import { QueryBuilder } from "./queryBuilder.js";
9
10
  import { SQLiteAdapter } from "./adapters/sqlite.js";
10
- import { QueryCache } from "./sqlTranslator.js";
11
+ import { QueryCache, SQLTranslator } from "./sqlTranslator.js";
11
12
  import { Log } from "../../core/src/index.js";
12
13
  import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
13
14
 
@@ -317,15 +318,17 @@ export class BaseModel {
317
318
  const url = process.env.TINA4_DATABASE_URL;
318
319
  if (url) {
319
320
  const parsed = parseDatabaseUrl(url);
320
- if (parsed.type === "sqlite") {
321
+ if (parsed.engine === "sqlite") {
321
322
  // SQLite adapter is synchronous — create it inline and register as default
322
- const dbPath = parsed.path ?? "./data/tina4.db";
323
- const adapter = new SQLiteAdapter(dbPath);
323
+ const dbPath = parsed.database || "./data/tina4.db";
324
+ // Typed as the INTERFACE so the optional identity tag is assignable.
325
+ const adapter: DatabaseAdapter = new SQLiteAdapter(dbPath);
326
+ adapter.cacheIdentity = QueryCache.cacheIdentity(url);
324
327
  setAdapter(adapter);
325
328
  return adapter;
326
329
  }
327
330
  throw new Error(
328
- `TINA4_DATABASE_URL is set to a non-SQLite engine ("${parsed.type}"). ` +
331
+ `TINA4_DATABASE_URL is set to a non-SQLite engine ("${parsed.engine}"). ` +
329
332
  `Call await initDatabase() at startup before using ORM models.`,
330
333
  );
331
334
  }
@@ -342,6 +345,34 @@ export class BaseModel {
342
345
  return Object.entries(this.fields).find(([, def]) => def.primaryKey)?.[0] ?? "id";
343
346
  }
344
347
 
348
+ /**
349
+ * EVERY primary-key field name, in declaration order.
350
+ *
351
+ * A key may span several columns. `getPkField()` returns only the FIRST and
352
+ * is kept for the auto-increment paths, which are single-column by
353
+ * definition. Anything that ADDRESSES a row must use this: keying on one
354
+ * column of a composite key matches every row sharing that value, which is
355
+ * the data-loss shape feature 4 removed from the raw write path below.
356
+ */
357
+ protected static getPkFields(): string[] {
358
+ const keys = Object.entries(this.fields)
359
+ .filter(([, def]) => def.primaryKey)
360
+ .map(([name]) => name);
361
+ return keys.length > 0 ? keys : ["id"];
362
+ }
363
+
364
+ /** A WHERE naming EVERY primary-key column, and its bound params. */
365
+ protected pkWhere(): { sql: string; params: unknown[] } {
366
+ const ModelClass = this.constructor as typeof BaseModel;
367
+ const clauses: string[] = [];
368
+ const params: unknown[] = [];
369
+ for (const name of ModelClass.getPkFields()) {
370
+ clauses.push(`${ModelClass.getDbColumn(name)} = ?`);
371
+ params.push((this as Record<string, unknown>)[name]);
372
+ }
373
+ return { sql: clauses.join(" AND "), params };
374
+ }
375
+
345
376
  /**
346
377
  * Get the primary key database column name (applies fieldMapping).
347
378
  */
@@ -538,16 +569,37 @@ export class BaseModel {
538
569
  }
539
570
 
540
571
  /**
541
- * Find all records, optionally with a where clause.
542
- * Alias: all()
543
- * @param where Optional WHERE clause.
544
- * @param params Optional query parameters.
545
- * @param include Optional array of relationship names to eager-load.
572
+ * Find all records.
573
+ *
574
+ * BREAKING (3.13.95, parity): the signature is now
575
+ * `all(limit?, offset?, include?, orderBy?)`. It NO LONGER accepts leading
576
+ * `where`/`params`.
577
+ *
578
+ * Node was the sole outlier of the four. The master and the other two never
579
+ * had a filter on `all()`:
580
+ * Python all(limit=100, offset=0, include=None, order_by=None)
581
+ * PHP all(int $limit = 100, int $offset = 0, ?array $include, ?string $orderBy)
582
+ * Ruby all(limit: 100, offset: nil, order_by: nil, include: nil)
583
+ * Node's extra leading parameters shifted every argument, so the same
584
+ * positional call meant different things in different languages -- which is
585
+ * precisely what the parity mandate exists to prevent.
586
+ *
587
+ * MIGRATION: a filtered read moves to `where()`, which already exists and
588
+ * takes the conditions first:
589
+ * before: User.all("age > ?", [28])
590
+ * after: User.where("age > ?", [28])
591
+ * TypeScript callers get a compile error (string is not assignable to number),
592
+ * so the break is loud rather than silent.
593
+ *
594
+ * @param limit Max records (default 100, the shared cross-framework cap).
595
+ * @param offset Records to skip (default 0).
596
+ * @param include Relationship names to eager-load.
597
+ * @param orderBy ORDER BY clause (e.g. "name ASC").
546
598
  */
547
599
  static async all<T extends BaseModel>(
548
600
  this: new (data?: Record<string, unknown>) => T,
549
- where?: string,
550
- params?: unknown[],
601
+ limit: number = DEFAULT_ROW_CAP,
602
+ offset: number = 0,
551
603
  include?: string[],
552
604
  orderBy?: string,
553
605
  ): Promise<T[]> {
@@ -561,15 +613,16 @@ export class BaseModel {
561
613
  if (ModelClass.tableFilter) {
562
614
  conditions.push(ModelClass.tableFilter);
563
615
  }
564
- if (where) {
565
- conditions.push(where);
566
- }
567
616
 
568
617
  const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
569
618
  const orderClause = orderBy ? ` ORDER BY ${orderBy}` : "";
570
- const sql = `SELECT * FROM "${ModelClass.tableName}"${whereClause}${orderClause}`;
619
+ const sql = `SELECT * FROM "${ModelClass.tableName}"${whereClause}${orderClause}`
620
+ + ` LIMIT ${limit} OFFSET ${offset}`;
571
621
 
572
- const rows = await adapterQuery(db, sql, params);
622
+ // No bind parameters: the only conditions left are the framework's own
623
+ // softDelete / tableFilter literals. A caller-supplied filter belongs on
624
+ // where(), which binds its params properly.
625
+ const rows = await adapterQuery(db, sql, []);
573
626
  const instances = rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
574
627
  if (include) {
575
628
  await ModelClass._eagerLoad(instances, include);
@@ -583,7 +636,7 @@ export class BaseModel {
583
636
  *
584
637
  * @param conditions WHERE clause (e.g. "age > ? AND active = ?")
585
638
  * @param params Bind parameters
586
- * @param limit Max records (default 20)
639
+ * @param limit Max records (default 100)
587
640
  * @param offset Skip records (default 0)
588
641
  * @param include Relationship names to eager-load
589
642
  * @param orderBy ORDER BY clause (e.g. "name ASC")
@@ -592,7 +645,7 @@ export class BaseModel {
592
645
  this: new (data?: Record<string, unknown>) => T,
593
646
  conditions: string,
594
647
  params?: unknown[],
595
- limit: number = 20,
648
+ limit: number = DEFAULT_ROW_CAP,
596
649
  offset: number = 0,
597
650
  include?: string[],
598
651
  orderBy?: string,
@@ -676,7 +729,23 @@ export class BaseModel {
676
729
  isUpdate = true;
677
730
  } else {
678
731
  try {
679
- isUpdate = await ModelClass.exists(pkValue);
732
+ // This asked exists(pkValue), which tests only the FIRST key column.
733
+ // On a composite key that is true for any row sharing that column, so
734
+ // inserting a genuinely NEW row was decided to be an UPDATE and
735
+ // silently OVERWROTE a different row: saving (acme, a2) rewrote
736
+ // (acme, a1). The check has to name the whole key, like the write
737
+ // that follows it.
738
+ const probe = this.pkWhere();
739
+ if (ModelClass.getPkFields().length > 1 && probe.sql) {
740
+ const found = await db.fetch(
741
+ `SELECT 1 AS present FROM ${ModelClass.tableName} WHERE ${probe.sql}`,
742
+ probe.params,
743
+ 1,
744
+ );
745
+ isUpdate = (found as unknown as { length: number }).length > 0;
746
+ } else {
747
+ isUpdate = await ModelClass.exists(pkValue);
748
+ }
680
749
  } catch {
681
750
  // If we can't tell (e.g. table doesn't exist yet), fall back
682
751
  // to INSERT so the user sees the real driver error rather
@@ -689,16 +758,22 @@ export class BaseModel {
689
758
  await adapterStartTransaction(db);
690
759
  try {
691
760
  if (isUpdate) {
692
- // Update
761
+ // Update — keyed on the WHOLE primary key (see pkWhere).
693
762
  const updateFields = Object.entries(ModelClass.fields).filter(
694
763
  ([name, def]) => !def.primaryKey && this[name] !== undefined,
695
764
  );
696
765
  if (updateFields.length === 0) { await adapterCommit(db); return this; }
697
766
 
698
767
  const setClause = updateFields.map(([k]) => `"${ModelClass.getDbColumn(k)}" = ?`).join(", ");
699
- const values = [...updateFields.map(([k, def]) => toDbFieldValue(def, this[k])), pkValue];
700
-
701
- await adapterExecute(db, `UPDATE "${ModelClass.tableName}" SET ${setClause} WHERE "${pkCol}" = ?`, values);
768
+ // The key params come from pkWhere() below; appending pkValue here too
769
+ // would bind the first key column twice and shift every placeholder.
770
+ const values = [...updateFields.map(([k, def]) => toDbFieldValue(def, this[k]))];
771
+
772
+ // Keyed on the WHOLE primary key: one column of a composite key matches
773
+ // every row sharing that value.
774
+ const uw = this.pkWhere();
775
+ values.push(...uw.params);
776
+ await adapterExecute(db, `UPDATE "${ModelClass.tableName}" SET ${setClause} WHERE ${uw.sql}`, values);
702
777
  } else {
703
778
  // Insert
704
779
  //
@@ -854,14 +929,14 @@ export class BaseModel {
854
929
  try {
855
930
  if (ModelClass.softDelete) {
856
931
  await adapterExecute(db,
857
- `UPDATE "${ModelClass.tableName}" SET is_deleted = 1 WHERE "${pkCol}" = ?`,
858
- [pkValue],
932
+ `UPDATE "${ModelClass.tableName}" SET is_deleted = 1 WHERE ${this.pkWhere().sql}`,
933
+ this.pkWhere().params,
859
934
  );
860
935
  this.is_deleted = 1;
861
936
  } else {
862
937
  await adapterExecute(db,
863
- `DELETE FROM "${ModelClass.tableName}" WHERE "${pkCol}" = ?`,
864
- [pkValue],
938
+ `DELETE FROM "${ModelClass.tableName}" WHERE ${this.pkWhere().sql}`,
939
+ this.pkWhere().params,
865
940
  );
866
941
  }
867
942
  await adapterCommit(db);
@@ -1047,7 +1122,10 @@ export class BaseModel {
1047
1122
  const dbCol = this.getDbColumn(fieldName);
1048
1123
  const sqlType = typeMap[def.type] || "TEXT";
1049
1124
  const parts = [`"${dbCol}" ${sqlType}`];
1050
- if (def.primaryKey) parts.push("PRIMARY KEY");
1125
+ // A COMPOSITE key is declared ONCE, at table level (below). An inline
1126
+ // PRIMARY KEY per column is invalid DDL - SQLite, PostgreSQL and MySQL
1127
+ // all reject two of them in one table.
1128
+ if (def.primaryKey && this.getPkFields().length === 1) parts.push("PRIMARY KEY");
1051
1129
  if (def.autoIncrement) parts.push("AUTOINCREMENT");
1052
1130
  if (def.required && !def.primaryKey) parts.push("NOT NULL");
1053
1131
  // A callable default (e.g. `default: () => new Date()`) is resolved per-row
@@ -1061,6 +1139,15 @@ export class BaseModel {
1061
1139
  colDefs.push(parts.join(" "));
1062
1140
  }
1063
1141
 
1142
+ // A COMPOSITE key is declared ONCE, at table level; the per-column inline
1143
+ // form above is suppressed for it, because two inline primary keys is
1144
+ // invalid DDL on every engine.
1145
+ const pkFields = this.getPkFields();
1146
+ if (pkFields.length > 1) {
1147
+ const pkCols = pkFields.map((f) => this.getDbColumn(f));
1148
+ colDefs.push(`PRIMARY KEY (${pkCols.join(", ")})`);
1149
+ }
1150
+
1064
1151
  const sql = `CREATE TABLE IF NOT EXISTS "${this.tableName}" (${colDefs.join(", ")})`;
1065
1152
  await adapterStartTransaction(db);
1066
1153
  try {
@@ -1099,7 +1186,7 @@ export class BaseModel {
1099
1186
  * @param sql SQL query string.
1100
1187
  * @param params Bind parameters.
1101
1188
  * @param ttl Cache TTL in seconds (default 60).
1102
- * @param limit Max records to return (default 20).
1189
+ * @param limit Max records to return (default 100).
1103
1190
  * @param offset Records to skip (default 0).
1104
1191
  * @param include Relationship names to eager-load on cache miss.
1105
1192
  */
@@ -1108,7 +1195,7 @@ export class BaseModel {
1108
1195
  sql: string,
1109
1196
  params?: unknown[],
1110
1197
  ttl = 60,
1111
- limit = 20,
1198
+ limit = DEFAULT_ROW_CAP,
1112
1199
  offset = 0,
1113
1200
  include?: string[],
1114
1201
  ): Promise<T[]> {
@@ -1117,7 +1204,7 @@ export class BaseModel {
1117
1204
  ModelClass._queryCache = new QueryCache({ defaultTtl: ttl, maxSize: 500 });
1118
1205
  }
1119
1206
  const cacheKey = `${ModelClass.tableName}:${sql}:${limit}:${offset}`;
1120
- const key = QueryCache.queryKey(cacheKey, params ?? []);
1207
+ const key = QueryCache.queryKey(cacheKey, params ?? [], ModelClass.getDb().cacheIdentity ?? "");
1121
1208
  const hit = ModelClass._queryCache.get(key) as T[] | undefined;
1122
1209
  if (hit !== undefined) return hit;
1123
1210
 
@@ -1149,10 +1236,18 @@ export class BaseModel {
1149
1236
  this: new (data?: Record<string, unknown>) => T,
1150
1237
  sql: string,
1151
1238
  params?: unknown[],
1239
+ limit: number = DEFAULT_ROW_CAP,
1240
+ offset: number = 0,
1152
1241
  ): Promise<T[]> {
1153
1242
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
1154
1243
  const db = ModelClass.getDb();
1155
- const rows = await adapterQuery(db, sql, params);
1244
+ // Skip appending when the caller's SQL already carries its own LIMIT --
1245
+ // a second one is a syntax error on every engine. SQLTranslator.appendLimit
1246
+ // scrubs literals/comments first (a substring search here used to drop the
1247
+ // row cap on `WHERE label != 'LIMIT'`) and appends on a new line so the
1248
+ // clause is never swallowed by a trailing `--` comment.
1249
+ const paged = SQLTranslator.appendLimit(sql, limit, offset);
1250
+ const rows = await adapterQuery(db, paged, params);
1156
1251
  return rows.map((row) => new ModelClass(row as Record<string, unknown>) as T);
1157
1252
  }
1158
1253
 
@@ -1188,8 +1283,8 @@ export class BaseModel {
1188
1283
  await adapterStartTransaction(db);
1189
1284
  try {
1190
1285
  await adapterExecute(db,
1191
- `DELETE FROM "${ModelClass.tableName}" WHERE "${pkCol}" = ?`,
1192
- [pkValue],
1286
+ `DELETE FROM "${ModelClass.tableName}" WHERE ${this.pkWhere().sql}`,
1287
+ this.pkWhere().params,
1193
1288
  );
1194
1289
  await adapterCommit(db);
1195
1290
  } catch (e) {
@@ -1220,8 +1315,8 @@ export class BaseModel {
1220
1315
  await adapterStartTransaction(db);
1221
1316
  try {
1222
1317
  await adapterExecute(db,
1223
- `UPDATE "${ModelClass.tableName}" SET is_deleted = 0 WHERE "${pkCol}" = ?`,
1224
- [pkValue],
1318
+ `UPDATE "${ModelClass.tableName}" SET is_deleted = 0 WHERE ${this.pkWhere().sql}`,
1319
+ this.pkWhere().params,
1225
1320
  );
1226
1321
  await adapterCommit(db);
1227
1322
  } catch (e) {
@@ -1239,8 +1334,8 @@ export class BaseModel {
1239
1334
  this: new (data?: Record<string, unknown>) => T,
1240
1335
  conditions?: string,
1241
1336
  params?: unknown[],
1242
- limit?: number,
1243
- offset?: number,
1337
+ limit: number = DEFAULT_ROW_CAP,
1338
+ offset: number = 0,
1244
1339
  ): Promise<T[]> {
1245
1340
  const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
1246
1341
  const db = ModelClass.getDb();
@@ -1309,7 +1404,7 @@ export class BaseModel {
1309
1404
  params?: unknown[],
1310
1405
  ): void {
1311
1406
  const ModelClass = this as unknown as typeof BaseModel;
1312
- (ModelClass as any)[name] = (limit: number = 20, offset: number = 0) => {
1407
+ (ModelClass as any)[name] = (limit: number = DEFAULT_ROW_CAP, offset: number = 0) => {
1313
1408
  return ModelClass.where.call(ModelClass as any, filterSql, params, limit, offset);
1314
1409
  };
1315
1410
  }