turbine-orm 0.81.0 → 0.90.0

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 (295) hide show
  1. package/README.md +26 -25
  2. package/dist/checkout.d.ts +27 -2
  3. package/dist/checkout.js +75 -4
  4. package/dist/cjs/checkout.d.ts +27 -2
  5. package/dist/cjs/checkout.js +76 -3
  6. package/dist/cjs/cli/code-first-tags.d.ts +62 -0
  7. package/dist/cjs/cli/code-first-tags.js +124 -0
  8. package/dist/cjs/cli/compile-query.js +9 -0
  9. package/dist/cjs/cli/config.d.ts +35 -6
  10. package/dist/cjs/cli/config.js +17 -1
  11. package/dist/cjs/cli/destructive.d.ts +7 -4
  12. package/dist/cjs/cli/destructive.js +224 -20
  13. package/dist/cjs/cli/error-catalog.js +36 -0
  14. package/dist/cjs/cli/error-text.d.ts +15 -0
  15. package/dist/cjs/cli/error-text.js +40 -0
  16. package/dist/cjs/cli/index.d.ts +62 -4
  17. package/dist/cjs/cli/index.js +568 -108
  18. package/dist/cjs/cli/local-server-guard.d.ts +53 -0
  19. package/dist/cjs/cli/local-server-guard.js +119 -0
  20. package/dist/cjs/cli/local-server-output.d.ts +41 -0
  21. package/dist/cjs/cli/local-server-output.js +103 -0
  22. package/dist/cjs/cli/mcp.d.ts +8 -0
  23. package/dist/cjs/cli/mcp.js +121 -32
  24. package/dist/cjs/cli/migrate.d.ts +113 -5
  25. package/dist/cjs/cli/migrate.js +270 -21
  26. package/dist/cjs/cli/observe.d.ts +6 -1
  27. package/dist/cjs/cli/observe.js +85 -55
  28. package/dist/cjs/cli/pii-predicate-guard.d.ts +43 -23
  29. package/dist/cjs/cli/pii-predicate-guard.js +98 -37
  30. package/dist/cjs/cli/prisma-resolve.js +17 -13
  31. package/dist/cjs/cli/prisma-schema.js +12 -4
  32. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  33. package/dist/cjs/cli/studio.d.ts +14 -2
  34. package/dist/cjs/cli/studio.js +244 -69
  35. package/dist/cjs/cli/ui.d.ts +1 -1
  36. package/dist/cjs/client.d.ts +558 -85
  37. package/dist/cjs/client.js +1270 -395
  38. package/dist/cjs/connection-guard.d.ts +13 -0
  39. package/dist/cjs/connection-guard.js +28 -0
  40. package/dist/cjs/connection-url.d.ts +51 -16
  41. package/dist/cjs/connection-url.js +67 -34
  42. package/dist/cjs/diagnostics.d.ts +81 -0
  43. package/dist/cjs/diagnostics.js +147 -0
  44. package/dist/cjs/dialect.d.ts +449 -20
  45. package/dist/cjs/dialect.js +117 -76
  46. package/dist/cjs/engine-config.d.ts +7 -6
  47. package/dist/cjs/errors.d.ts +126 -6
  48. package/dist/cjs/errors.js +256 -13
  49. package/dist/cjs/generate.d.ts +57 -7
  50. package/dist/cjs/generate.js +635 -279
  51. package/dist/cjs/global-filters-config.d.ts +59 -0
  52. package/dist/cjs/global-filters-config.js +367 -0
  53. package/dist/cjs/index.d.ts +9 -4
  54. package/dist/cjs/index.js +10 -2
  55. package/dist/cjs/introspect.d.ts +31 -6
  56. package/dist/cjs/introspect.js +108 -50
  57. package/dist/cjs/log-codes.d.ts +12 -0
  58. package/dist/cjs/log-codes.js +77 -0
  59. package/dist/cjs/logger.d.ts +87 -0
  60. package/dist/cjs/logger.js +98 -0
  61. package/dist/cjs/mssql.d.ts +8 -5
  62. package/dist/cjs/mssql.js +352 -65
  63. package/dist/cjs/mysql.d.ts +3 -5
  64. package/dist/cjs/mysql.js +420 -70
  65. package/dist/cjs/nested-write.d.ts +3 -0
  66. package/dist/cjs/nested-write.js +179 -114
  67. package/dist/cjs/pipeline-submittable.d.ts +12 -0
  68. package/dist/cjs/pipeline-submittable.js +118 -44
  69. package/dist/cjs/pipeline.js +21 -26
  70. package/dist/cjs/powdb-introspect.d.ts +5 -2
  71. package/dist/cjs/powdb-introspect.js +20 -15
  72. package/dist/cjs/powdb-shared.d.ts +36 -5
  73. package/dist/cjs/powdb-shared.js +67 -8
  74. package/dist/cjs/powdb.d.ts +4 -0
  75. package/dist/cjs/powdb.js +183 -51
  76. package/dist/cjs/powql.d.ts +394 -51
  77. package/dist/cjs/powql.js +1829 -284
  78. package/dist/cjs/prisma-compat.d.ts +23 -15
  79. package/dist/cjs/prisma-compat.js +507 -372
  80. package/dist/cjs/query/aggregates.d.ts +17 -1
  81. package/dist/cjs/query/aggregates.js +261 -29
  82. package/dist/cjs/query/batched-loader.d.ts +25 -6
  83. package/dist/cjs/query/batched-loader.js +103 -20
  84. package/dist/cjs/query/builder.d.ts +325 -184
  85. package/dist/cjs/query/builder.js +1452 -490
  86. package/dist/cjs/query/column-ref.d.ts +61 -0
  87. package/dist/cjs/query/column-ref.js +73 -0
  88. package/dist/cjs/query/compound-unique.d.ts +21 -0
  89. package/dist/cjs/query/compound-unique.js +98 -18
  90. package/dist/cjs/query/deferred.d.ts +53 -16
  91. package/dist/cjs/query/expr.d.ts +217 -0
  92. package/dist/cjs/query/expr.js +859 -0
  93. package/dist/cjs/query/filters.d.ts +155 -53
  94. package/dist/cjs/query/filters.js +613 -76
  95. package/dist/cjs/query/index.d.ts +2 -0
  96. package/dist/cjs/query/index.js +7 -1
  97. package/dist/cjs/query/json-value.d.ts +43 -0
  98. package/dist/cjs/query/json-value.js +59 -0
  99. package/dist/cjs/query/normalize-args.d.ts +87 -0
  100. package/dist/cjs/query/normalize-args.js +527 -0
  101. package/dist/cjs/query/option-surface.d.ts +16 -4
  102. package/dist/cjs/query/option-surface.js +57 -14
  103. package/dist/cjs/query/relation-names.d.ts +14 -0
  104. package/dist/cjs/query/relation-names.js +77 -18
  105. package/dist/cjs/query/relations.d.ts +30 -3
  106. package/dist/cjs/query/relations.js +219 -63
  107. package/dist/cjs/query/row-lock.d.ts +49 -0
  108. package/dist/cjs/query/row-lock.js +138 -0
  109. package/dist/cjs/query/sql-comment.d.ts +161 -0
  110. package/dist/cjs/query/sql-comment.js +381 -0
  111. package/dist/cjs/query/stream-idle.d.ts +132 -0
  112. package/dist/cjs/query/stream-idle.js +212 -0
  113. package/dist/cjs/query/table-client.d.ts +101 -0
  114. package/dist/cjs/query/table-client.js +74 -0
  115. package/dist/cjs/query/types.d.ts +677 -298
  116. package/dist/cjs/query/utils.d.ts +105 -6
  117. package/dist/cjs/query/utils.js +426 -19
  118. package/dist/cjs/query/warn-registry.d.ts +18 -0
  119. package/dist/cjs/query/warn-registry.js +18 -0
  120. package/dist/cjs/query/where-compile.d.ts +138 -14
  121. package/dist/cjs/query/where-compile.js +513 -29
  122. package/dist/cjs/query/where.d.ts +159 -59
  123. package/dist/cjs/query/where.js +716 -246
  124. package/dist/cjs/query/writes.d.ts +112 -50
  125. package/dist/cjs/query/writes.js +1498 -200
  126. package/dist/cjs/query-events.d.ts +4 -2
  127. package/dist/cjs/query-events.js +3 -1
  128. package/dist/cjs/realtime.d.ts +7 -3
  129. package/dist/cjs/realtime.js +13 -2
  130. package/dist/cjs/schema-builder.d.ts +55 -4
  131. package/dist/cjs/schema-builder.js +382 -18
  132. package/dist/cjs/schema-metadata.d.ts +21 -1
  133. package/dist/cjs/schema-metadata.js +29 -7
  134. package/dist/cjs/schema-sql.d.ts +75 -5
  135. package/dist/cjs/schema-sql.js +497 -45
  136. package/dist/cjs/schema.d.ts +26 -5
  137. package/dist/cjs/schema.js +110 -13
  138. package/dist/cjs/sql-placeholders.d.ts +56 -0
  139. package/dist/cjs/sql-placeholders.js +204 -0
  140. package/dist/cjs/sqlite.d.ts +75 -20
  141. package/dist/cjs/sqlite.js +593 -95
  142. package/dist/cjs/supplied-pool-types.d.ts +48 -0
  143. package/dist/cjs/supplied-pool-types.js +256 -0
  144. package/dist/cjs/transaction-control.d.ts +51 -0
  145. package/dist/cjs/transaction-control.js +316 -0
  146. package/dist/cjs/transaction-state.d.ts +342 -0
  147. package/dist/cjs/transaction-state.js +707 -0
  148. package/dist/cjs/typed-sql.d.ts +37 -3
  149. package/dist/cjs/typed-sql.js +102 -8
  150. package/dist/cli/code-first-tags.d.ts +62 -0
  151. package/dist/cli/code-first-tags.js +120 -0
  152. package/dist/cli/compile-query.js +9 -0
  153. package/dist/cli/config.d.ts +35 -6
  154. package/dist/cli/config.js +16 -1
  155. package/dist/cli/destructive.d.ts +7 -4
  156. package/dist/cli/destructive.js +224 -20
  157. package/dist/cli/error-catalog.js +36 -0
  158. package/dist/cli/error-text.d.ts +15 -0
  159. package/dist/cli/error-text.js +37 -0
  160. package/dist/cli/index.d.ts +62 -4
  161. package/dist/cli/index.js +569 -112
  162. package/dist/cli/local-server-guard.d.ts +53 -0
  163. package/dist/cli/local-server-guard.js +113 -0
  164. package/dist/cli/local-server-output.d.ts +41 -0
  165. package/dist/cli/local-server-output.js +98 -0
  166. package/dist/cli/mcp.d.ts +8 -0
  167. package/dist/cli/mcp.js +121 -34
  168. package/dist/cli/migrate.d.ts +113 -5
  169. package/dist/cli/migrate.js +261 -21
  170. package/dist/cli/observe.d.ts +6 -1
  171. package/dist/cli/observe.js +84 -54
  172. package/dist/cli/pii-predicate-guard.d.ts +43 -23
  173. package/dist/cli/pii-predicate-guard.js +100 -35
  174. package/dist/cli/prisma-resolve.js +15 -11
  175. package/dist/cli/prisma-schema.js +12 -4
  176. package/dist/cli/studio-ui.generated.js +1 -1
  177. package/dist/cli/studio.d.ts +14 -2
  178. package/dist/cli/studio.js +246 -71
  179. package/dist/cli/ui.d.ts +1 -1
  180. package/dist/client.d.ts +558 -85
  181. package/dist/client.js +1275 -400
  182. package/dist/connection-guard.d.ts +13 -0
  183. package/dist/connection-guard.js +27 -0
  184. package/dist/connection-url.d.ts +51 -16
  185. package/dist/connection-url.js +63 -34
  186. package/dist/diagnostics.d.ts +81 -0
  187. package/dist/diagnostics.js +144 -0
  188. package/dist/dialect.d.ts +449 -20
  189. package/dist/dialect.js +115 -43
  190. package/dist/engine-config.d.ts +7 -6
  191. package/dist/errors.d.ts +126 -6
  192. package/dist/errors.js +252 -12
  193. package/dist/generate.d.ts +57 -7
  194. package/dist/generate.js +635 -281
  195. package/dist/global-filters-config.d.ts +59 -0
  196. package/dist/global-filters-config.js +361 -0
  197. package/dist/index.d.ts +9 -4
  198. package/dist/index.js +4 -2
  199. package/dist/introspect.d.ts +31 -6
  200. package/dist/introspect.js +106 -50
  201. package/dist/log-codes.d.ts +12 -0
  202. package/dist/log-codes.js +74 -0
  203. package/dist/logger.d.ts +87 -0
  204. package/dist/logger.js +93 -0
  205. package/dist/mssql.d.ts +8 -5
  206. package/dist/mssql.js +354 -67
  207. package/dist/mysql.d.ts +3 -5
  208. package/dist/mysql.js +423 -73
  209. package/dist/nested-write.d.ts +3 -0
  210. package/dist/nested-write.js +177 -112
  211. package/dist/pipeline-submittable.d.ts +12 -0
  212. package/dist/pipeline-submittable.js +117 -44
  213. package/dist/pipeline.js +21 -26
  214. package/dist/powdb-introspect.d.ts +5 -2
  215. package/dist/powdb-introspect.js +21 -16
  216. package/dist/powdb-shared.d.ts +36 -5
  217. package/dist/powdb-shared.js +65 -8
  218. package/dist/powdb.d.ts +4 -0
  219. package/dist/powdb.js +183 -51
  220. package/dist/powql.d.ts +394 -51
  221. package/dist/powql.js +1836 -291
  222. package/dist/prisma-compat.d.ts +23 -15
  223. package/dist/prisma-compat.js +508 -373
  224. package/dist/query/aggregates.d.ts +17 -1
  225. package/dist/query/aggregates.js +263 -32
  226. package/dist/query/batched-loader.d.ts +25 -6
  227. package/dist/query/batched-loader.js +101 -21
  228. package/dist/query/builder.d.ts +325 -184
  229. package/dist/query/builder.js +1458 -495
  230. package/dist/query/column-ref.d.ts +61 -0
  231. package/dist/query/column-ref.js +66 -0
  232. package/dist/query/compound-unique.d.ts +21 -0
  233. package/dist/query/compound-unique.js +97 -19
  234. package/dist/query/deferred.d.ts +53 -16
  235. package/dist/query/expr.d.ts +217 -0
  236. package/dist/query/expr.js +848 -0
  237. package/dist/query/filters.d.ts +155 -53
  238. package/dist/query/filters.js +597 -72
  239. package/dist/query/index.d.ts +2 -0
  240. package/dist/query/index.js +4 -0
  241. package/dist/query/json-value.d.ts +43 -0
  242. package/dist/query/json-value.js +54 -0
  243. package/dist/query/normalize-args.d.ts +87 -0
  244. package/dist/query/normalize-args.js +517 -0
  245. package/dist/query/option-surface.d.ts +16 -4
  246. package/dist/query/option-surface.js +56 -13
  247. package/dist/query/relation-names.d.ts +14 -0
  248. package/dist/query/relation-names.js +77 -19
  249. package/dist/query/relations.d.ts +30 -3
  250. package/dist/query/relations.js +220 -67
  251. package/dist/query/row-lock.d.ts +49 -0
  252. package/dist/query/row-lock.js +131 -0
  253. package/dist/query/sql-comment.d.ts +161 -0
  254. package/dist/query/sql-comment.js +374 -0
  255. package/dist/query/stream-idle.d.ts +132 -0
  256. package/dist/query/stream-idle.js +203 -0
  257. package/dist/query/table-client.d.ts +101 -0
  258. package/dist/query/table-client.js +70 -0
  259. package/dist/query/types.d.ts +677 -298
  260. package/dist/query/utils.d.ts +105 -6
  261. package/dist/query/utils.js +414 -18
  262. package/dist/query/warn-registry.d.ts +18 -0
  263. package/dist/query/warn-registry.js +18 -0
  264. package/dist/query/where-compile.d.ts +138 -14
  265. package/dist/query/where-compile.js +508 -31
  266. package/dist/query/where.d.ts +159 -59
  267. package/dist/query/where.js +711 -248
  268. package/dist/query/writes.d.ts +112 -50
  269. package/dist/query/writes.js +1496 -202
  270. package/dist/query-events.d.ts +4 -2
  271. package/dist/query-events.js +3 -1
  272. package/dist/realtime.d.ts +7 -3
  273. package/dist/realtime.js +13 -2
  274. package/dist/schema-builder.d.ts +55 -4
  275. package/dist/schema-builder.js +381 -18
  276. package/dist/schema-metadata.d.ts +21 -1
  277. package/dist/schema-metadata.js +28 -7
  278. package/dist/schema-sql.d.ts +75 -5
  279. package/dist/schema-sql.js +494 -44
  280. package/dist/schema.d.ts +26 -5
  281. package/dist/schema.js +109 -13
  282. package/dist/sql-placeholders.d.ts +56 -0
  283. package/dist/sql-placeholders.js +200 -0
  284. package/dist/sqlite.d.ts +75 -20
  285. package/dist/sqlite.js +597 -99
  286. package/dist/supplied-pool-types.d.ts +48 -0
  287. package/dist/supplied-pool-types.js +247 -0
  288. package/dist/transaction-control.d.ts +51 -0
  289. package/dist/transaction-control.js +311 -0
  290. package/dist/transaction-state.d.ts +342 -0
  291. package/dist/transaction-state.js +696 -0
  292. package/dist/typed-sql.d.ts +37 -3
  293. package/dist/typed-sql.js +100 -8
  294. package/package.json +7 -4
  295. package/skills/turbine-orm/SKILL.md +56 -6
package/dist/cjs/powql.js CHANGED
@@ -69,23 +69,35 @@ var __importStar = (this && this.__importStar) || (function () {
69
69
  };
70
70
  })();
71
71
  Object.defineProperty(exports, "__esModule", { value: true });
72
- exports.PowqlInterface = exports.MAX_POWQL_DATETIME_TERMS = void 0;
72
+ exports.PowqlInterface = exports.MAX_RELATION_FILTER_KEYS = exports.MAX_POWQL_DATETIME_TERMS = void 0;
73
73
  const node_crypto_1 = require("node:crypto");
74
74
  const connection_guard_js_1 = require("./connection-guard.js");
75
+ const diagnostics_js_1 = require("./diagnostics.js");
75
76
  const errors_js_1 = require("./errors.js");
77
+ const logger_js_1 = require("./logger.js");
76
78
  const nested_write_js_1 = require("./nested-write.js");
77
79
  const powdb_shared_js_1 = require("./powdb-shared.js");
78
80
  const aggregates_js_1 = require("./query/aggregates.js");
81
+ const batched_loader_js_1 = require("./query/batched-loader.js");
82
+ const builder_js_1 = require("./query/builder.js");
83
+ const column_ref_js_1 = require("./query/column-ref.js");
79
84
  const compound_unique_js_1 = require("./query/compound-unique.js");
85
+ const expr_js_1 = require("./query/expr.js");
80
86
  const filters_js_1 = require("./query/filters.js");
87
+ const json_value_js_1 = require("./query/json-value.js");
88
+ const normalize_args_js_1 = require("./query/normalize-args.js");
81
89
  const option_surface_js_1 = require("./query/option-surface.js");
82
90
  const relation_names_js_1 = require("./query/relation-names.js");
91
+ const relations_js_1 = require("./query/relations.js");
83
92
  // The privilege sentinel and its resolver: `includePii` / `allowFullTableScan`
84
93
  // are unlocked ONLY by the UNSAFE symbol, on this engine exactly as on the SQL
85
94
  // engines, so a spread request body cannot turn either on here either.
86
95
  const types_js_1 = require("./query/types.js");
87
96
  const utils_js_1 = require("./query/utils.js");
97
+ const warn_registry_js_1 = require("./query/warn-registry.js");
88
98
  const where_js_1 = require("./query/where.js");
99
+ const where_compile_js_1 = require("./query/where-compile.js");
100
+ const writes_js_1 = require("./query/writes.js");
89
101
  const schema_js_1 = require("./schema.js");
90
102
  /**
91
103
  * Max parent keys per relation-loader `in (…)` query. A `with` over a large
@@ -94,6 +106,8 @@ const schema_js_1 = require("./schema.js");
94
106
  * before grouping. Mirrors the chunking the parity matrix documents.
95
107
  */
96
108
  const MAX_RELATION_KEYS = 1000;
109
+ /** A predicate that holds for every row (an empty `notIn`, an `OR` with an empty branch). */
110
+ const ALWAYS_TRUE = '(1 = 1)';
97
111
  /**
98
112
  * Max values one `in` / `notIn` on a PowDB-native `datetime` column may carry.
99
113
  *
@@ -178,6 +192,71 @@ const POWQL_HAVING_AGG_FNS = new Map([
178
192
  ['_max', 'max'],
179
193
  ['_count', 'count'],
180
194
  ]);
195
+ /**
196
+ * Max distinct keys one client-side relation filter may resolve to.
197
+ *
198
+ * A relation filter becomes a literal `in (…)` list on the local key, and the
199
+ * engine evaluates that list per row, so the cost grows with rows times keys
200
+ * (measured on the 0.20 addon over 100k rows: about 0.3 s per 1,000 keys).
201
+ * Past this bound the filter is refused with a typed error naming the bound,
202
+ * instead of building a statement whose cost has no ceiling.
203
+ */
204
+ exports.MAX_RELATION_FILTER_KEYS = 10_000;
205
+ /** The relation-filter keys the SQL engines accept, in their compile order. */
206
+ const RELATION_FILTER_KEYS = ['some', 'none', 'every', 'is', 'isNot'];
207
+ /**
208
+ * True for a branded column reference (`col('x')`). PowQL has no column-to-column
209
+ * operand form in a filter, so a reference is refused rather than bound as data.
210
+ * The brand is read by its registry key so this needs no import of the module
211
+ * that creates it.
212
+ */
213
+ function isColumnReference(value) {
214
+ return typeof value === 'object' && value !== null && Symbol.for('turbine-orm.columnRef') in value;
215
+ }
216
+ /** A value a non-JSON column can be compared against. */
217
+ function isScalarOperand(value) {
218
+ const t = typeof value;
219
+ return (t === 'string' ||
220
+ t === 'number' ||
221
+ t === 'bigint' ||
222
+ t === 'boolean' ||
223
+ value instanceof Date ||
224
+ value instanceof Uint8Array);
225
+ }
226
+ /** The largest delay `setTimeout` honours; Node fires anything larger after 1 ms. */
227
+ const MAX_TIMEOUT_MS = 2_147_483_647;
228
+ /** The PowQL operator each arithmetic update operator compiles to. */
229
+ const POWQL_ARITHMETIC = {
230
+ increment: '+',
231
+ decrement: '-',
232
+ multiply: '*',
233
+ divide: '/',
234
+ };
235
+ /** The accepted `relationLoadStrategy` values, the SQL engines' set. */
236
+ const RELATION_LOAD_STRATEGIES = Object.freeze(['join', 'batched', 'auto', 'flatten']);
237
+ /** A short, value-safe rendering of a rejected option value. */
238
+ function describeOptionValue(value) {
239
+ if (typeof value === 'number' || typeof value === 'boolean')
240
+ return String(value);
241
+ if (typeof value === 'string')
242
+ return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}...` : value);
243
+ return value === null ? 'null' : `a value of type ${typeof value}`;
244
+ }
245
+ /** `{ equals: v }` and nothing else. */
246
+ /** `AND` / `OR` / `NOT`, the keys {@link combinatorBranches} reads. */
247
+ function isCombinatorKey(key) {
248
+ return key === 'AND' || key === 'OR' || key === 'NOT';
249
+ }
250
+ /** Short, data-free description of an operand for an error message. */
251
+ function describeOperand(value) {
252
+ if (value === null)
253
+ return 'null';
254
+ if (Array.isArray(value))
255
+ return 'an array';
256
+ if (typeof value === 'object')
257
+ return 'an object';
258
+ return typeof value;
259
+ }
181
260
  /** Filters that have no PowDB representation and must throw E017. */
182
261
  function rejectUnsupportedFilter(value, field) {
183
262
  if ('distance' in value || 'metric' in value) {
@@ -204,8 +283,12 @@ function rejectUnsupportedFilter(value, field) {
204
283
  }
205
284
  /**
206
285
  * The PowQL query interface. Constructed by `turbinePowDB` via the
207
- * `queryInterfaceFactory` seam and cast to `QueryInterface<object>` so
208
- * `TurbineClient.table()` can return it transparently.
286
+ * `queryInterfaceFactory` seam. `implements TableClient` makes a method the SQL
287
+ * accessor has and this class lacks, or one whose signature differs, a build
288
+ * error. Where a method's result is narrowed by `select` / `omit` / `with`, a
289
+ * public overload signature carries the SQL accessor's typing and the
290
+ * implementation below it is typed on the plain row; both return the same
291
+ * projected, relation-loaded rows at run time.
209
292
  */
210
293
  class PowqlInterface {
211
294
  pool;
@@ -217,7 +300,10 @@ class PowqlInterface {
217
300
  defaultLimit;
218
301
  warnOnUnlimited;
219
302
  onQuery;
220
- warnedUnlimited = false;
303
+ /** Where this interface's log lines go: the client's logger, else the console. */
304
+ logger;
305
+ /** The logger for advice lines, or `undefined` when `diagnostics` turns advice off. */
306
+ advice;
221
307
  constructor(pool, table, schema, middlewares = [], options = {}) {
222
308
  this.pool = pool;
223
309
  this.table = table;
@@ -239,6 +325,10 @@ class PowqlInterface {
239
325
  ? (warnOpt[table] ?? warnOpt[(0, schema_js_1.snakeToCamel)(table)]) !== false
240
326
  : warnOpt !== false;
241
327
  this.onQuery = options._onQuery;
328
+ // Resolved by the client once and passed in; an interface built directly
329
+ // resolves here, at construction, as QueryInterface does.
330
+ this.logger = options.logger ?? logger_js_1.consoleLogger;
331
+ this.advice = (0, diagnostics_js_1.resolveDiagnostics)(options.diagnostics).advice ? this.logger : undefined;
242
332
  }
243
333
  // -------------------------------------------------------------------------
244
334
  // Column / value helpers
@@ -338,11 +428,44 @@ class PowqlInterface {
338
428
  */
339
429
  param(value, params, col) {
340
430
  let tagged = value;
341
- if (col && typeof value === 'number' && this.isFloatCol(col)) {
431
+ const labels = col ? this.enumLabels(col) : undefined;
432
+ if (labels && value !== null && value !== undefined) {
433
+ // An enum column takes one of its labels, in a write and in a filter
434
+ // alike, as PostgreSQL does (22P02 there).
435
+ this.assertEnumLabel(col, labels, value);
436
+ }
437
+ else if ((0, json_value_js_1.isJsonValue)(value)) {
438
+ // `jsonValue(v)`: serialized with JSON.stringify, as on the SQL engines.
439
+ tagged = new powdb_shared_js_1.PowdbJsonParam(value.value, col?.name);
440
+ }
441
+ else if (col && typeof value === 'string' && (0, powdb_shared_js_1.isDateColumn)(col)) {
442
+ // Date text against a Date column: the epoch micros the column stores,
443
+ // as PostgreSQL reads the same text against a date / timestamp.
444
+ tagged = isoTextMicros(value, col, this.table);
445
+ }
446
+ else if (col &&
447
+ (0, powdb_shared_js_1.isDateColumn)(col) &&
448
+ (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean')) {
449
+ // The column holds epoch MICROseconds, so a number bound as-is names a
450
+ // different instant (a millisecond count lands in 1970) and matches
451
+ // nothing. PostgreSQL refuses a number against a date / timestamp too.
452
+ throw new errors_js_1.ValidationError(`Invalid value for the Date column "${col.name}" on "${this.table}": expected a Date or ISO 8601 text, ` +
453
+ `got ${describeOperand(value)}. Pass \`new Date(ms)\` for a millisecond timestamp.`);
454
+ }
455
+ else if (col && typeof value === 'string' && this.isIntegerCol(col)) {
456
+ // Integer text against an integer column, as PostgreSQL reads it; an int8
457
+ // value past 2^53 keeps every digit as a bigint.
458
+ tagged = integerTextValue(value, col, this.table);
459
+ }
460
+ else if (col && typeof value === 'number' && this.isFloatCol(col)) {
342
461
  // Float column: force a float-form literal even for an integer value.
343
462
  tagged = new powdb_shared_js_1.PowdbFloatParam(value);
344
463
  }
345
- else if (col && value !== null && typeof value === 'object' && !(value instanceof Date) && (0, powdb_shared_js_1.isJsonColumn)(col)) {
464
+ else if (col &&
465
+ value !== null &&
466
+ typeof value === 'object' &&
467
+ !(value instanceof Date) &&
468
+ (0, powdb_shared_js_1.isJsonColumn)(col, this.schema.enums)) {
346
469
  // json document column: a JS object/array is serialized to canonical JSON
347
470
  // text and stored as a json document (a JS string passes through raw, same
348
471
  // contract as pg jsonb; `null` stays `null`).
@@ -362,14 +485,36 @@ class PowqlInterface {
362
485
  * and coercion stays unambiguous. Non-finite floats are still rejected.
363
486
  */
364
487
  writeRef(value, col, params) {
488
+ // A value position (create data, `{ set }`): the SQL engines' rule and message.
489
+ if ((0, expr_js_1.isSqlExpr)(value) || (0, column_ref_js_1.isColumnRef)(value)) {
490
+ throw new errors_js_1.ValidationError((0, expr_js_1.exprNotAllowedMessage)(this.table, col.field, value));
491
+ }
365
492
  if (typeof value === 'number' && !Number.isFinite(value) && this.isFloatCol(col)) {
366
493
  throw new errors_js_1.ValidationError(`Non-finite value for float column "${col.name}" on "${this.table}".`);
367
494
  }
368
495
  return this.param(value, params, col);
369
496
  }
497
+ /**
498
+ * A PowQL `int` column declared as an integer type (not a Date, which is
499
+ * stored as int micros, and not a column whose declared type is something
500
+ * else).
501
+ */
502
+ isIntegerCol(col) {
503
+ if ((0, powdb_shared_js_1.isDateColumn)(col))
504
+ return false;
505
+ const declared = (col.dialectType ?? col.pgType ?? '').toLowerCase();
506
+ if (declared !== '' && !/int|serial/.test(declared))
507
+ return false;
508
+ try {
509
+ return (0, powdb_shared_js_1.powqlColumnType)(col, this.schema.enums) === 'int';
510
+ }
511
+ catch {
512
+ return false;
513
+ }
514
+ }
370
515
  isFloatCol(col) {
371
516
  try {
372
- return (0, powdb_shared_js_1.powqlColumnType)(col) === 'float';
517
+ return (0, powdb_shared_js_1.powqlColumnType)(col, this.schema.enums) === 'float';
373
518
  }
374
519
  catch {
375
520
  return false;
@@ -467,15 +612,6 @@ class PowqlInterface {
467
612
  const normalized = (0, relation_names_js_1.normalizeWithClause)(this.schema, this.table, args.with);
468
613
  return normalized === args.with ? args : { ...args, with: normalized };
469
614
  }
470
- /**
471
- * Caller args in canonical form: declared relation spellings, and `take` /
472
- * `skip` folded into `limit` / `offset`.
473
- *
474
- * Same composition as `QueryInterface.normalizeArgs` and here for the same
475
- * reason the method above is here: nothing about a parallel implementation
476
- * makes a core rule arrive on its own, and an engine that reads `take` but
477
- * not `skip` pages differently from one that reads both.
478
- */
479
615
  /**
480
616
  * Fill in the deterministic per-relation ordering `stableRelationOrder` asks
481
617
  * for, through the shared transform the SQL engines use.
@@ -485,6 +621,13 @@ class PowqlInterface {
485
621
  * option whose entire purpose is that they do not. Same resolution as
486
622
  * QueryInterface: a per-query flag wins, else the client-level default.
487
623
  */
624
+ /** `take` / `skip` inside every `with` level folded into `limit` / `offset` (shared rule). */
625
+ withRelationPagination(args) {
626
+ if (!args?.with)
627
+ return args;
628
+ const normalized = (0, relation_names_js_1.normalizeWithPagination)(this.schema, this.table, args.with);
629
+ return normalized === args.with ? args : { ...args, with: normalized };
630
+ }
488
631
  withStableRelationOrder(args) {
489
632
  if (!args?.with)
490
633
  return args;
@@ -494,8 +637,158 @@ class PowqlInterface {
494
637
  const ordered = (0, relation_names_js_1.applyStableRelationOrderTo)(this.schema, args.with, this.table);
495
638
  return ordered === args.with ? args : { ...args, with: ordered };
496
639
  }
497
- normalizeArgs(args) {
498
- return (0, utils_js_1.normalizePagination)(this.withStableRelationOrder(this.withDeclaredRelationNames(args)));
640
+ /**
641
+ * Caller args in canonical form: `undefined` pruned (query/normalize-args.ts),
642
+ * declared relation spellings, stable relation order, and `take` / `skip`
643
+ * folded into `limit` / `offset` at the top level and inside `with`. Same composition as
644
+ * `QueryInterface.normalizeArgs`, run at the top of every public operation,
645
+ * so the two engines agree about what each argument means.
646
+ *
647
+ * `refuseEmptiedWhere` names the single-row read (`findFirst`,
648
+ * `findFirstOrThrow`) that refuses a `where` whose every key was `undefined`.
649
+ */
650
+ normalizeArgs(args, refuseEmptiedWhere) {
651
+ const pruned = (0, normalize_args_js_1.pruneUndefinedArgs)(this.schema, this.table, args);
652
+ if (pruned !== args) {
653
+ if (refuseEmptiedWhere)
654
+ (0, normalize_args_js_1.assertWhereNotEmptiedByPruning)(this.table, refuseEmptiedWhere, args, pruned);
655
+ (0, normalize_args_js_1.assertCursorNotEmptied)(this.table, args, pruned);
656
+ }
657
+ this.assertQueryOptions(pruned);
658
+ const shaped = pruned;
659
+ return (0, utils_js_1.normalizePagination)(this.withStableRelationOrder(this.withRelationPagination(this.withDeclaredRelationNames(shaped))));
660
+ }
661
+ /**
662
+ * The per-query `timeout` and `relationLoadStrategy`, checked with the SQL
663
+ * engines' rule and message: a timeout is a positive number of ms up to
664
+ * {@link MAX_TIMEOUT_MS} (or 0 for none), a strategy one of the SQL engines' values.
665
+ */
666
+ assertQueryOptions(args) {
667
+ const { timeout, relationLoadStrategy } = args;
668
+ if (timeout !== undefined &&
669
+ !(typeof timeout === 'number' && Number.isFinite(timeout) && timeout >= 0 && timeout <= MAX_TIMEOUT_MS)) {
670
+ throw new errors_js_1.ValidationError(`\`timeout\` on ${this.table} must be a positive number of milliseconds up to ${MAX_TIMEOUT_MS}, or 0 for ` +
671
+ `no timeout (received ${describeOptionValue(timeout)}). Leave it out for no timeout.`);
672
+ }
673
+ if (relationLoadStrategy !== undefined && !RELATION_LOAD_STRATEGIES.includes(relationLoadStrategy)) {
674
+ throw new errors_js_1.ValidationError(`\`relationLoadStrategy\` must be one of ${RELATION_LOAD_STRATEGIES.map((v) => `'${v}'`).join(', ')} ` +
675
+ `(received ${describeOptionValue(relationLoadStrategy)}).`);
676
+ }
677
+ }
678
+ /**
679
+ * Whether `cursor` carries a value, after the SQL engines' cursor checks: a
680
+ * cursor that names no row (`null`, `{}`) is E003, and each value is a row's
681
+ * value under the shared rule (`cursorFieldValue`). Unknown fields are E003.
682
+ */
683
+ cursorCarriesValues(cursor) {
684
+ (0, normalize_args_js_1.assertCursorWritten)(this.table, 'findMany', cursor);
685
+ if (!(0, normalize_args_js_1.hasCursor)(cursor))
686
+ return false;
687
+ const flat = (0, compound_unique_js_1.expandCompoundUniqueWhere)(this.meta, cursor);
688
+ for (const [key, written] of Object.entries(flat)) {
689
+ if (written === undefined)
690
+ continue;
691
+ const col = this.column(key);
692
+ (0, filters_js_1.cursorFieldValue)(key, written, {
693
+ column: col.name,
694
+ table: this.table,
695
+ kind: (0, filters_js_1.operandColumnKind)(this.meta.pgTypes[col.name]),
696
+ nullable: col.nullable && !this.meta.primaryKey.includes(col.name),
697
+ });
698
+ }
699
+ return true;
700
+ }
701
+ /**
702
+ * The SQL engines' cursor placement rule (query/builder.ts `planCursor`),
703
+ * so a cursor they refuse is the same E003 here and only a cursor they would
704
+ * serve reaches PowDB's E017. The page order is `orderBy` plus the
705
+ * tiebreaker; the cursor must carry a value for every column of it, or name
706
+ * one row by a unique key (the shared findUnique rule).
707
+ */
708
+ assertCursorPlaceable(cursor, orderBy) {
709
+ const meta = this.meta;
710
+ const field = (column) => meta.reverseColumnMap[column] ?? column;
711
+ const flat = (0, compound_unique_js_1.expandCompoundUniqueWhere)(meta, cursor);
712
+ const valued = new Set();
713
+ for (const [key, value] of Object.entries(flat)) {
714
+ if (value !== undefined)
715
+ valued.add(this.column(key).name);
716
+ }
717
+ const terms = [];
718
+ for (const [key, dir] of (0, filters_js_1.orderByEntries)(orderBy).filter(([, d]) => d !== undefined)) {
719
+ if ((0, filters_js_1.isVectorOrderBy)(dir) || (0, filters_js_1.isJsonPathOrderBy)(dir) || (typeof dir !== 'string' && !(0, filters_js_1.isOrderBySpec)(dir))) {
720
+ throw new errors_js_1.ValidationError(`findMany on "${this.table}" refused: \`cursor\` cannot page past the orderBy entry "${key}", which ` +
721
+ 'orders by a relation, a JSON path or a vector distance rather than a column. Order the cursor ' +
722
+ 'query by columns only, or page it with `offset`.');
723
+ }
724
+ const column = this.column(key).name;
725
+ if (terms.some((t) => t.column === column))
726
+ continue;
727
+ terms.push({ column, desc: (0, filters_js_1.normalizeOrderBy)(dir).dir === 'DESC' });
728
+ }
729
+ const nullable = (column) => !meta.primaryKey.includes(column) && meta.columns.find((c) => c.name === column)?.nullable === true;
730
+ const keys = (0, compound_unique_js_1.uniqueKeyNames)(meta).filter((cols) => cols.every((c) => !nullable(c)));
731
+ const ordered = new Set(terms.map((t) => t.column));
732
+ const tiebreak = keys.some((cols) => cols.every((c) => ordered.has(c)))
733
+ ? []
734
+ : keys[0]?.filter((c) => !ordered.has(c));
735
+ if (!tiebreak) {
736
+ throw new errors_js_1.ValidationError(`findMany on "${this.table}" refused: a \`cursor\` page needs an order with no ties, and "${this.table}" ` +
737
+ 'has no primary key or unique key on NOT NULL columns to break them. Add a primary key, or page ' +
738
+ 'this table with `offset`.');
739
+ }
740
+ const desc = terms.length > 0 ? terms[terms.length - 1].desc : false;
741
+ for (const column of tiebreak)
742
+ terms.push({ column, desc });
743
+ if (terms.every((t) => valued.has(t.column)) || (0, compound_unique_js_1.whereIdentifiesOneRow)(meta, flat))
744
+ return;
745
+ const missing = terms.filter((t) => !valued.has(t.column)).map((t) => `"${field(t.column)}"`);
746
+ const order = terms.map((t) => `${field(t.column)} ${t.desc ? 'desc' : 'asc'}`).join(', ');
747
+ const named = (0, compound_unique_js_1.uniqueKeyNames)(meta).map((cols) => cols.length === 1 ? `{ ${field(cols[0])} }` : `{ ${cols.map(field).join(', ')} }`);
748
+ throw new errors_js_1.ValidationError(`findMany on "${this.table}" refused: the \`cursor\` has no value for ${missing.join(', ')}, which the ` +
749
+ `page order (${order}) needs, and it does not name one row by a unique key. Give the cursor a value ` +
750
+ `for every ordered field, or name the cursor row by ${named.length > 0 ? named.join(' or ') : 'a unique key'}.`);
751
+ }
752
+ /**
753
+ * A paginated `findMany` (`limit` / `offset`) whose `orderBy` can tie gets
754
+ * a tiebreaker appended in the last term's direction (the primary key, else
755
+ * the first unique key on NOT NULL columns), as on the SQL engines, so a page
756
+ * boundary cannot fall between tied rows in a different order on each page.
757
+ * Returns `args` itself when the order is already total or nothing pages.
758
+ */
759
+ completePageOrder(args) {
760
+ if (args.orderBy === undefined || (args.limit === undefined && args.offset === undefined))
761
+ return args;
762
+ if (args.distinct && args.distinct.length > 0)
763
+ return args;
764
+ const meta = this.meta;
765
+ const entries = (0, filters_js_1.orderByEntries)(args.orderBy).filter(([, d]) => d !== undefined);
766
+ const ordered = new Set();
767
+ let lastDesc = false;
768
+ for (const [key, dir] of entries) {
769
+ lastDesc = false;
770
+ // A JSON-path, vector or relation ordering is not a column of this table.
771
+ if (typeof dir !== 'string' && !(0, filters_js_1.isOrderBySpec)(dir))
772
+ continue;
773
+ const column = (0, utils_js_1.resolveColumnName)(meta, key);
774
+ if (column === undefined)
775
+ continue;
776
+ ordered.add(column);
777
+ lastDesc = (0, filters_js_1.normalizeOrderBy)(dir).dir === 'DESC';
778
+ }
779
+ const nullable = (column) => !meta.primaryKey.includes(column) && meta.columns.find((c) => c.name === column)?.nullable === true;
780
+ const keys = (0, compound_unique_js_1.uniqueKeyNames)(meta).filter((cols) => cols.every((c) => !nullable(c)));
781
+ if (keys.length === 0 || keys.some((cols) => cols.every((c) => ordered.has(c))))
782
+ return args;
783
+ const missing = keys[0].filter((c) => !ordered.has(c));
784
+ const dir = lastDesc ? 'desc' : 'asc';
785
+ return {
786
+ ...args,
787
+ orderBy: [
788
+ ...entries.map(([k, v]) => ({ [k]: v })),
789
+ ...missing.map((c) => ({ [meta.reverseColumnMap[c] ?? c]: dir })),
790
+ ],
791
+ };
499
792
  }
500
793
  /**
501
794
  * `jsonEncoding` selects between PostgreSQL's `json_build_object` and
@@ -509,12 +802,34 @@ class PowqlInterface {
509
802
  return;
510
803
  throw new errors_js_1.UnsupportedFeatureError('jsonEncoding', 'PowDB', 'PowQL emits no JSON row encoding for relations, so there is nothing to select; omit the option');
511
804
  }
805
+ /** `optimisticLock` is an `update` option; the shared refusal and message from query/writes.ts. */
806
+ assertNoOptimisticLock(args, operation) {
807
+ // The shared check reads only the table name from its builder context.
808
+ (0, writes_js_1.assertNoOptimisticLock)({ table: this.table }, args, operation);
809
+ }
512
810
  assertNoForceCustomPlan(args) {
513
811
  if (args?.forceCustomPlan !== true)
514
812
  return;
515
813
  throw new errors_js_1.UnsupportedFeatureError('The forceCustomPlan query option', 'powdb', 'Forcing a per-query custom plan means keeping the statement out of the PostgreSQL plan cache, and PowDB ' +
516
814
  'has no such cache to keep it out of. Remove the option, or set it only on PostgreSQL queries.');
517
815
  }
816
+ /** `lock` (a row-locking read): PowDB has no row locks. */
817
+ assertNoRowLock(args) {
818
+ if (args?.lock === undefined)
819
+ return;
820
+ throw new errors_js_1.UnsupportedFeatureError('The lock read option', 'powdb', 'PowDB has no row locks: a transaction holds the single writer gate. Remove `lock`; inside ' +
821
+ '$transaction on PowDB, writes are already serialized.');
822
+ }
823
+ /** `_percentile`: PowQL has no percentile aggregate. */
824
+ assertNoPercentile(args) {
825
+ const spec = args._percentile;
826
+ // An empty block asks for nothing, as on the SQL engines.
827
+ if (spec === undefined)
828
+ return;
829
+ if (typeof spec === 'object' && spec !== null && !Object.values(spec).some((v) => v != null))
830
+ return;
831
+ throw new errors_js_1.UnsupportedFeatureError('_percentile', 'powdb', 'Percentile aggregates need PostgreSQL; PowQL has count, sum, avg, min and max only.');
832
+ }
518
833
  /** See query/compound-unique.ts: one rule and one message across engines. */
519
834
  assertIdentifiesOneRow(where) {
520
835
  (0, compound_unique_js_1.assertWhereIdentifiesOneRow)(this.meta, this.table, where);
@@ -570,6 +885,11 @@ class PowqlInterface {
570
885
  * an already-RESOLVED where (relation filters pre-resolved to literal in-lists
571
886
  * by {@link resolveRelationFilters}): the relation-key branch below still
572
887
  * throws, so an unresolved relation filter can never leak into a join.
888
+ *
889
+ * `NOT` here is PowQL's two-valued complement. A caller's where reaches this
890
+ * only through {@link sqlTrueWhere} (inside {@link resolveRelationFilters},
891
+ * and in {@link applyGlobalFilter}), which leaves `NOT` only where the two
892
+ * readings agree.
573
893
  */
574
894
  buildWhere(where, params, alias) {
575
895
  if (!where)
@@ -578,26 +898,36 @@ class PowqlInterface {
578
898
  for (const [key, value] of Object.entries(where)) {
579
899
  if (value === undefined)
580
900
  continue;
581
- if (key === 'AND') {
582
- const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
583
- if (sub.length)
584
- parts.push(`(${sub.join(' and ')})`);
585
- }
586
- else if (key === 'OR') {
587
- const sub = value.map((w) => this.buildWhere(w, params, alias)).filter(Boolean);
588
- if (sub.length)
901
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
902
+ const branches = (0, where_compile_js_1.combinatorBranches)(key, value, this.table);
903
+ if (branches === null)
904
+ continue;
905
+ if (key === 'OR' && branches.length === 0) {
906
+ parts.push(this.alwaysFalse());
907
+ continue;
908
+ }
909
+ const built = branches.map((w) => this.buildWhere(w, params, alias));
910
+ // A branch with no condition is TRUE, so the whole `OR` is: a constant
911
+ // condition rather than none, so a `NOT` around it matches no row.
912
+ if (key === 'OR' && built.some((c) => !c)) {
913
+ parts.push(ALWAYS_TRUE);
914
+ continue;
915
+ }
916
+ const sub = built.filter(Boolean);
917
+ if (!sub.length)
918
+ continue;
919
+ if (key === 'OR')
589
920
  parts.push(`(${sub.join(' or ')})`);
921
+ else if (key === 'AND')
922
+ parts.push(`(${sub.join(' and ')})`);
923
+ else
924
+ parts.push(...sub.map((c) => `not (${c})`));
590
925
  }
591
- else if (key === 'NOT') {
592
- const sub = this.buildWhere(value, params, alias);
593
- if (sub)
594
- parts.push(`not (${sub})`);
595
- }
596
- else if (this.meta.relations[key]) {
926
+ else if (Object.hasOwn(this.meta.relations, key)) {
597
927
  // Relation filters are pre-resolved to scalar in/notIn by
598
928
  // resolveRelationFilters() before buildWhere runs, reaching here means
599
929
  // a caller skipped that step (an internal bug, not user error).
600
- throw new errors_js_1.ValidationError(`internal: relation filter "${key}" reached buildWhere unresolved (missing resolveRelationFilters()).`);
930
+ throw new errors_js_1.InvariantError('powql-relation-filter-unresolved', `relation filter "${key}" reached buildWhere unresolved: resolveRelationFilters() must run first.`);
601
931
  }
602
932
  else {
603
933
  // A JsonFilter (or a bare `{ path }`) can compile to zero clauses; skip
@@ -646,37 +976,158 @@ class PowqlInterface {
646
976
  'against a bound timestamp needs the fix. A nested `with` (the default nested-projection / join paths) ' +
647
977
  'correlates column to column, so it loads the relation without any such comparison.');
648
978
  }
979
+ /**
980
+ * Refuse a value comparison on a `numeric` / `decimal` column. PowDB stores
981
+ * those as `str` (it has no decimal type, and `float` would lose digits), so
982
+ * every comparison, ordering and aggregate the engine could run on them is a
983
+ * TEXT comparison: `'7.50' > '7.5'`, `'10' < '9'`, and `'7.5' = '7.50'` is
984
+ * false; arithmetic on a `str` yields NULL. Null checks, reads and plain
985
+ * writes are unaffected.
986
+ */
987
+ assertDecimalComparable(col, what) {
988
+ if (!(0, powdb_shared_js_1.isPowdbDecimalTextColumn)(col, this.schema.enums))
989
+ return;
990
+ throw new errors_js_1.UnsupportedFeatureError(`${what} on the numeric column "${col.name}" of "${this.table}"`, 'PowDB', 'PowDB stores numeric/decimal values as text, so the engine would compare, order and aggregate them as ' +
991
+ "strings ('10' < '9', '7.5' != '7.50') and arithmetic on them yields NULL. Reading, writing and `null` " +
992
+ 'checks work. To compare, aggregate or do arithmetic, store the value as an integer of minor units ' +
993
+ '(cents) or as a float, or do it in application code.');
994
+ }
995
+ /** The labels of `col`'s enum type, or `undefined` when it is not an enum column. */
996
+ enumLabels(col) {
997
+ return (0, powdb_shared_js_1.powdbEnumLabels)(col, this.schema.enums);
998
+ }
999
+ /**
1000
+ * E003 unless `value` is one of the enum column's labels. PostgreSQL refuses
1001
+ * any other value (22P02, which Turbine reports as E003) whether it is
1002
+ * written or compared, and the label set is exact: case and spelling count.
1003
+ * The value itself is not echoed, only its kind.
1004
+ */
1005
+ assertEnumLabel(col, labels, value) {
1006
+ if (typeof value === 'string' && labels.includes(value))
1007
+ return;
1008
+ const shown = labels.slice(0, 10).map((l) => JSON.stringify(l));
1009
+ if (labels.length > shown.length)
1010
+ shown.push(`and ${labels.length - shown.length} more`);
1011
+ throw new errors_js_1.ValidationError(`Invalid value for the enum column "${col.name}" on "${this.table}": expected one of its labels ` +
1012
+ `(${shown.join(', ')}), got ${typeof value === 'string' ? 'a string that is not one of them' : describeOperand(value)}.`);
1013
+ }
1014
+ /**
1015
+ * Refuse an ORDERING of an enum column (orderBy, `_min` / `_max`). PostgreSQL
1016
+ * orders an enum by the position of its labels in the type's declaration;
1017
+ * PowDB stores the label text and would order it alphabetically, a different
1018
+ * answer with no error. Range filters (`gt` / `gte` / `lt` / `lte`) do not come
1019
+ * here: they compile to the exact set of labels on that side of the operand.
1020
+ */
1021
+ assertEnumOrderable(col, what) {
1022
+ if (!this.enumLabels(col))
1023
+ return;
1024
+ throw new errors_js_1.UnsupportedFeatureError(`${what} on the enum column "${col.name}" of "${this.table}"`, 'PowDB', 'PostgreSQL orders an enum by the order its labels are declared in, and PowDB stores the label text, so ' +
1025
+ 'it would order the labels alphabetically. Equality, `in` / `notIn`, `gt` / `gte` / `lt` / `lte` and ' +
1026
+ 'groupBy keys work. Order by the label position in application code.');
1027
+ }
1028
+ /**
1029
+ * An aggregate over an enum column: `_min` / `_max` order it (see
1030
+ * {@link assertEnumOrderable}); `_sum` / `_avg` have no meaning on a label,
1031
+ * which PostgreSQL refuses as E003 (42883, no such function).
1032
+ */
1033
+ assertEnumAggregate(col, fn) {
1034
+ if (!this.enumLabels(col))
1035
+ return;
1036
+ if (fn === '_min' || fn === '_max') {
1037
+ this.assertEnumOrderable(col, `\`${fn}\``);
1038
+ return;
1039
+ }
1040
+ throw new errors_js_1.ValidationError(`\`${fn}\` on the enum column "${col.name}" of "${this.table}": an enum label is not a number. ` +
1041
+ 'Use `_count`, or `_min` / `_max` on another engine.');
1042
+ }
1043
+ /** The E003 for an operand that is not a comparable value for its column. */
1044
+ operandError(field, opName, value, expected) {
1045
+ const position = opName === '=' ? `"${field}"` : `\`${opName}\` on "${field}"`;
1046
+ return new errors_js_1.ValidationError(`Invalid operand for ${position} on "${this.table}": expected ${expected}, got ${describeOperand(value)}. ` +
1047
+ 'A column is compared against a string, number, bigint, boolean or Date; pass an operator object ' +
1048
+ '(`{ gt: 5 }`, `{ in: [1, 2] }`) to choose the comparison.');
1049
+ }
649
1050
  /** Build a single `field: value | operator` condition. */
650
1051
  buildFieldCondition(field, value, params, alias) {
651
1052
  const colMeta = this.column(field);
652
1053
  const ref = this.ref(field, alias);
653
1054
  if (value === null)
654
1055
  return `${ref} is null`;
655
- if (value instanceof Date || typeof value !== 'object') {
1056
+ if (isColumnReference(value)) {
1057
+ throw new errors_js_1.UnsupportedFeatureError('Column references in a filter', 'PowDB', `field "${field}": PowQL has no column-to-column operand in a filter. Compare in application code.`);
1058
+ }
1059
+ if (isScalarOperand(value)) {
1060
+ this.assertDecimalComparable(colMeta, 'Equality');
656
1061
  this.assertDatetimePredicateSupported(colMeta);
657
- return `${ref} = ${this.param(value, params, colMeta)}`;
1062
+ return `${ref} = ${this.filterOperand(value, params, colMeta)}`;
658
1063
  }
659
1064
  const op = value;
660
1065
  // JSON path / key filters on a json document column compile to PowQL `->`
661
1066
  // path filters (≥ 0.12). `isJsonFilter` matches `path`/`equals`/`contains`/
662
1067
  // `hasKey`; on a NON-json column those fall through to the scalar operator
663
1068
  // path below (e.g. `equals` stays a plain equality), exactly like SQL.
664
- if ((0, powdb_shared_js_1.isJsonColumn)(colMeta) && (0, filters_js_1.isJsonFilter)(value)) {
1069
+ if ((0, powdb_shared_js_1.isJsonColumn)(colMeta, this.schema.enums) && (0, filters_js_1.isJsonFilter)(value)) {
665
1070
  (0, powdb_shared_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON path filters');
666
1071
  return this.buildJsonPathCondition(colMeta, value, params, alias);
667
1072
  }
668
- rejectUnsupportedFilter(op, field);
669
- if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
670
- // A bare object that is not an operator set, equality by value.
671
- this.assertDatetimePredicateSupported(colMeta);
672
- return `${ref} = ${this.param(value, params)}`;
1073
+ if (!Array.isArray(op))
1074
+ rejectUnsupportedFilter(op, field);
1075
+ if (Array.isArray(op) || !Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
1076
+ // A json document column compares a whole document by value. Any other
1077
+ // column has no object or array value, so binding one as data compared
1078
+ // against nothing meaningful; it is refused, as on the SQL engines.
1079
+ if (!(0, powdb_shared_js_1.isJsonColumn)(colMeta, this.schema.enums))
1080
+ throw this.operandError(field, '=', value, 'a scalar or an operator object');
1081
+ throw jsonDocumentEqualityUnsupported(colMeta);
673
1082
  }
1083
+ // The SQL engines' operand rules and messages, so the engines agree on
1084
+ // which operator objects are valid; one naming no comparison
1085
+ // (`{ mode: 'insensitive' }`) is refused instead of compiling to nothing.
1086
+ const operand = {
1087
+ column: colMeta.name,
1088
+ table: this.table,
1089
+ kind: (0, filters_js_1.operandColumnKind)(this.meta.pgTypes[colMeta.name]),
1090
+ };
1091
+ if (!(0, powdb_shared_js_1.isJsonColumn)(colMeta, this.schema.enums))
1092
+ (0, filters_js_1.assertOperatorOperands)(op, operand);
1093
+ // On a JSON column a `not` object made only of filter operators is refused,
1094
+ // as on the SQL engines (one spelling, one meaning on every engine).
1095
+ else
1096
+ (0, filters_js_1.assertJsonNotOperand)(op.not, operand);
674
1097
  const insensitive = op.mode === 'insensitive';
675
1098
  const lhs = insensitive ? `lower(${ref})` : ref;
1099
+ const labels = this.enumLabels(colMeta);
676
1100
  const conds = [];
677
1101
  for (const [opName, opVal] of Object.entries(op)) {
678
1102
  if (opVal === undefined || opName === 'mode')
679
1103
  continue;
1104
+ if (labels) {
1105
+ const range = this.enumRangeCondition(colMeta, labels, ref, opName, opVal, insensitive, params);
1106
+ if (range !== undefined) {
1107
+ conds.push(range);
1108
+ continue;
1109
+ }
1110
+ }
1111
+ if (opName === 'not' && opVal !== null && !isScalarOperand(opVal) && !isColumnReference(opVal)) {
1112
+ // `not` takes an operator object, as on the SQL engines: the rows where
1113
+ // the inner condition is FALSE (a NULL column is neither, so it is
1114
+ // excluded, which PowQL's two-valued `not` alone would not do).
1115
+ if (Array.isArray(opVal) || Object.keys(opVal).length === 0) {
1116
+ throw this.operandError(field, 'not', opVal, 'a scalar, null, or a non-empty operator object');
1117
+ }
1118
+ const inner = { ...(op.mode !== undefined ? { mode: op.mode } : {}), ...opVal };
1119
+ const negated = this.buildWhere(this.sqlFalseWhere({ [field]: inner }), params, alias);
1120
+ conds.push(negated || this.alwaysFalse());
1121
+ continue;
1122
+ }
1123
+ if (opName === 'in' || opName === 'notIn') {
1124
+ if (!Array.isArray(opVal) || opVal.some((v) => v !== null && !isScalarOperand(v))) {
1125
+ throw this.operandError(field, opName, opVal, 'an array of scalars');
1126
+ }
1127
+ }
1128
+ else if (opVal !== null && !isScalarOperand(opVal)) {
1129
+ throw this.operandError(field, opName, opVal, 'a scalar');
1130
+ }
680
1131
  // Every operator below compares the column against a bound literal, which
681
1132
  // is the shape a PowDB `datetime` column can only answer from engine 0.20
682
1133
  // (see assertDatetimePredicateSupported; `in`/`notIn` reach the engine as
@@ -686,30 +1137,32 @@ class PowqlInterface {
686
1137
  // (a compile-time constant, never sent).
687
1138
  const comparesNothing = (opVal === null && (opName === 'equals' || opName === 'not')) ||
688
1139
  ((opName === 'in' || opName === 'notIn') && Array.isArray(opVal) && opVal.length === 0);
689
- if (!comparesNothing)
1140
+ if (!comparesNothing) {
1141
+ this.assertDecimalComparable(colMeta, `\`${opName}\``);
690
1142
  this.assertDatetimePredicateSupported(colMeta);
1143
+ }
691
1144
  switch (opName) {
692
1145
  case 'equals':
693
- conds.push(opVal === null ? `${ref} is null` : `${lhs} = ${this.bind(opVal, params, insensitive)}`);
1146
+ conds.push(opVal === null ? `${ref} is null` : `${lhs} = ${this.bind(opVal, params, insensitive, colMeta)}`);
694
1147
  break;
695
1148
  case 'not':
696
1149
  // Spell as `lhs != $N` (not `not (lhs = $N)`) so a missing-value row is
697
1150
  // EXCLUDED, matching SQL null semantics on PowDB >= 0.18.2. Behavior-neutral
698
1151
  // on older engines (pre-0.18.2 `!=` MATCHED missing rows exactly like the
699
1152
  // old `not (...)` spelling), so no capability gate is needed.
700
- conds.push(opVal === null ? `${ref} is not null` : `${lhs} != ${this.bind(opVal, params, insensitive)}`);
1153
+ conds.push(opVal === null ? `${ref} is not null` : `${lhs} != ${this.bind(opVal, params, insensitive, colMeta)}`);
701
1154
  break;
702
1155
  case 'gt':
703
- conds.push(`${lhs} > ${this.bind(opVal, params, insensitive)}`);
1156
+ conds.push(`${lhs} > ${this.bind(opVal, params, insensitive, colMeta)}`);
704
1157
  break;
705
1158
  case 'gte':
706
- conds.push(`${lhs} >= ${this.bind(opVal, params, insensitive)}`);
1159
+ conds.push(`${lhs} >= ${this.bind(opVal, params, insensitive, colMeta)}`);
707
1160
  break;
708
1161
  case 'lt':
709
- conds.push(`${lhs} < ${this.bind(opVal, params, insensitive)}`);
1162
+ conds.push(`${lhs} < ${this.bind(opVal, params, insensitive, colMeta)}`);
710
1163
  break;
711
1164
  case 'lte':
712
- conds.push(`${lhs} <= ${this.bind(opVal, params, insensitive)}`);
1165
+ conds.push(`${lhs} <= ${this.bind(opVal, params, insensitive, colMeta)}`);
713
1166
  break;
714
1167
  case 'in':
715
1168
  conds.push(this.buildInList(lhs, opVal, params, insensitive, false, colMeta));
@@ -718,13 +1171,9 @@ class PowqlInterface {
718
1171
  conds.push(this.buildInList(lhs, opVal, params, insensitive, true, colMeta));
719
1172
  break;
720
1173
  case 'contains':
721
- conds.push(`${lhs} like ${this.bindLike(`%${(0, utils_js_1.escapeLike)(String(opVal))}%`, params, insensitive)}`);
722
- break;
723
1174
  case 'startsWith':
724
- conds.push(`${lhs} like ${this.bindLike(`${(0, utils_js_1.escapeLike)(String(opVal))}%`, params, insensitive)}`);
725
- break;
726
1175
  case 'endsWith':
727
- conds.push(`${lhs} like ${this.bindLike(`%${(0, utils_js_1.escapeLike)(String(opVal))}`, params, insensitive)}`);
1176
+ conds.push(this.buildStringMatch(opName, ref, String(opVal), params, insensitive, `"${field}"`));
728
1177
  break;
729
1178
  default:
730
1179
  throw new errors_js_1.ValidationError(`Unsupported operator "${opName}" on PowDB.`);
@@ -793,9 +1242,8 @@ class PowqlInterface {
793
1242
  throw new errors_js_1.UnsupportedFeatureError('JSON containment filters (contains)', 'PowDB', `column "${col.name}": PowQL has no JSON containment operator`);
794
1243
  }
795
1244
  if (filter.equals !== undefined) {
796
- if (filter.path === undefined || filter.path.length === 0) {
797
- throw new errors_js_1.UnsupportedFeatureError('JSON containment (equals without path)', 'PowDB', `column "${col.name}": pass a \`path\` to compare a specific json value; PowQL has no whole-document containment`);
798
- }
1245
+ if (filter.path === undefined || filter.path.length === 0)
1246
+ throw jsonDocumentEqualityUnsupported(col);
799
1247
  conds.push(filter.equals === null ? `${pathP()} is null` : `${pathP()} = ${this.param(filter.equals, params)}`);
800
1248
  }
801
1249
  if (filter.hasKey !== undefined) {
@@ -827,30 +1275,70 @@ class PowqlInterface {
827
1275
  conds.push(`${pathP()} ${powOp} ${this.param(v, params)}`);
828
1276
  }
829
1277
  // Substring comparisons against the text at `path`, in the same fixed
830
- // order as the SQL path. PowQL has `like`, so these compile natively.
831
- for (const { pattern, value } of (0, where_js_1.jsonStringEntries)(filter, col.name)) {
1278
+ // order as the SQL path, through the same literal matcher as a column.
1279
+ const stringOps = {
1280
+ stringContains: 'contains',
1281
+ stringStartsWith: 'startsWith',
1282
+ stringEndsWith: 'endsWith',
1283
+ };
1284
+ for (const { op, value } of (0, where_js_1.jsonStringEntries)(filter, col.name)) {
832
1285
  const insensitive = filter.mode === 'insensitive';
833
- const lhs = insensitive ? `lower(${pathP()})` : pathP();
834
- conds.push(`${lhs} like ${this.bindLike(pattern((0, utils_js_1.escapeLike)(value)), params, insensitive)}`);
1286
+ const kind = stringOps[op];
1287
+ if (!kind)
1288
+ throw new errors_js_1.InvariantError('powql-json-operator', `unknown JSON string operator "${op}" on "${col.name}".`);
1289
+ conds.push(this.buildStringMatch(kind, pathP(), value, params, insensitive, `the JSON path on "${col.name}"`));
835
1290
  }
836
1291
  // Every reachable shape now emits a condition or throws: assertJsonFilterKeys
837
1292
  // rejects a filter that compares nothing, so an empty list here would be a
838
1293
  // generator bug rather than a user error, and must not silently widen the
839
1294
  // query the way it used to.
840
1295
  if (!conds.length) {
841
- throw new errors_js_1.ValidationError(`internal: JSON filter on "${col.name}" compiled to no condition. This is a bug in turbine-orm.`);
1296
+ throw new errors_js_1.InvariantError('powql-json-filter-empty', `JSON filter on "${col.name}" compiled to no condition.`);
842
1297
  }
843
1298
  return conds.length > 1 ? `(${conds.join(' and ')})` : conds[0];
844
1299
  }
845
1300
  /** Bind a value, lowercasing for case-insensitive comparisons. */
846
- bind(value, params, insensitive) {
847
- const ph = this.param(value, params);
1301
+ bind(value, params, insensitive, col) {
1302
+ const ph = this.filterOperand(value, params, col);
848
1303
  return insensitive ? `lower(${ph})` : ph;
849
1304
  }
850
- /** Bind a LIKE pattern (already escaped), lowercasing for insensitive mode. */
851
- bindLike(pattern, params, insensitive) {
852
- const ph = this.param(pattern, params);
853
- return insensitive ? `lower(${ph})` : ph;
1305
+ /**
1306
+ * A bound filter operand. An integral number compared with a float column is
1307
+ * cast to float in the statement: the typed wire carries an integral number as
1308
+ * an int, and the engine compares int with float by type tag inside `not`,
1309
+ * `or` and `in` (on 0.20 `not (.rating = 4)` matches the 4.0 row and
1310
+ * `.rating in (4, 5)` matches nothing).
1311
+ */
1312
+ filterOperand(value, params, col) {
1313
+ const ph = this.param(value, params, col);
1314
+ return col && typeof value === 'number' && Number.isInteger(value) && this.isFloatCol(col) ? `(${ph} + 0.0)` : ph;
1315
+ }
1316
+ /**
1317
+ * `contains` / `startsWith` / `endsWith` against `expr` (a column or a JSON
1318
+ * path), matching `value` literally.
1319
+ *
1320
+ * PowQL's `like` has NO escape character: `%` and `_` in the pattern are
1321
+ * always wildcards and `\` is an ordinary character. So a prefix or suffix
1322
+ * test never goes through `like`: it compares the leading or trailing
1323
+ * `length($v)` characters with `substring`, which is exact for every value
1324
+ * on every engine version (and sidesteps the pre-0.27 matcher, which missed
1325
+ * text containing a literal `%`). `contains` has no such form, since PowQL
1326
+ * has no position function, so a value carrying `%` or `_` is refused rather
1327
+ * than sent as a pattern that means something else.
1328
+ */
1329
+ buildStringMatch(op, expr, value, params, insensitive, target) {
1330
+ const fold = (e) => (insensitive ? `lower(${e})` : e);
1331
+ if (op === 'contains') {
1332
+ if (/[%_]/.test(value)) {
1333
+ throw new errors_js_1.UnsupportedFeatureError('`contains` with a value containing `%` or `_`', 'PowDB', `${target} on "${this.table}": PowQL's \`like\` has no escape character, so those characters would act ` +
1334
+ 'as wildcards and match other rows. `startsWith` and `endsWith` match such values exactly; for a ' +
1335
+ 'substring, filter in application code.');
1336
+ }
1337
+ return `${fold(expr)} like ${fold(this.param(`%${value}%`, params))}`;
1338
+ }
1339
+ const ph = this.param(value, params);
1340
+ const start = op === 'startsWith' ? '1' : `length(${expr}) - length(${ph}) + 1`;
1341
+ return `(length(${expr}) >= length(${ph}) and ${fold(`substring(${expr}, ${start}, length(${ph}))`)} = ${fold(ph)})`;
854
1342
  }
855
1343
  /**
856
1344
  * `lhs [not] in ($1, $2, …)`, empty list collapses to a constant.
@@ -858,15 +1346,41 @@ class PowqlInterface {
858
1346
  * A PowDB-native `datetime` column takes the expanded form instead (see
859
1347
  * {@link buildDatetimeInList}): its `in` list is still broken upstream at 0.20.
860
1348
  */
1349
+ /**
1350
+ * The enum-specific shapes of one operator on an enum column, or `undefined`
1351
+ * for an operator compiled the ordinary way (equality, `in` / `notIn`, `not`,
1352
+ * whose labels {@link param} checks).
1353
+ *
1354
+ * `gt` / `gte` / `lt` / `lte` compile to `in` over the labels on that side of
1355
+ * the operand in DECLARATION order, which is what PostgreSQL compares; the
1356
+ * text comparison PowDB would run is alphabetical, a different answer. A null
1357
+ * operand matches no row, as `col > NULL` does. Case-insensitive matching and
1358
+ * `contains` / `startsWith` / `endsWith` are E003: PostgreSQL has no `lower`
1359
+ * or `LIKE` on an enum (42883).
1360
+ */
1361
+ enumRangeCondition(col, labels, ref, opName, opVal, insensitive, params) {
1362
+ if (insensitive || opName === 'contains' || opName === 'startsWith' || opName === 'endsWith') {
1363
+ throw new errors_js_1.ValidationError(`${insensitive ? "`mode: 'insensitive'`" : `\`${opName}\``} on the enum column "${col.name}" of ` +
1364
+ `"${this.table}": an enum compares whole labels exactly. Use \`equals\`, \`in\` or \`notIn\` with its labels.`);
1365
+ }
1366
+ if (opName !== 'gt' && opName !== 'gte' && opName !== 'lt' && opName !== 'lte')
1367
+ return undefined;
1368
+ if (opVal === null)
1369
+ return this.alwaysFalse();
1370
+ this.assertEnumLabel(col, labels, opVal);
1371
+ const at = labels.indexOf(opVal);
1372
+ const side = labels.filter((_, i) => opName === 'gt' ? i > at : opName === 'gte' ? i >= at : opName === 'lt' ? i < at : i <= at);
1373
+ return this.buildInList(ref, side, params, false, false, col);
1374
+ }
861
1375
  buildInList(lhs, values, params, insensitive, negate, col) {
862
1376
  if (!Array.isArray(values) || values.length === 0) {
863
1377
  // `in []` matches nothing; `not in []` matches everything (SQL parity requires a
864
1378
  // missing-value row to match `notIn []`, so NO presence guard is appended here).
865
- return negate ? '(1 = 1)' : this.alwaysFalse();
1379
+ return negate ? ALWAYS_TRUE : this.alwaysFalse();
866
1380
  }
867
1381
  if (col && (0, powdb_shared_js_1.isPowdbDatetimeColumn)(col))
868
1382
  return this.buildDatetimeInList(lhs, values, params, negate, col);
869
- const items = values.map((v) => this.bind(v, params, insensitive)).join(', ');
1383
+ const items = values.map((v) => this.bind(v, params, insensitive, col)).join(', ');
870
1384
  if (negate) {
871
1385
  // PowQL `not in` matches missing-value rows, so append `and lhs is not null`
872
1386
  // for SQL null-semantics parity (non-empty list only, per the empty-list note above).
@@ -947,21 +1461,33 @@ class PowqlInterface {
947
1461
  return col && (0, powdb_shared_js_1.isPowdbDatetimeColumn)(col) ? exports.MAX_POWQL_DATETIME_TERMS : MAX_RELATION_KEYS;
948
1462
  }
949
1463
  /**
950
- * Pre-resolve every relation filter (`some`/`none`/`every`) in a where clause
951
- * into a plain scalar `in`/`notIn` condition on the **local key**, by running
952
- * the inner predicate as its own query and materializing the matching keys as
953
- * a literal list. The compiled where is then relation-free and `buildWhere`
954
- * emits only `in (<literal list>)`.
1464
+ * Pre-resolve every relation filter in a where clause into a plain scalar
1465
+ * condition on the **local key**, by running the inner predicate as its own
1466
+ * query and materializing the matching keys as a literal list. The compiled
1467
+ * where is then relation-free and `buildWhere` emits only `in (<literal list>)`.
955
1468
  *
956
1469
  * Why not an IN-subquery (`.k in (Target filter <e> { .fk })`)? PowDB's
957
1470
  * executor caches a subquery's result by **plan shape, ignoring the literal**,
958
1471
  * so a second subquery of the same shape with a different value returns the
959
1472
  * first one's stale rows (reproduced live on the embedded engine; the
960
1473
  * single-statement literal `in (list)` form is always correct). Resolving
961
- * client-side trades extra round-trips for correctness, and recurses, nested
1474
+ * client-side trades extra round-trips for correctness, and recurses: nested
962
1475
  * relation filters in the inner predicate resolve when the target query runs.
1476
+ *
1477
+ * `skip` is the query's `skipGlobalFilters`, forwarded to every target query
1478
+ * so the target's global filter bounds the related rows exactly as the SQL
1479
+ * engines' EXISTS does, and an opt-out reaches the targets as it does there.
1480
+ */
1481
+ async resolveRelationFilters(where, timeout, skip) {
1482
+ if (!where)
1483
+ return where;
1484
+ return this.resolveRelationFiltersIn(this.sqlTrueWhere(where), timeout, skip);
1485
+ }
1486
+ /**
1487
+ * {@link resolveRelationFilters} over a where already in {@link sqlTrueWhere}'s
1488
+ * form (`AND` / `OR` arrays, `NOT` objects).
963
1489
  */
964
- async resolveRelationFilters(where, timeout) {
1490
+ async resolveRelationFiltersIn(where, timeout, skip) {
965
1491
  if (!where)
966
1492
  return where;
967
1493
  const scalar = {};
@@ -970,76 +1496,195 @@ class PowqlInterface {
970
1496
  if (value === undefined)
971
1497
  continue;
972
1498
  if (key === 'AND' || key === 'OR') {
973
- scalar[key] = await Promise.all(value.map((w) => this.resolveRelationFilters(w, timeout)));
974
- }
975
- else if (key === 'NOT') {
976
- scalar[key] = await this.resolveRelationFilters(value, timeout);
1499
+ scalar[key] = await Promise.all(value.map((w) => this.resolveRelationFiltersIn(w, timeout, skip)));
1500
+ continue;
977
1501
  }
978
- else if ((0, utils_js_1.resolveRelationDef)(this.meta.relations, key)) {
979
- relConds.push(await this.resolveRelationCondition((0, utils_js_1.resolveRelationDef)(this.meta.relations, key), value, timeout));
1502
+ if (key === 'NOT') {
1503
+ scalar[key] = await this.resolveRelationFiltersIn(value, timeout, skip);
1504
+ continue;
980
1505
  }
981
- else {
1506
+ const rel = (0, utils_js_1.resolveRelationDef)(this.meta.relations, key);
1507
+ if (!rel) {
982
1508
  scalar[key] = value;
1509
+ continue;
983
1510
  }
1511
+ const cond = await this.resolveRelationCondition(rel, key, value, timeout, skip);
1512
+ if (cond)
1513
+ relConds.push(cond);
984
1514
  }
985
1515
  if (!relConds.length)
986
1516
  return scalar;
987
1517
  const hasScalar = Object.keys(scalar).length > 0;
988
1518
  return { AND: [...(hasScalar ? [scalar] : []), ...relConds] };
989
1519
  }
990
- /** Resolve one hasMany/hasOne/belongsTo filter to `{ localField: { in|notIn: [...] } }`. */
991
- async resolveRelationCondition(rel, filter, timeout) {
992
- const mode = filter.some ? 'some' : filter.none ? 'none' : filter.every ? 'every' : null;
993
- if (!mode)
1520
+ /**
1521
+ * Resolve one relation filter to a relation-free condition on the local key,
1522
+ * with the SQL engines' meaning for every branch:
1523
+ *
1524
+ * some / is EXISTS a related row matching the inner where
1525
+ * none / isNot NOT EXISTS such a row (true for a NULL local key)
1526
+ * every NOT EXISTS a related row for which the inner where is FALSE
1527
+ * is: null NOT EXISTS any related row
1528
+ * isNot: null EXISTS a related row
1529
+ *
1530
+ * A bare object on a to-one relation is an implicit `is` (the shared
1531
+ * {@link normalizeRelationFilter} rule). Branches combine with AND. A shape
1532
+ * that names no branch, or names something else, is refused: compiling it to
1533
+ * nothing would match every row, and a mutation carrying it would write them.
1534
+ * Returns `null` when every branch key is `undefined` (an absent filter).
1535
+ */
1536
+ async resolveRelationCondition(rel, key, value, timeout, skip) {
1537
+ const shapeError = (detail) => new errors_js_1.ValidationError(`Invalid relation filter "${key}" on "${this.table}": ${detail}. ` +
1538
+ (rel.type === 'belongsTo' || rel.type === 'hasOne'
1539
+ ? `A to-one relation takes \`{ is: {...} }\`, \`{ isNot: {...} }\`, a bare where object, or \`is: null\`.`
1540
+ : 'A to-many relation takes `{ some: {...} }`, `{ none: {...} }` or `{ every: {...} }`.'));
1541
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
1542
+ throw shapeError(`expected an object, got ${describeOperand(value)}`);
1543
+ }
1544
+ // The shared rule; its first parameter is unused, so it needs no SQL builder context.
1545
+ const filter = (0, where_js_1.normalizeRelationFilter)(undefined, rel, value);
1546
+ const keys = Object.keys(filter);
1547
+ if (keys.length === 0)
1548
+ throw shapeError('the filter object is empty');
1549
+ const unknown = keys.filter((k) => !RELATION_FILTER_KEYS.includes(k));
1550
+ if (unknown.length)
1551
+ throw shapeError(`unrecognized key ${unknown.map((k) => `"${k}"`).join(', ')}`);
1552
+ if (keys.every((k) => filter[k] === undefined))
1553
+ return null;
1554
+ // The shared value rule: `some` / `none` / `every` take a where object,
1555
+ // `is` / `isNot` a where object or null. Checked for every branch before
1556
+ // any of them runs a query.
1557
+ for (const branch of RELATION_FILTER_KEYS) {
1558
+ const problem = (0, where_compile_js_1.relationFilterValueProblem)(branch, filter[branch]);
1559
+ if (problem !== null)
1560
+ throw shapeError(problem);
1561
+ }
1562
+ // The shared cardinality rule: `is` / `isNot` on a to-many relation.
1563
+ const cardinality = (0, where_compile_js_1.relationCardinalityProblem)(rel, filter);
1564
+ if (cardinality !== null)
1565
+ throw shapeError(cardinality);
1566
+ const conds = [];
1567
+ for (const branch of RELATION_FILTER_KEYS) {
1568
+ const inner = filter[branch];
1569
+ if (inner === undefined)
1570
+ continue;
1571
+ const where = inner;
1572
+ switch (branch) {
1573
+ case 'some':
1574
+ conds.push(await this.relationMatch(rel, where, true, timeout, skip));
1575
+ break;
1576
+ case 'none':
1577
+ conds.push(await this.relationMatch(rel, where, false, timeout, skip));
1578
+ break;
1579
+ case 'every': {
1580
+ // `every` over a where that compiles to no condition (`{}`, `AND: []`)
1581
+ // holds for every parent, as in SQL. Otherwise the parent fails when
1582
+ // one related row makes the predicate FALSE; a row that leaves it
1583
+ // unknown (a NULL column) does not count against it.
1584
+ const targetQi = this.relationTargetQi(rel);
1585
+ if (targetQi.compilesToNothing(where))
1586
+ break;
1587
+ const violates = targetQi.sqlFalseWhere(where);
1588
+ conds.push(await this.relationMatch(rel, violates, false, timeout, skip));
1589
+ break;
1590
+ }
1591
+ case 'is':
1592
+ conds.push(await this.relationMatch(rel, where ?? {}, where !== null, timeout, skip));
1593
+ break;
1594
+ case 'isNot':
1595
+ conds.push(await this.relationMatch(rel, where ?? {}, where === null, timeout, skip));
1596
+ break;
1597
+ }
1598
+ }
1599
+ if (conds.length === 0)
994
1600
  return {};
995
- const innerWhere = filter[mode];
996
- const innerEmpty = !innerWhere || Object.keys(innerWhere).length === 0;
997
- if (rel.type === 'manyToMany') {
998
- return this.resolveManyToManyCondition(rel, mode, innerWhere, innerEmpty, timeout);
1601
+ return conds.length === 1 ? conds[0] : { AND: conds };
1602
+ }
1603
+ /** A fresh interface over a relation's target table, for an internal fetch. */
1604
+ relationTargetQi(rel) {
1605
+ if (!this.schema.tables[rel.to]) {
1606
+ throw new errors_js_1.ValidationError(`Relation "${rel.name}" targets unknown table "${rel.to}".`);
999
1607
  }
1608
+ return new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
1609
+ }
1610
+ /**
1611
+ * `EXISTS` (or `NOT EXISTS`, when `exists` is false) a related row matching
1612
+ * `inner`, as a condition on this table's local key.
1613
+ *
1614
+ * The NOT EXISTS form keeps a NULL local key: such a row has no related row,
1615
+ * so it qualifies. PowQL's `not in` alone would drop it.
1616
+ */
1617
+ async relationMatch(rel, inner, exists, timeout, skip) {
1618
+ const { localField, keys } = rel.type === 'manyToMany'
1619
+ ? await this.manyToManySourceKeys(rel, inner, timeout, skip)
1620
+ : await this.directRelationKeys(rel, inner, timeout, skip);
1621
+ if (exists)
1622
+ return { [localField]: { in: keys } };
1623
+ const localCol = this.column(localField);
1624
+ if (!localCol.nullable)
1625
+ return { [localField]: { notIn: keys } };
1626
+ return { OR: [{ [localField]: null }, { [localField]: { notIn: keys } }] };
1627
+ }
1628
+ /**
1629
+ * hasMany / hasOne / belongsTo: the local field, and the distinct correlation
1630
+ * keys of the target rows matching `inner` (inside the target's global filter).
1631
+ * hasMany/hasOne: local = our referenceKey, collected = the child's foreignKey.
1632
+ * belongsTo: local = our foreignKey, collected = the target's referenceKey.
1633
+ */
1634
+ async directRelationKeys(rel, inner, timeout, skip) {
1000
1635
  const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
1001
1636
  const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
1002
1637
  if (fk.length > 1 || rk.length > 1) {
1003
1638
  throw new errors_js_1.UnsupportedFeatureError('composite-key relation filters', 'PowDB', `relation "${rel.name}" uses a composite key, PowQL has no tuple-\`in\` to express it`);
1004
1639
  }
1005
- const targetMeta = this.schema.tables[rel.to];
1006
- if (!targetMeta)
1007
- throw new errors_js_1.ValidationError(`Relation "${rel.name}" targets unknown table "${rel.to}".`);
1008
- // hasMany/hasOne: localKey = our referenceKey, collect the child's foreignKey.
1009
- // belongsTo: localKey = our foreignKey, collect the target's referenceKey.
1640
+ const targetQi = this.relationTargetQi(rel);
1010
1641
  const localCol = rel.type === 'belongsTo' ? fk[0] : rk[0];
1011
1642
  const childCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
1012
1643
  const localField = this.meta.reverseColumnMap[localCol] ?? localCol;
1013
- const childField = targetMeta.reverseColumnMap[childCol] ?? childCol;
1014
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
1015
- const collect = async (w) => {
1016
- const rows = await targetQi.findMany({
1017
- where: w,
1018
- select: { [childField]: true },
1019
- timeout,
1020
- });
1021
- return [...new Set(rows.map((r) => r[childField]).filter((v) => v != null))];
1022
- };
1023
- if (mode === 'some')
1024
- return { [localField]: { in: await collect(innerWhere) } };
1025
- if (mode === 'none')
1026
- return { [localField]: { notIn: await collect(innerWhere) } };
1027
- // every: a parent qualifies unless it has a child FAILING the predicate.
1028
- if (innerEmpty)
1029
- return {}; // every:{} ⇒ trivially true for all parents
1030
- return { [localField]: { notIn: await collect({ NOT: innerWhere }) } };
1031
- }
1032
- /** Resolve a manyToMany filter through the junction to `{ sourceRefField: { in|notIn: [...] } }`. */
1033
- async resolveManyToManyCondition(rel, mode, innerWhere, innerEmpty, timeout) {
1644
+ const childField = targetQi.meta.reverseColumnMap[childCol] ?? childCol;
1645
+ const keys = await targetQi.collectRelationKeys(inner, childField, rel.name, timeout, skip);
1646
+ return { localField, keys };
1647
+ }
1648
+ /**
1649
+ * The distinct non-null values of `field` over this table's rows matching
1650
+ * `where` (inside this table's global filter, honouring `skip`), refused
1651
+ * past {@link MAX_RELATION_FILTER_KEYS}.
1652
+ */
1653
+ async collectRelationKeys(where, field, relName, timeout, skip) {
1654
+ const rows = await this.findMany({
1655
+ where: where,
1656
+ select: { [field]: true },
1657
+ timeout,
1658
+ ...(skip !== undefined ? { skipGlobalFilters: skip } : {}),
1659
+ });
1660
+ return this.distinctKeys(rows.map((r) => r[field]), relName);
1661
+ }
1662
+ /** Dedupe correlation keys by value (a Date or bigint by its value, not identity) and enforce the cap. */
1663
+ distinctKeys(values, relName) {
1664
+ const byKey = new Map();
1665
+ for (const v of values) {
1666
+ const k = this.joinKey(v);
1667
+ if (k !== null && !byKey.has(k))
1668
+ byKey.set(k, v);
1669
+ }
1670
+ if (byKey.size > exports.MAX_RELATION_FILTER_KEYS) {
1671
+ throw new errors_js_1.UnsupportedFeatureError(`A relation filter on "${relName}" matching ${byKey.size} distinct keys`, 'PowDB', `PowDB resolves a relation filter into a literal list of keys, which the engine checks row by row, so ` +
1672
+ `the cost grows with rows times keys; Turbine refuses lists over ${exports.MAX_RELATION_FILTER_KEYS}. Narrow the ` +
1673
+ 'inner predicate, or query from the related side instead (filter the related table and load the parent ' +
1674
+ 'with `with`).');
1675
+ }
1676
+ return [...byKey.values()];
1677
+ }
1678
+ /** manyToMany: the source key field, and the source keys linked (through the junction) to targets matching `inner`. */
1679
+ async manyToManySourceKeys(rel, inner, timeout, skip) {
1034
1680
  const through = rel.through;
1035
1681
  if (!through)
1036
1682
  throw new errors_js_1.ValidationError(`manyToMany relation "${rel.name}" is missing its junction (\`through\`).`);
1037
1683
  const sourceJ = (0, schema_js_1.normalizeKeyColumns)(through.sourceKey);
1038
1684
  const targetJ = (0, schema_js_1.normalizeKeyColumns)(through.targetKey);
1039
1685
  const sourceRef = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
1040
- const targetMeta = this.schema.tables[rel.to];
1041
- if (!targetMeta)
1042
- throw new errors_js_1.ValidationError(`Relation "${rel.name}" targets unknown table "${rel.to}".`);
1686
+ const targetQi = this.relationTargetQi(rel);
1687
+ const targetMeta = targetQi.meta;
1043
1688
  if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
1044
1689
  throw new errors_js_1.UnsupportedFeatureError('composite-key manyToMany filters', 'PowDB', `relation "${rel.name}", PowQL has no tuple-\`in\` for composite junction/target keys`);
1045
1690
  }
@@ -1047,50 +1692,158 @@ class PowqlInterface {
1047
1692
  const targetJCol = targetJ[0];
1048
1693
  const sourceRefCol = sourceRef[0];
1049
1694
  const targetPkCol = targetMeta.primaryKey[0];
1050
- const sourceRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
1695
+ const localField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
1051
1696
  const sourceRefColMeta = this.meta.columns.find((c) => c.name === sourceRefCol);
1052
1697
  const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
1053
- const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
1054
- const collectTargetPks = async (w) => {
1055
- const rows = await targetQi.findMany({
1056
- where: w,
1057
- select: { [targetPkField]: true },
1058
- timeout,
1059
- });
1060
- return [...new Set(rows.map((r) => r[targetPkField]).filter((v) => v != null))];
1061
- };
1062
- // Junction source keys linking any of `targetPks` (literal IN-list, never a subquery).
1698
+ const targetPks = await targetQi.collectRelationKeys(inner, targetPkField, rel.name, timeout, skip);
1699
+ if (!targetPks.length)
1700
+ return { localField, keys: [] };
1701
+ // Junction source keys linking any of the targets (literal in-lists, never a subquery).
1063
1702
  const junctionMeta = this.schema.tables[through.table];
1064
1703
  const targetJColMeta = junctionMeta?.columns.find((c) => c.name === targetJCol);
1065
1704
  const junctionChunk = this.keyChunkSize(junctionMeta, targetJCol);
1066
- const sourcesForTargets = async (targetPks) => {
1067
- if (!targetPks.length)
1068
- return [];
1069
- const out = new Set();
1070
- for (let i = 0; i < targetPks.length; i += junctionChunk) {
1071
- const chunk = targetPks.slice(i, i + junctionChunk);
1072
- const params = [];
1073
- const keyClause = this.inClause(this.colRefName(targetJCol), chunk, params, targetJColMeta);
1074
- const { rows } = await this.exec(`${(0, powdb_shared_js_1.quotePowqlIdent)(through.table)} filter ${keyClause} { ${this.colRefName(sourceJCol)} }`, params, timeout, 'findMany');
1075
- for (const r of rows) {
1076
- const v = r[sourceJCol];
1077
- if (v != null)
1078
- out.add(sourceRefColMeta ? coerceScalar(String(v), sourceRefColMeta.tsType) : v);
1705
+ const sources = [];
1706
+ for (let i = 0; i < targetPks.length; i += junctionChunk) {
1707
+ const chunk = targetPks.slice(i, i + junctionChunk);
1708
+ const params = [];
1709
+ const keyClause = this.inClause(this.colRefName(targetJCol), chunk, params, targetJColMeta);
1710
+ const { rows } = await this.exec(`${(0, powdb_shared_js_1.quotePowqlIdent)(through.table)} filter ${keyClause} { ${this.colRefName(sourceJCol)} }`, params, timeout, 'findMany');
1711
+ for (const r of rows) {
1712
+ const v = r[sourceJCol];
1713
+ if (v != null)
1714
+ sources.push(sourceRefColMeta ? coerceScalar(String(v), sourceRefColMeta.tsType) : v);
1715
+ }
1716
+ }
1717
+ return { localField, keys: this.distinctKeys(sources, rel.name) };
1718
+ }
1719
+ /**
1720
+ * Rows where `where` is TRUE under SQL three-valued logic. PowQL's own
1721
+ * evaluation already excludes a NULL from every comparison, so this differs
1722
+ * from `where` only under `NOT`, where PowQL takes the plain complement and
1723
+ * SQL keeps a NULL comparison unknown.
1724
+ *
1725
+ * Combinators are read by the shared {@link combinatorBranches} rule, so a
1726
+ * shape the SQL engines refuse is refused here (E003), and the output uses
1727
+ * one spelling: `AND` / `OR` arrays and `NOT` objects. `OR: []` stays as
1728
+ * written and compiles to no row.
1729
+ */
1730
+ sqlTrueWhere(where) {
1731
+ const parts = [];
1732
+ for (const [key, value] of Object.entries(where)) {
1733
+ if (value === undefined)
1734
+ continue;
1735
+ if (isCombinatorKey(key)) {
1736
+ const branches = (0, where_compile_js_1.combinatorBranches)(key, value, this.table);
1737
+ if (branches === null)
1738
+ continue;
1739
+ if (key === 'NOT') {
1740
+ for (const branch of branches) {
1741
+ // A branch that compiles to no condition is skipped, as on the SQL engines.
1742
+ if (!this.compilesToNothing(branch)) {
1743
+ parts.push(this.sqlFalseWhere(branch));
1744
+ }
1745
+ }
1746
+ }
1747
+ else if (key === 'OR' || branches.length > 0) {
1748
+ parts.push({ [key]: branches.map((w) => this.sqlTrueWhere(w)) });
1079
1749
  }
1080
1750
  }
1081
- return [...out];
1082
- };
1083
- if (mode === 'some') {
1084
- return { [sourceRefField]: { in: await sourcesForTargets(await collectTargetPks(innerWhere)) } };
1751
+ else {
1752
+ parts.push({ [key]: value });
1753
+ }
1754
+ }
1755
+ if (parts.length === 0)
1756
+ return {};
1757
+ return (parts.length === 1 ? parts[0] : { AND: parts });
1758
+ }
1759
+ /**
1760
+ * Rows where `where` is FALSE under SQL three-valued logic, the complement
1761
+ * PowQL's two-valued `not` cannot express: a comparison against a NULL
1762
+ * column is unknown, which is neither TRUE nor FALSE, so the row belongs to
1763
+ * neither set. Relation filters (EXISTS) are two-valued and negate plainly.
1764
+ *
1765
+ * A sub-where that compiles to no condition is TRUE, so it is FALSE for no
1766
+ * row and contributes nothing; `OR: []` is FALSE for every row.
1767
+ */
1768
+ sqlFalseWhere(where) {
1769
+ const parts = [];
1770
+ for (const [key, value] of Object.entries(where)) {
1771
+ if (value === undefined)
1772
+ continue;
1773
+ if (isCombinatorKey(key)) {
1774
+ const branches = (0, where_compile_js_1.combinatorBranches)(key, value, this.table);
1775
+ if (branches === null)
1776
+ continue;
1777
+ if (key === 'OR' && branches.length === 0) {
1778
+ parts.push(this.trueWhere());
1779
+ continue;
1780
+ }
1781
+ const live = branches.filter((w) => !this.compilesToNothing(w));
1782
+ // An `OR` with a branch that has no condition is TRUE: FALSE for no row.
1783
+ if (live.length === 0 || (key === 'OR' && live.length < branches.length))
1784
+ continue;
1785
+ if (key === 'AND')
1786
+ parts.push({ OR: live.map((w) => this.sqlFalseWhere(w)) });
1787
+ else if (key === 'OR')
1788
+ parts.push({ AND: live.map((w) => this.sqlFalseWhere(w)) });
1789
+ else
1790
+ for (const w of live)
1791
+ parts.push(this.sqlTrueWhere(w));
1792
+ }
1793
+ else if ((0, utils_js_1.resolveRelationDef)(this.meta.relations, key)) {
1794
+ parts.push({ NOT: { [key]: value } });
1795
+ }
1796
+ else {
1797
+ // A column leaf is FALSE when the column is non-null and the leaf does
1798
+ // not hold; on a NULL column only an explicit `not: null` (or an empty
1799
+ // `in`) makes it FALSE, every other comparison is unknown there.
1800
+ const op = value !== null && typeof value === 'object' ? value : undefined;
1801
+ const falseAtNull = op !== undefined && (op.not === null || (Array.isArray(op.in) && op.in.length === 0));
1802
+ const branch = { AND: [{ [key]: { not: null } }, { NOT: { [key]: value } }] };
1803
+ parts.push(falseAtNull ? { OR: [branch, { [key]: null }] } : branch);
1804
+ }
1085
1805
  }
1086
- if (mode === 'none') {
1087
- return { [sourceRefField]: { notIn: await sourcesForTargets(await collectTargetPks(innerWhere)) } };
1806
+ // A where with no condition is TRUE for every row, so FALSE for none.
1807
+ if (parts.length === 0)
1808
+ return this.falseWhere();
1809
+ return (parts.length === 1 ? parts[0] : { OR: parts });
1810
+ }
1811
+ /**
1812
+ * Whether a where compiles to no condition, as {@link buildWhere} and the
1813
+ * SQL where walk both read it: only absent keys, and `AND` / `NOT` whose
1814
+ * branches all compile to nothing (`AND: []`, `NOT: [{}]`). Any `OR` is a
1815
+ * condition: `OR: []` matches no row, and an `OR` with a branch that has no
1816
+ * condition is constant TRUE (`OR: [{}]`), which `NOT` negates. So is any
1817
+ * column or relation key.
1818
+ */
1819
+ compilesToNothing(where) {
1820
+ if (where === null || typeof where !== 'object' || Array.isArray(where))
1821
+ return false;
1822
+ for (const [key, value] of Object.entries(where)) {
1823
+ if (value === undefined)
1824
+ continue;
1825
+ if (!isCombinatorKey(key))
1826
+ return false;
1827
+ const branches = (0, where_compile_js_1.combinatorBranches)(key, value, this.table);
1828
+ if (branches === null)
1829
+ continue;
1830
+ if (key === 'OR')
1831
+ return false;
1832
+ if (!branches.every((w) => this.compilesToNothing(w)))
1833
+ return false;
1088
1834
  }
1089
- // every: exclude parents that link a target FAILING the predicate.
1090
- if (innerEmpty)
1091
- return {}; // every:{} ⇒ trivially true
1092
- const failing = await sourcesForTargets(await collectTargetPks({ NOT: innerWhere }));
1093
- return { [sourceRefField]: { notIn: failing } };
1835
+ return true;
1836
+ }
1837
+ /** A where clause that matches every row (the primary key is null or not null). */
1838
+ trueWhere() {
1839
+ const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name ?? 'id';
1840
+ const field = this.meta.reverseColumnMap[pk] ?? pk;
1841
+ return { OR: [{ [field]: null }, { [field]: { not: null } }] };
1842
+ }
1843
+ /** A where clause that matches no row (an empty `in` on the primary key). */
1844
+ falseWhere() {
1845
+ const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name ?? 'id';
1846
+ return { [this.meta.reverseColumnMap[pk] ?? pk]: { in: [] } };
1094
1847
  }
1095
1848
  // -------------------------------------------------------------------------
1096
1849
  // Projection / order
@@ -1099,8 +1852,9 @@ class PowqlInterface {
1099
1852
  * Resolve the set of columns to project, honouring `select` / `omit` and the
1100
1853
  * query-level `includePii` opt-in. PII-tagged (`defineSchema` `pii: true`)
1101
1854
  * columns are EXCLUDED from a default (or omit-only) projection unless
1102
- * `includePii` is true; an explicit `select` naming a PII column IS the opt-in
1103
- * and returns it regardless. Untagged tables project exactly as before.
1855
+ * `includePii: UNSAFE` is passed, and a `select` naming one without it is
1856
+ * refused (E003), as on the SQL engines. Untagged tables project exactly as
1857
+ * before.
1104
1858
  */
1105
1859
  /**
1106
1860
  * Resolve one projection field name, or throw. PowQL already refused an
@@ -1164,6 +1918,23 @@ class PowqlInterface {
1164
1918
  const forcedPk = [];
1165
1919
  const hasSelect = select && Object.keys(select).length;
1166
1920
  if (hasSelect) {
1921
+ // A PII column named in `select` is refused unless the read passes
1922
+ // `includePii: UNSAFE`, as on the SQL engines. A key the loader added
1923
+ // itself (a PII-tagged correlation column) carries the stitch-only mark.
1924
+ if (!includePii) {
1925
+ const pii = this.piiColumnNames();
1926
+ if (pii.size) {
1927
+ const stitchOnly = (0, batched_loader_js_1.stitchOnlyFields)(select);
1928
+ for (const [key, on] of Object.entries(select)) {
1929
+ if (!on || stitchOnly?.has(key))
1930
+ continue;
1931
+ const column = this.projectionColumn(key, 'select');
1932
+ if (pii.has(column) && !pk.has(column)) {
1933
+ throw new errors_js_1.ValidationError((0, relations_js_1.piiSelectRefusedMessage)(this.table, this.meta.reverseColumnMap[column] ?? column));
1934
+ }
1935
+ }
1936
+ }
1937
+ }
1167
1938
  const picked = new Set(Object.entries(select)
1168
1939
  .filter(([, v]) => v)
1169
1940
  .map(([k]) => this.projectionColumn(k, 'select')));
@@ -1213,6 +1984,34 @@ class PowqlInterface {
1213
1984
  * every consumer of the key (relation stitching, reselect) has finished with
1214
1985
  * it. A no-op, and free, when nothing was forced.
1215
1986
  */
1987
+ /**
1988
+ * The columns of this table that the post-fetch relation loaders read off
1989
+ * each parent row for `withClause`: a belongsTo's foreign key, a hasMany /
1990
+ * hasOne / manyToMany's reference key, and the same for every relation a
1991
+ * `_count` names. An unknown name contributes nothing; the loader raises its
1992
+ * E005.
1993
+ */
1994
+ loaderParentKeyColumns(withClause) {
1995
+ const out = new Set();
1996
+ const add = (rel) => {
1997
+ const cols = rel.type === 'belongsTo' ? rel.foreignKey : rel.referenceKey;
1998
+ for (const c of (0, schema_js_1.normalizeKeyColumns)(cols))
1999
+ out.add(c);
2000
+ };
2001
+ for (const [relName, opt] of Object.entries(withClause)) {
2002
+ if (!opt)
2003
+ continue;
2004
+ if (relName === '_count') {
2005
+ for (const rel of (0, batched_loader_js_1.resolveCountRelations)(this.meta, opt))
2006
+ add(rel);
2007
+ continue;
2008
+ }
2009
+ const rel = (0, utils_js_1.ownLookup)(this.meta.relations, relName);
2010
+ if (rel)
2011
+ add(rel);
2012
+ }
2013
+ return [...out];
2014
+ }
1216
2015
  stripForcedPk(entities, forcedPk) {
1217
2016
  if (forcedPk.length === 0 || entities.length === 0)
1218
2017
  return;
@@ -1353,6 +2152,8 @@ class PowqlInterface {
1353
2152
  if (spec.nulls === 'first') {
1354
2153
  throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST placement', 'PowDB', `field "${field}": PowDB orders NULLs / missing keys LAST in both directions`);
1355
2154
  }
2155
+ this.assertDecimalComparable(this.column(field), 'Ordering');
2156
+ this.assertEnumOrderable(this.column(field), 'Ordering');
1356
2157
  return `${this.ref(field, alias)} ${this.dirKeyword(spec.sort, `orderBy on "${field}"`)}`;
1357
2158
  }
1358
2159
  // Name the actual feature in the refusal, a pick-row ordering
@@ -1369,6 +2170,8 @@ class PowqlInterface {
1369
2170
  : 'object-valued ordering';
1370
2171
  throw new errors_js_1.UnsupportedFeatureError(feature, 'PowDB', `field "${field}"`);
1371
2172
  }
2173
+ this.assertDecimalComparable(this.column(field), 'Ordering');
2174
+ this.assertEnumOrderable(this.column(field), 'Ordering');
1372
2175
  return `${this.ref(field, alias)} ${this.dirKeyword(dir, `orderBy on "${field}"`)}`;
1373
2176
  });
1374
2177
  return ` order ${parts.join(', ')}`;
@@ -1376,7 +2179,7 @@ class PowqlInterface {
1376
2179
  /** Compile one {@link JsonPathOrderBy} entry to `order .col->$n asc` (+ optional numeric cast). */
1377
2180
  buildJsonPathOrder(field, spec, params, alias) {
1378
2181
  const col = this.column(field);
1379
- if (!(0, powdb_shared_js_1.isJsonColumn)(col)) {
2182
+ if (!(0, powdb_shared_js_1.isJsonColumn)(col, this.schema.enums)) {
1380
2183
  throw new errors_js_1.UnsupportedFeatureError('JSON-path ordering', 'PowDB', `field "${field}" is not a json column`);
1381
2184
  }
1382
2185
  (0, powdb_shared_js_1.requireCapability)(this.capabilities, 'jsonDocs', 'JSON path ordering');
@@ -1496,16 +2299,21 @@ class PowqlInterface {
1496
2299
  // `QueryInterface.executeWithMiddleware`. The option surface is the CORE
1497
2300
  // one because these args ARE core's args; PowDB reads them, it does not
1498
2301
  // define them.
1499
- (0, option_surface_js_1.warnUnknownQueryOptions)(this.table, action, args);
2302
+ (0, option_surface_js_1.warnUnknownQueryOptions)(this.table, action, args, this.advice ?? null);
1500
2303
  if (this.middlewares.length === 0)
1501
2304
  return executor();
2305
+ // A copy, as on the SQL engines: the statement is built from `args`, not
2306
+ // from what middleware sees, so `next()` throws on a changed copy rather
2307
+ // than doing nothing.
2308
+ const params = (0, utils_js_1.readOnlyMiddlewareParams)(this.table, action, args);
1502
2309
  let index = 0;
1503
2310
  const next = async (p) => {
2311
+ (0, utils_js_1.assertMiddlewareArgsKept)(params, p);
1504
2312
  if (index < this.middlewares.length)
1505
2313
  return this.middlewares[index++](p, next);
1506
2314
  return executor();
1507
2315
  };
1508
- return next({ model: this.table, action, args: { ...args } });
2316
+ return next(params);
1509
2317
  }
1510
2318
  /** Map raw rows to typed entities. `native` is the wire that ACTUALLY served
1511
2319
  * this result (threaded from {@link execOnce}, not the pool-level capability),
@@ -1515,15 +2323,13 @@ class PowqlInterface {
1515
2323
  * coerces its string cells correctly. Defaults to the pool capability for the
1516
2324
  * rare caller with no per-result flag (hand-built test pools). */
1517
2325
  shape(rows, native = Boolean(this.capabilities.nativeRaw)) {
1518
- return rows.map((r) => (0, powdb_shared_js_1.rowToEntity)(r, this.meta, native));
2326
+ return rows.map((r) => (0, powdb_shared_js_1.rowToEntity)(r, this.meta, native, this.schema.enums));
1519
2327
  }
1520
- // -------------------------------------------------------------------------
1521
- // Reads
1522
- // -------------------------------------------------------------------------
1523
2328
  async findMany(args = {}) {
1524
2329
  this.assertNoForceCustomPlan(args);
2330
+ this.assertNoRowLock(args);
1525
2331
  this.assertNoJsonEncoding(args);
1526
- args = this.normalizeArgs(args);
2332
+ args = this.completePageOrder(this.normalizeArgs(args));
1527
2333
  return this.withMiddleware('findMany', args, async () => {
1528
2334
  // `limit: 0` means "no rows" (SQL `LIMIT 0`), and answering it client-side
1529
2335
  // is correct on every engine version: PowDB's projection fast path returned
@@ -1539,7 +2345,7 @@ class PowqlInterface {
1539
2345
  if (linkPlans.length)
1540
2346
  this.attachLinkRows(entities, linkPlans, native);
1541
2347
  if (residualWith) {
1542
- await this.loadRelations(entities, residualWith, args.timeout, 0, { args, resolvedWhere }, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'));
2348
+ await this.loadRelations(entities, residualWith, args.timeout, 0, { args, resolvedWhere }, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'), args.skipGlobalFilters);
1543
2349
  }
1544
2350
  // LAST: every consumer of the internally-forced PK (relation stitching,
1545
2351
  // the loaders' correlation) has finished with it by here.
@@ -1561,7 +2367,11 @@ class PowqlInterface {
1561
2367
  * to the pre-0.18 output (no alias, `.col` refs).
1562
2368
  */
1563
2369
  async buildFind(args, params) {
1564
- if (args.cursor) {
2370
+ // A cursor is checked with the SQL engines' rules first, so an invalid one
2371
+ // is E003 on every engine; a valid one is an engine gap (E017).
2372
+ const cursor = args.cursor;
2373
+ if (this.cursorCarriesValues(cursor)) {
2374
+ this.assertCursorPlaceable(cursor, args.orderBy);
1565
2375
  throw new errors_js_1.UnsupportedFeatureError('cursor pagination', 'PowDB', 'use limit/offset instead');
1566
2376
  }
1567
2377
  // `distinct` names COLUMNS: Postgres compiles it to `SELECT DISTINCT ON
@@ -1574,16 +2384,18 @@ class PowqlInterface {
1574
2384
  // dialect seam exists to prevent.
1575
2385
  //
1576
2386
  // Refused with the same E017 the other non-Postgres engines already raise
1577
- // (query/builder.ts's `dialect.name !== 'postgresql'` gate) and in the same
2387
+ // (query/builder.ts's `distinctOn` capability gate) and in the same
1578
2388
  // words, so `distinct` now means one thing across every engine and matches
1579
2389
  // what the README documents. Refused BEFORE the column names are resolved,
1580
2390
  // matching that gate's position, so `distinct: ['nope']` reports the
1581
- // unsupported feature rather than the typo on every engine alike.
2391
+ // unsupported feature rather than the typo on every engine alike. The
2392
+ // element shape is checked first, as there: a non-string element is E003.
1582
2393
  if (args.distinct?.length) {
2394
+ (0, filters_js_1.dedupeColumnList)(this.meta, args.distinct);
1583
2395
  throw new errors_js_1.UnsupportedFeatureError('DISTINCT ON (findMany distinct)', 'PowDB', "findMany({ distinct }) requires PostgreSQL: PowQL's `distinct` is row-wide, not per-column. " +
1584
2396
  'Group in application code, or use groupBy({ by }) for one row per combination.');
1585
2397
  }
1586
- const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2398
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout, args.skipGlobalFilters);
1587
2399
  const { cols, forcedPk } = this.projectionPlan(args.select, args.omit, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'));
1588
2400
  // Partition the `with` clause: nested-projection blocks vs loader residue.
1589
2401
  // A relation whose field name collides with a projected parent column stays
@@ -1594,6 +2406,8 @@ class PowqlInterface {
1594
2406
  // with a `distinct` present and testing for it again would be dead code
1595
2407
  // implying the option is still accepted.
1596
2408
  const withClause = args.with;
2409
+ if (withClause)
2410
+ this.assertCountTopLevelOnly(withClause, 0);
1597
2411
  const nestedPlans = [];
1598
2412
  const linkPlans = [];
1599
2413
  let residualWith = withClause;
@@ -1615,7 +2429,7 @@ class PowqlInterface {
1615
2429
  // inside, only when a genuine candidate reaches it), else it stays on the
1616
2430
  // loaders.
1617
2431
  const linkPlan = rel && !cols.includes(relName)
1618
- ? await this.planLinkPathRelation(relName, rel, opt, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'), cols, linkPlans.length + 1)
2432
+ ? await this.planLinkPathRelation(relName, rel, opt, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'), cols, linkPlans.length + 1, args.skipGlobalFilters)
1619
2433
  : null;
1620
2434
  if (linkPlan)
1621
2435
  linkPlans.push(linkPlan);
@@ -1624,17 +2438,34 @@ class PowqlInterface {
1624
2438
  }
1625
2439
  residualWith = Object.keys(residue).length ? residue : undefined;
1626
2440
  }
2441
+ // The loaders (and the native join) read each parent's correlation key off
2442
+ // the fetched row, so a `select` / `omit` that leaves it out would stitch
2443
+ // nothing. Fetch it anyway and take it back off afterwards, as the SQL
2444
+ // engines' batched loader does.
2445
+ if (residualWith) {
2446
+ for (const column of this.loaderParentKeyColumns(residualWith)) {
2447
+ if (cols.includes(column))
2448
+ continue;
2449
+ cols.push(column);
2450
+ forcedPk.push(column);
2451
+ }
2452
+ }
1627
2453
  const nest = nestedPlans.length > 0 || linkPlans.length > 0;
1628
2454
  const alias = nest ? 't0' : undefined;
1629
2455
  let where = this.buildWhere(resolvedWhere, params, alias);
1630
- where = this.applyGlobalFilter(where, params, args.skipGlobalFilters, alias);
2456
+ where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout), alias);
1631
2457
  const filter = where ? ` filter ${where}` : '';
1632
2458
  const order = this.buildOrder(args.orderBy, params, alias);
1633
2459
  const limit = this.effectiveLimit(args);
1634
2460
  this.assertPagination(limit, args.offset, 'findMany');
1635
- if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
1636
- this.warnedUnlimited = true;
1637
- console.warn(`[turbine] findMany on "${this.table}" has no limit: this scans the whole table.`);
2461
+ // Once per table per process, as on the SQL engines: a transaction builds
2462
+ // its own interfaces, so a per-interface flag repeated it in each one.
2463
+ if (limit === undefined && this.warnOnUnlimited && (0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.unlimitedFindMany, this.table)) {
2464
+ (0, logger_js_1.logTo)(this.logger, 'warn', `findMany on "${this.table}" has no limit: this scans the whole table.`, {
2465
+ code: 'unlimited-find-many',
2466
+ table: this.table,
2467
+ operation: 'findMany',
2468
+ });
1638
2469
  }
1639
2470
  const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
1640
2471
  const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
@@ -1645,7 +2476,7 @@ class PowqlInterface {
1645
2476
  const aliasCtr = { n: 1 };
1646
2477
  const parts = cols.map((c) => `${(0, powdb_shared_js_1.quotePowqlIdent)(c)}: t0.${(0, powdb_shared_js_1.quotePowqlIdent)(c)}`);
1647
2478
  for (const plan of nestedPlans) {
1648
- parts.push(await this.buildNestedBlock(plan, 't0', aliasCtr, params, args.timeout));
2479
+ parts.push(await this.buildNestedBlock(plan, 't0', aliasCtr, params, args.timeout, args.skipGlobalFilters));
1649
2480
  }
1650
2481
  // Scalar link-path hops (flat native-typed fields; never a JSON block).
1651
2482
  for (const plan of linkPlans)
@@ -1684,7 +2515,7 @@ class PowqlInterface {
1684
2515
  // written with `take` / `skip` would report a plan for a DIFFERENT
1685
2516
  // statement than the one that executes, which is the one thing a
1686
2517
  // diagnostic must not do.
1687
- args = this.normalizeArgs(args);
2518
+ args = this.completePageOrder(this.normalizeArgs(args));
1688
2519
  const params = [];
1689
2520
  const { powql } = await this.buildFind(args, params);
1690
2521
  const { rows } = await this.exec(`explain ${powql}`, params, args.timeout, 'explain');
@@ -1697,6 +2528,7 @@ class PowqlInterface {
1697
2528
  }
1698
2529
  async findUnique(args) {
1699
2530
  this.assertNoForceCustomPlan(args);
2531
+ this.assertNoRowLock(args);
1700
2532
  this.assertNoJsonEncoding(args);
1701
2533
  args = this.normalizeArgs(args);
1702
2534
  // Prisma compound-unique selector → column conjunction (engine parity with
@@ -1721,17 +2553,21 @@ class PowqlInterface {
1721
2553
  if (linkPlans.length)
1722
2554
  this.attachLinkRows(entities, linkPlans, native);
1723
2555
  if (residualWith)
1724
- await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'));
2556
+ await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'), args.skipGlobalFilters);
1725
2557
  // See findMany: the strip is last, after every consumer of the key.
1726
2558
  this.stripForcedPk(entities, forcedPk);
1727
2559
  return entities[0];
1728
2560
  });
1729
2561
  }
1730
2562
  async findFirst(args = {}) {
1731
- args = this.normalizeArgs(args);
2563
+ args = this.normalizeArgs(args, 'findFirst');
1732
2564
  this.assertNoForceCustomPlan(args);
2565
+ this.assertNoRowLock(args);
1733
2566
  return this.withMiddleware('findFirst', args, async () => {
1734
- const { rows, native, nestedPlans, linkPlans, residualWith, forcedPk } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
2567
+ // An `offset` makes this a page of one, which gets the page tiebreaker
2568
+ // (the row would otherwise depend on which tied rows were skipped).
2569
+ const first = { ...args, limit: 1 };
2570
+ const { rows, native, nestedPlans, linkPlans, residualWith, forcedPk } = await this.runFind(args.offset === undefined ? first : this.completePageOrder(first), 'findFirst');
1735
2571
  if (!rows.length)
1736
2572
  return null;
1737
2573
  const entities = this.shape(rows, native);
@@ -1740,7 +2576,7 @@ class PowqlInterface {
1740
2576
  if (linkPlans.length)
1741
2577
  this.attachLinkRows(entities, linkPlans, native);
1742
2578
  if (residualWith)
1743
- await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'));
2579
+ await this.loadRelations(entities, residualWith, args.timeout, 0, undefined, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'), args.skipGlobalFilters);
1744
2580
  // See findMany: the strip is last, after every consumer of the key.
1745
2581
  this.stripForcedPk(entities, forcedPk);
1746
2582
  return entities[0];
@@ -1753,6 +2589,7 @@ class PowqlInterface {
1753
2589
  return row;
1754
2590
  }
1755
2591
  async findFirstOrThrow(args = {}) {
2592
+ args = this.normalizeArgs(args, 'findFirstOrThrow');
1756
2593
  const row = await this.findFirst(args);
1757
2594
  if (!row)
1758
2595
  throw new errors_js_1.NotFoundError({ table: this.table, where: (args.where ?? {}) });
@@ -1772,10 +2609,30 @@ class PowqlInterface {
1772
2609
  * the default `'batched'` strategy) keeps the loaders. Output is byte-equal
1773
2610
  * either way (the join reuses the same stitch / shape helpers).
1774
2611
  */
1775
- async loadRelations(parents, withClause, timeout, depth = 0, parent, includePii = false) {
2612
+ async loadRelations(parents, withClause, timeout, depth = 0, parent, includePii = false, skip) {
1776
2613
  if (depth >= 10) {
1777
2614
  throw new errors_js_1.ValidationError(`Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
1778
2615
  }
2616
+ // Every relation name and the `_count` spec are checked BEFORE the
2617
+ // zero-parent return, so whether a `with` is accepted never depends on
2618
+ // whether the base query found rows: the SQL engines compile it either way.
2619
+ // Same errors as the SQL engines: E005 for an unknown relation (and for a
2620
+ // `_count` below the top level), E003 for a `_count` naming a to-one.
2621
+ const countSpec = withClause._count;
2622
+ if (countSpec) {
2623
+ if (depth > 0) {
2624
+ throw new errors_js_1.RelationError(`Unknown relation "_count" on table "${this.table}". ` +
2625
+ `${(0, utils_js_1.availableClause)(Object.keys(this.meta.relations), 'It has no relations.')} ` +
2626
+ '(`_count` is supported on the top-level `with` only, on every relationLoadStrategy.)');
2627
+ }
2628
+ (0, batched_loader_js_1.resolveCountRelations)(this.meta, countSpec);
2629
+ }
2630
+ for (const [relName, opt] of Object.entries(withClause)) {
2631
+ if (opt && relName !== '_count' && !(0, utils_js_1.ownLookup)(this.meta.relations, relName)) {
2632
+ throw new errors_js_1.RelationError(`Unknown relation "${relName}" on table "${this.table}". ` +
2633
+ (0, utils_js_1.availableClause)(Object.keys(this.meta.relations), 'It has no relations.'));
2634
+ }
2635
+ }
1779
2636
  if (!parents.length)
1780
2637
  return;
1781
2638
  // The resolved strategy is 'join' only for an EXPLICIT 'join' (per-query arg
@@ -1785,14 +2642,12 @@ class PowqlInterface {
1785
2642
  // key, …) never triggers the capability's E017.
1786
2643
  const strategyIsJoin = parent ? this.resolveStrategy(parent.args) === 'join' : false;
1787
2644
  for (const [relName, opt] of Object.entries(withClause)) {
1788
- if (!opt)
2645
+ if (!opt || relName === '_count')
1789
2646
  continue;
1790
- const rel = this.meta.relations[relName];
1791
- if (!rel)
1792
- throw new errors_js_1.ValidationError(`Unknown relation "${relName}" on "${this.table}".`);
2647
+ const rel = (0, utils_js_1.ownLookup)(this.meta.relations, relName);
1793
2648
  if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args)) {
1794
2649
  if (this.capabilities.serverJoins) {
1795
- await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii);
2650
+ await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii, skip);
1796
2651
  continue;
1797
2652
  }
1798
2653
  // An otherwise-eligible relation the engine cannot join: a PER-QUERY
@@ -1804,7 +2659,7 @@ class PowqlInterface {
1804
2659
  }
1805
2660
  }
1806
2661
  if (rel.type === 'manyToMany') {
1807
- await this.loadManyToMany(parents, rel, relName, opt, timeout, includePii);
2662
+ await this.loadManyToMany(parents, rel, relName, opt, timeout, includePii, skip);
1808
2663
  continue;
1809
2664
  }
1810
2665
  const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
@@ -1859,7 +2714,7 @@ class PowqlInterface {
1859
2714
  if (userSelect) {
1860
2715
  fetchOptions = {
1861
2716
  ...options,
1862
- select: { ...userSelect, [childKeyField]: true },
2717
+ select: (0, batched_loader_js_1.markStitchOnly)({ ...userSelect, [childKeyField]: true }, [childKeyField]),
1863
2718
  };
1864
2719
  }
1865
2720
  else if (userOmit) {
@@ -1892,6 +2747,9 @@ class PowqlInterface {
1892
2747
  // Re-entering the public findMany, so the opt-in has to be spelled
1893
2748
  // in its PUBLIC form (the sentinel); a plain `true` is refused there.
1894
2749
  includePii: includePii ? types_js_1.UNSAFE : undefined,
2750
+ // The query's opt-out reaches the related table, as on the SQL engines;
2751
+ // without one the target's own global filter applies inside findMany.
2752
+ ...(skip !== undefined ? { skipGlobalFilters: skip } : {}),
1895
2753
  }));
1896
2754
  for (const child of children) {
1897
2755
  const k = this.joinKey(child[childKeyField]);
@@ -1920,6 +2778,175 @@ class PowqlInterface {
1920
2778
  : PowqlInterface.pageOf(matches, relLimit, relOffset);
1921
2779
  }
1922
2780
  }
2781
+ // Last, so `_count` is the final key on each row, as on the SQL engines.
2782
+ if (countSpec)
2783
+ await this.loadRelationCounts(parents, countSpec, timeout, skip);
2784
+ }
2785
+ /**
2786
+ * Refuse a `_count` anywhere below the top-level `with`, as the SQL engines
2787
+ * do (E005, their message). Checked over the whole tree before anything runs,
2788
+ * because a loader re-enters the child table's public findMany, where a
2789
+ * nested `_count` would otherwise read as top level and be counted.
2790
+ */
2791
+ assertCountTopLevelOnly(withClause, depth) {
2792
+ if (depth >= 10)
2793
+ return;
2794
+ if (depth > 0 && withClause._count) {
2795
+ throw new errors_js_1.RelationError(`Unknown relation "_count" on table "${this.table}". ` +
2796
+ `${(0, utils_js_1.availableClause)(Object.keys(this.meta.relations), 'It has no relations.')} ` +
2797
+ '(`_count` is supported on the top-level `with` only, on every relationLoadStrategy.)');
2798
+ }
2799
+ for (const [relName, opt] of Object.entries(withClause)) {
2800
+ if (!opt || opt === true || relName === '_count')
2801
+ continue;
2802
+ const nested = opt.with;
2803
+ const rel = (0, utils_js_1.ownLookup)(this.meta.relations, relName);
2804
+ if (!rel || !nested || typeof nested !== 'object' || !this.schema.tables[rel.to])
2805
+ continue;
2806
+ new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions()).assertCountTopLevelOnly(nested, depth + 1);
2807
+ }
2808
+ }
2809
+ /**
2810
+ * The reserved `with: { _count: ... }` key: one count per selected to-many
2811
+ * relation, attached as `_count: { rel: n }` (0 for a parent with no rows),
2812
+ * which is the SQL engines' output. The relations are selected by the shared
2813
+ * `resolveCountRelations`, so `true` counts every to-many relation and the
2814
+ * refusals match. Each count honours the counted table's global filter the
2815
+ * way the SQL engines' correlated `COUNT(*)` does: hasMany counts the
2816
+ * surviving child rows, manyToMany counts junction rows whose TARGET
2817
+ * survives. One grouped query per relation per key chunk.
2818
+ */
2819
+ async loadRelationCounts(parents, countSpec, timeout, skip) {
2820
+ const rels = (0, batched_loader_js_1.resolveCountRelations)(this.meta, countSpec);
2821
+ const countsOf = new Map();
2822
+ for (const parent of parents) {
2823
+ const counts = {};
2824
+ for (const rel of rels)
2825
+ counts[rel.name] = 0;
2826
+ const row = parent;
2827
+ delete row._count;
2828
+ row._count = counts;
2829
+ countsOf.set(parent, counts);
2830
+ }
2831
+ for (const rel of rels) {
2832
+ const { parentKeyField, byKey } = rel.type === 'manyToMany'
2833
+ ? await this.countManyToMany(parents, rel, timeout, skip)
2834
+ : await this.countHasMany(parents, rel, timeout, skip);
2835
+ for (const parent of parents) {
2836
+ const k = this.joinKey(parent[parentKeyField]);
2837
+ countsOf.get(parent)[rel.name] = (k == null ? undefined : byKey.get(k)) ?? 0;
2838
+ }
2839
+ }
2840
+ }
2841
+ /** Child rows per parent key for a hasMany `_count`, through the child's own groupBy. */
2842
+ async countHasMany(parents, rel, timeout, skip) {
2843
+ const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
2844
+ const rk = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
2845
+ if (fk.length > 1 || rk.length > 1) {
2846
+ throw new errors_js_1.UnsupportedFeatureError('composite-key relation _count', 'PowDB', `relation "${rel.name}"`);
2847
+ }
2848
+ const targetMeta = this.schema.tables[rel.to];
2849
+ if (!targetMeta)
2850
+ throw new errors_js_1.ValidationError(`Relation "${rel.name}" targets unknown table "${rel.to}".`);
2851
+ const parentKeyField = this.meta.reverseColumnMap[rk[0]] ?? rk[0];
2852
+ const childKeyField = targetMeta.reverseColumnMap[fk[0]] ?? fk[0];
2853
+ const keys = [
2854
+ ...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
2855
+ ];
2856
+ const byKey = new Map();
2857
+ const childQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
2858
+ const chunkSize = this.keyChunkSize(targetMeta, fk[0]);
2859
+ for (let i = 0; i < keys.length; i += chunkSize) {
2860
+ const groups = (await childQi.groupBy({
2861
+ by: [childKeyField],
2862
+ where: { [childKeyField]: { in: keys.slice(i, i + chunkSize) } },
2863
+ _count: true,
2864
+ timeout,
2865
+ // The key is only a stitch key and never returned, so a PII-tagged
2866
+ // foreign key does not make the count a PII read.
2867
+ includePii: types_js_1.UNSAFE,
2868
+ ...(skip !== undefined ? { skipGlobalFilters: skip } : {}),
2869
+ }));
2870
+ for (const g of groups) {
2871
+ const k = this.joinKey(g[childKeyField]);
2872
+ if (k != null)
2873
+ byKey.set(k, Number(g._count ?? 0));
2874
+ }
2875
+ }
2876
+ return { parentKeyField, byKey };
2877
+ }
2878
+ /**
2879
+ * Junction rows per parent key for a manyToMany `_count`. When the target
2880
+ * table has a global filter in force, only junction rows whose target row
2881
+ * survives it are counted, as the SQL engines' `EXISTS` on the target does.
2882
+ */
2883
+ async countManyToMany(parents, rel, timeout, skip) {
2884
+ const through = rel.through;
2885
+ if (!through)
2886
+ throw new errors_js_1.ValidationError(`manyToMany relation "${rel.name}" is missing its junction (\`through\`).`);
2887
+ const sourceJ = (0, schema_js_1.normalizeKeyColumns)(through.sourceKey);
2888
+ const targetJ = (0, schema_js_1.normalizeKeyColumns)(through.targetKey);
2889
+ const sourceRef = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
2890
+ const targetMeta = this.schema.tables[rel.to];
2891
+ if (!targetMeta)
2892
+ throw new errors_js_1.ValidationError(`Relation "${rel.name}" targets unknown table "${rel.to}".`);
2893
+ if (sourceJ.length > 1 || targetJ.length > 1 || sourceRef.length > 1 || targetMeta.primaryKey.length > 1) {
2894
+ throw new errors_js_1.UnsupportedFeatureError('composite-key relation _count', 'PowDB', `relation "${rel.name}"`);
2895
+ }
2896
+ const parentKeyField = this.meta.reverseColumnMap[sourceRef[0]] ?? sourceRef[0];
2897
+ const keys = [
2898
+ ...new Set(parents.map((p) => p[parentKeyField]).filter((k) => k != null)),
2899
+ ];
2900
+ // (1) Junction rows for these parents.
2901
+ const junctionMeta = this.schema.tables[through.table];
2902
+ const sourceJColMeta = junctionMeta?.columns.find((c) => c.name === sourceJ[0]);
2903
+ const pairs = [];
2904
+ const chunkSize = this.keyChunkSize(junctionMeta, sourceJ[0]);
2905
+ for (let i = 0; i < keys.length; i += chunkSize) {
2906
+ const params = [];
2907
+ const keyClause = this.inClause(this.colRefName(sourceJ[0]), keys.slice(i, i + chunkSize), params, sourceJColMeta);
2908
+ const powql = `${(0, powdb_shared_js_1.quotePowqlIdent)(through.table)} filter ${keyClause} ` +
2909
+ `{ ${this.colRefName(sourceJ[0])}, ${this.colRefName(targetJ[0])} }`;
2910
+ const { rows } = await this.exec(powql, params, timeout, 'findMany');
2911
+ for (const row of rows) {
2912
+ const sk = this.joinKey(row[sourceJ[0]]);
2913
+ const tk = this.joinKey(row[targetJ[0]]);
2914
+ if (sk != null && tk != null)
2915
+ pairs.push([sk, tk, row[targetJ[0]]]);
2916
+ }
2917
+ }
2918
+ // (2) With a target global filter in force, keep the targets that survive it.
2919
+ const gf = (0, where_js_1.resolveGlobalFilterFrom)(this.options.globalFilters, rel.to, (0, types_js_1.resolveSkipGlobalFilters)(skip), this.schema.tables);
2920
+ let surviving = null;
2921
+ if (gf && pairs.length) {
2922
+ surviving = new Set();
2923
+ const pkCol = targetMeta.primaryKey[0];
2924
+ const pkField = targetMeta.reverseColumnMap[pkCol] ?? pkCol;
2925
+ const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.loaderChildOptions());
2926
+ const targets = [...new Map(pairs.map(([, tk, raw]) => [tk, raw])).values()];
2927
+ const targetChunk = this.keyChunkSize(targetMeta, pkCol);
2928
+ for (let i = 0; i < targets.length; i += targetChunk) {
2929
+ const rows = (await targetQi.findMany({
2930
+ where: { [pkField]: { in: targets.slice(i, i + targetChunk) } },
2931
+ select: { [pkField]: true },
2932
+ timeout,
2933
+ includePii: types_js_1.UNSAFE,
2934
+ ...(skip !== undefined ? { skipGlobalFilters: skip } : {}),
2935
+ }));
2936
+ for (const row of rows) {
2937
+ const k = this.joinKey(row[pkField]);
2938
+ if (k != null)
2939
+ surviving.add(k);
2940
+ }
2941
+ }
2942
+ }
2943
+ const byKey = new Map();
2944
+ for (const [sk, tk] of pairs) {
2945
+ if (surviving && !surviving.has(tk))
2946
+ continue;
2947
+ byKey.set(sk, (byKey.get(sk) ?? 0) + 1);
2948
+ }
2949
+ return { parentKeyField, byKey };
1923
2950
  }
1924
2951
  /**
1925
2952
  * manyToMany nested read, a three-hop batched loader (no `json_agg`/join
@@ -1929,7 +2956,7 @@ class PowqlInterface {
1929
2956
  * single-key N+1 loaders; the junction's source/target columns must be single
1930
2957
  * (composite junction keys would need PowQL tuple-`in`, which it lacks).
1931
2958
  */
1932
- async loadManyToMany(parents, rel, relName, opt, timeout, includePii = false) {
2959
+ async loadManyToMany(parents, rel, relName, opt, timeout, includePii = false, skip) {
1933
2960
  const through = rel.through;
1934
2961
  if (!through)
1935
2962
  throw new errors_js_1.ValidationError(`manyToMany relation "${relName}" is missing its junction (\`through\`).`);
@@ -2016,7 +3043,7 @@ class PowqlInterface {
2016
3043
  if (userSelect) {
2017
3044
  fetchOptions = {
2018
3045
  ...options,
2019
- select: { ...userSelect, [targetPkField]: true },
3046
+ select: (0, batched_loader_js_1.markStitchOnly)({ ...userSelect, [targetPkField]: true }, [targetPkField]),
2020
3047
  };
2021
3048
  }
2022
3049
  else if (userOmit) {
@@ -2049,6 +3076,7 @@ class PowqlInterface {
2049
3076
  timeout: options.timeout ?? timeout,
2050
3077
  // Public findMany, so the sentinel form. See loadRelation above.
2051
3078
  includePii: includePii ? types_js_1.UNSAFE : undefined,
3079
+ ...(skip !== undefined ? { skipGlobalFilters: skip } : {}),
2052
3080
  }));
2053
3081
  for (const t of targets) {
2054
3082
  const pk = String(t[targetPkField]);
@@ -2186,9 +3214,9 @@ class PowqlInterface {
2186
3214
  return tableMeta.indexes.some((idx) => idx.unique && !idx.docPath && idx.columns.length === 1 && idx.columns[0] === col);
2187
3215
  }
2188
3216
  /** Dispatch one eligible relation to the correct native-join loader. */
2189
- async loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false) {
3217
+ async loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false, skip) {
2190
3218
  if (rel.type === 'manyToMany') {
2191
- await this.loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii);
3219
+ await this.loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii, skip);
2192
3220
  return;
2193
3221
  }
2194
3222
  const options = (opt === true ? {} : opt);
@@ -2207,7 +3235,7 @@ class PowqlInterface {
2207
3235
  const parentKeyField = this.meta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
2208
3236
  const params = [];
2209
3237
  const { cols: childCols, forcedPk: childForcedPk } = this.joinChildCols(targetQi, options, includePii);
2210
- const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
3238
+ const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout, skip);
2211
3239
  const order = targetQi.buildOrder(options.orderBy, params, 'c');
2212
3240
  // No limit/offset clause: on the join they would bound the TOTAL child
2213
3241
  // count across every parent. The relation's page is per parent (pageOf).
@@ -2233,7 +3261,7 @@ class PowqlInterface {
2233
3261
  * → the already-fetched side (alias `p`), correlating `__tpk` from the
2234
3262
  * junction's source key. Always a list, stitched exactly like the loader.
2235
3263
  */
2236
- async loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false) {
3264
+ async loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false, skip) {
2237
3265
  const through = rel.through;
2238
3266
  if (!through)
2239
3267
  throw new errors_js_1.ValidationError(`manyToMany relation "${relName}" is missing its junction (\`through\`).`);
@@ -2249,7 +3277,7 @@ class PowqlInterface {
2249
3277
  const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
2250
3278
  const params = [];
2251
3279
  const { cols: childCols, forcedPk: childForcedPk } = this.joinChildCols(targetQi, options, includePii);
2252
- const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 't', params, options.timeout ?? timeout);
3280
+ const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 't', params, options.timeout ?? timeout, skip);
2253
3281
  const proj = this.joinProjection(childCols, `j.${(0, powdb_shared_js_1.quotePowqlIdent)(sourceJCol)}`, 't');
2254
3282
  const powql = `${targetQi.qt} as t ` +
2255
3283
  `join ${(0, powdb_shared_js_1.quotePowqlIdent)(through.table)} as j on t.${(0, powdb_shared_js_1.quotePowqlIdent)(targetPkCol)} = j.${(0, powdb_shared_js_1.quotePowqlIdent)(targetJCol)} ` +
@@ -2293,14 +3321,18 @@ class PowqlInterface {
2293
3321
  * to literal in-lists before the base query ran); the relation where is resolved
2294
3322
  * on the target the same way before qualifying, so a nested relation filter in
2295
3323
  * the relation `where` never reaches the join unresolved. Params bind in order.
3324
+ * Each side carries its own table's global filter under the query's `skip`,
3325
+ * so the join returns exactly the rows the loaders would.
2296
3326
  */
2297
- async joinFilter(targetQi, parentResolvedWhere, relWhere, childAlias, params, timeout) {
3327
+ async joinFilter(targetQi, parentResolvedWhere, relWhere, childAlias, params, timeout, skip) {
2298
3328
  const parts = [];
2299
- const pw = this.buildWhere(parentResolvedWhere, params, 'p');
3329
+ const parentGf = await this.globalFilterWhere(skip, timeout);
3330
+ const pw = this.applyGlobalFilter(this.buildWhere(parentResolvedWhere, params, 'p'), params, parentGf, 'p');
2300
3331
  if (pw)
2301
3332
  parts.push(pw);
2302
- const relResolved = await targetQi.resolveRelationFilters(relWhere, timeout);
2303
- const rw = targetQi.buildWhere(relResolved, params, childAlias);
3333
+ const relResolved = await targetQi.resolveRelationFilters(relWhere, timeout, skip);
3334
+ const targetGf = await targetQi.globalFilterWhere(skip, timeout);
3335
+ const rw = targetQi.applyGlobalFilter(targetQi.buildWhere(relResolved, params, childAlias), params, targetGf, childAlias);
2304
3336
  if (rw)
2305
3337
  parts.push(rw);
2306
3338
  return parts.length ? ` filter ${parts.join(' and ')}` : '';
@@ -2442,8 +3474,12 @@ class PowqlInterface {
2442
3474
  * Aliases share the parent statement's counter, so arbitrarily deep and
2443
3475
  * self-referential trees stay collision-free (the same discipline as the SQL
2444
3476
  * engine's json_agg subqueries).
3477
+ *
3478
+ * The target's global filter is ANDed into the block's filter at every level,
3479
+ * under the query's `skip`, exactly as the SQL engines AND it into each
3480
+ * relation subquery: a block that omitted it handed back another tenant's rows.
2445
3481
  */
2446
- async buildNestedBlock(plan, parentAlias, aliasCtr, params, timeout) {
3482
+ async buildNestedBlock(plan, parentAlias, aliasCtr, params, timeout, skip) {
2447
3483
  const { rel, relName, targetQi, options, single } = plan;
2448
3484
  const alias = `t${aliasCtr.n++}`;
2449
3485
  const fk = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey)[0];
@@ -2454,8 +3490,9 @@ class PowqlInterface {
2454
3490
  // child where chains behind it as a parenthesized `and` group, which the
2455
3491
  // grammar accepts even when the group itself contains `or`.
2456
3492
  const correlation = `${alias}.${(0, powdb_shared_js_1.quotePowqlIdent)(childKeyCol)} = ${parentAlias}.${(0, powdb_shared_js_1.quotePowqlIdent)(parentKeyCol)}`;
2457
- const resolved = await targetQi.resolveRelationFilters(options.where, timeout);
2458
- const childWhere = targetQi.buildWhere(resolved, params, alias);
3493
+ const resolved = await targetQi.resolveRelationFilters(options.where, timeout, skip);
3494
+ const targetGf = await targetQi.globalFilterWhere(skip, timeout);
3495
+ const childWhere = targetQi.applyGlobalFilter(targetQi.buildWhere(resolved, params, alias), params, targetGf, alias);
2459
3496
  const filter = childWhere ? `${correlation} and (${childWhere})` : correlation;
2460
3497
  const order = targetQi.buildOrder(options.orderBy, params, alias);
2461
3498
  const limitClause = single
@@ -2466,7 +3503,7 @@ class PowqlInterface {
2466
3503
  const offsetClause = !single && options.offset ? ` offset ${this.param(options.offset, params)}` : '';
2467
3504
  const parts = plan.cols.map((c) => `${(0, powdb_shared_js_1.quotePowqlIdent)(c)}: ${alias}.${(0, powdb_shared_js_1.quotePowqlIdent)(c)}`);
2468
3505
  for (const child of plan.children) {
2469
- parts.push(await targetQi.buildNestedBlock(child, alias, aliasCtr, params, timeout));
3506
+ parts.push(await targetQi.buildNestedBlock(child, alias, aliasCtr, params, timeout, skip));
2470
3507
  }
2471
3508
  return (`${(0, powdb_shared_js_1.quotePowqlIdent)(relName)}: ${targetQi.qt} as ${alias} ` +
2472
3509
  `filter ${filter}${order}${limitClause}${offsetClause} { ${parts.join(', ')} }`);
@@ -2497,7 +3534,7 @@ class PowqlInterface {
2497
3534
  }
2498
3535
  }
2499
3536
  const items = Array.isArray(value) ? value : [];
2500
- const shaped = items.map((o) => (0, powdb_shared_js_1.rowToEntity)(o, plan.targetQi.meta, true));
3537
+ const shaped = items.map((o) => (0, powdb_shared_js_1.rowToEntity)(o, plan.targetQi.meta, true, this.schema.enums));
2501
3538
  for (const child of shaped) {
2502
3539
  for (const sub of plan.children)
2503
3540
  plan.targetQi.attachOneNested(child, sub);
@@ -2581,12 +3618,13 @@ class PowqlInterface {
2581
3618
  * link path would regress a hot path for no gain. Requires: single-column
2582
3619
  * belongsTo; no relation `with` / `where` / `distinct` / `orderBy` /
2583
3620
  * `limit` / `offset` (a scalar path has no per-hop filter/order and cannot
2584
- * reproduce those, such inputs stay on the loader for exact parity); a link
3621
+ * reproduce those, such inputs stay on the loader for exact parity); no
3622
+ * global filter on the target, for the same reason; a link
2585
3623
  * name and all projected columns that are bare identifiers (a quoted segment in
2586
3624
  * a dotted link path is outside the verified spelling, fall back); and a
2587
3625
  * DECLARED link that verifiably matches (`findMatchingLink`).
2588
3626
  */
2589
- async planLinkPathRelation(relName, rel, opt, includePii, parentCols, index) {
3627
+ async planLinkPathRelation(relName, rel, opt, includePii, parentCols, index, skip) {
2590
3628
  if (!this.capabilities.linkPaths)
2591
3629
  return null;
2592
3630
  if (rel.type !== 'belongsTo')
@@ -2603,6 +3641,10 @@ class PowqlInterface {
2603
3641
  if (options.orderBy || options.limit !== undefined || options.offset)
2604
3642
  return null;
2605
3643
  const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
3644
+ // A link hop has no per-hop filter, so it cannot carry the target's global
3645
+ // filter; such a relation stays on the loaders, which apply it.
3646
+ if (targetQi.hasGlobalFilter(skip))
3647
+ return null;
2606
3648
  // `projectionPlan`, not `projectedColumns`: the plan is what carries
2607
3649
  // `forcedPk`, and reading the column LIST alone is what made this path's
2608
3650
  // strip dead. See the pkProjected line below.
@@ -2680,7 +3722,7 @@ class PowqlInterface {
2680
3722
  raw[c] = row[`${plan.keyPrefix}${c}`];
2681
3723
  delete row[`${plan.keyPrefix}${c}`];
2682
3724
  }
2683
- const child = (0, powdb_shared_js_1.rowToEntity)(raw, plan.targetQi.meta, native);
3725
+ const child = (0, powdb_shared_js_1.rowToEntity)(raw, plan.targetQi.meta, native, this.schema.enums);
2684
3726
  if (child[pkField] == null) {
2685
3727
  row[plan.relName] = null;
2686
3728
  continue;
@@ -2731,6 +3773,42 @@ class PowqlInterface {
2731
3773
  }
2732
3774
  return out;
2733
3775
  }
3776
+ /**
3777
+ * Fill every omitted column that declares a default, since PowDB has no
3778
+ * column defaults of its own (its only generated value is `auto`, which is
3779
+ * left to the engine). Without this an omitted defaulted column was written
3780
+ * as NULL, silently, or failed NOT NULL where every SQL engine applied the
3781
+ * default.
3782
+ *
3783
+ * "Omitted" means absent or `undefined` under BOTH spellings, exactly the
3784
+ * columns a SQL insert leaves to the database; an explicit `null` stays
3785
+ * `null`. The value comes from the column's declared default expression,
3786
+ * evaluated per row (`now()` is the insert time). An expression this cannot
3787
+ * evaluate faithfully, or metadata that does not carry the expression, is
3788
+ * refused rather than guessed.
3789
+ */
3790
+ applyColumnDefaults(data) {
3791
+ let out;
3792
+ for (const col of this.meta.columns) {
3793
+ if (!col.hasDefault || col.isGenerated || this.meta.primaryKey.includes(col.name))
3794
+ continue;
3795
+ if (data[col.field] !== undefined || data[col.name] !== undefined)
3796
+ continue;
3797
+ const expr = col.defaultValue;
3798
+ const value = expr == null ? NO_DEFAULT : emulateColumnDefault(expr, col, this.schema.enums);
3799
+ if (value === NO_DEFAULT) {
3800
+ throw new errors_js_1.UnsupportedFeatureError(`The default of column "${col.name}" on "${this.table}"`, 'PowDB', (expr == null
3801
+ ? 'The schema metadata marks the column as defaulted but does not carry the default expression'
3802
+ : `The default expression \`${expr}\` has no client-side equivalent`) +
3803
+ ', and PowDB has no column defaults, so the value cannot be applied. Supply the field explicitly ' +
3804
+ 'on create (or build the metadata with defineSchema, which records literal, now(), current_date and ' +
3805
+ 'gen_random_uuid() defaults).');
3806
+ }
3807
+ out ??= { ...data };
3808
+ out[col.field] = value;
3809
+ }
3810
+ return out ?? data;
3811
+ }
2734
3812
  /**
2735
3813
  * The table name as a PowQL type reference, backtick-quoted when it is a
2736
3814
  * reserved word (e.g. a table named `order`). Used in every emitted
@@ -2740,12 +3818,13 @@ class PowqlInterface {
2740
3818
  return (0, powdb_shared_js_1.quotePowqlIdent)(this.table);
2741
3819
  }
2742
3820
  async create(args) {
3821
+ args = this.normalizeArgs(args);
2743
3822
  return this.withMiddleware('create', args, async () => {
2744
3823
  const plan = this.writeReturnPlan(args);
2745
3824
  if ((0, nested_write_js_1.hasRelationFields)(args.data, this.meta)) {
2746
3825
  return this.nestedCreate(args);
2747
3826
  }
2748
- const data = this.applyPkDefault(args.data);
3827
+ const data = this.applyColumnDefaults(this.applyPkDefault(args.data));
2749
3828
  const assigns = this.scalarData(data);
2750
3829
  const params = [];
2751
3830
  const body = assigns
@@ -2806,16 +3885,28 @@ class PowqlInterface {
2806
3885
  }
2807
3886
  }
2808
3887
  async createMany(args) {
3888
+ args = this.normalizeArgs(args);
2809
3889
  return this.withMiddleware('createMany', args, async () => {
3890
+ if (args.onConflict !== undefined) {
3891
+ throw new errors_js_1.UnsupportedFeatureError('createMany({ onConflict })', 'powdb', "PowQL's upsert statement takes one row. Call upsert() per row inside $transaction.");
3892
+ }
3893
+ // Validated as every engine validates it; it acts only on onConflict's
3894
+ // update, which is refused above.
3895
+ (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
2810
3896
  if (args.skipDuplicates) {
2811
3897
  // PowQL's `insert … returning` has no conflict clause, so there is no
2812
3898
  // faithful skip-duplicates form; refuse rather than silently insert.
2813
3899
  throw new errors_js_1.UnsupportedFeatureError('createMany({ skipDuplicates: true })', 'powdb', 'PowQL insert has no ON CONFLICT DO NOTHING equivalent, filter duplicates before inserting.');
2814
3900
  }
2815
- const inputs = args.data.map((d) => this.applyPkDefault(d));
2816
- if (!inputs.length)
3901
+ // Resolved before the write, so a bad projection writes nothing.
3902
+ const plan = this.writeReturnPlan(args);
3903
+ const supplied = args.data.map((d) => this.applyPkDefault(d));
3904
+ if (!supplied.length)
2817
3905
  return [];
2818
- this.assertUniformCreateManyRows(inputs);
3906
+ // Uniformity is judged on what the caller supplied, as the SQL engines
3907
+ // judge it; defaults are then filled per row.
3908
+ this.assertUniformCreateManyRows(supplied);
3909
+ const inputs = supplied.map((d) => this.applyColumnDefaults(d));
2819
3910
  const params = [];
2820
3911
  const tuples = inputs.map((d) => {
2821
3912
  const assigns = this.scalarData(d);
@@ -2823,10 +3914,11 @@ class PowqlInterface {
2823
3914
  });
2824
3915
  // Multi-row insert with `returning` hands back every inserted row in one round-trip.
2825
3916
  const { rows, native } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout, 'createMany');
2826
- return this.shape(rows, native).map((r) => this.stripWritePii(r));
3917
+ return this.shape(rows, native).map((r) => this.shapeWriteRow(r, plan));
2827
3918
  });
2828
3919
  }
2829
3920
  async update(args) {
3921
+ args = this.normalizeArgs(args);
2830
3922
  return this.withMiddleware('update', args, async () => {
2831
3923
  const plan = this.writeReturnPlan(args);
2832
3924
  if ((0, nested_write_js_1.hasRelationFields)(args.data, this.meta)) {
@@ -2840,13 +3932,13 @@ class PowqlInterface {
2840
3932
  // counts as the key it is (engine parity with the SQL buildUpdate).
2841
3933
  const userWhere = this.expandedWhere(args.where);
2842
3934
  const params = [];
2843
- const resolvedWhere = await this.resolveRelationFilters(userWhere, args.timeout);
3935
+ const resolvedWhere = await this.resolveRelationFilters(userWhere, args.timeout, args.skipGlobalFilters);
2844
3936
  let where = this.buildWhere(resolvedWhere, params);
2845
3937
  // `false` here refused an empty where even WITH the opt-in, while every
2846
3938
  // SQL engine accepted it: verified by probe on buildUpdate/buildDelete.
2847
- this.assertCompiledWhere(where, allowFullTableScan, 'update');
3939
+ this.assertCompiledWhere(where, allowFullTableScan, 'update', userWhere);
2848
3940
  this.assertMutationIdentifiesOneRow(userWhere, allowFullTableScan, 'update');
2849
- where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
3941
+ where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
2850
3942
  let setClause = this.buildUpdateAssignments(args.data, params);
2851
3943
  // Optimistic locking, matching the SQL engines exactly: bump the version
2852
3944
  // column in the SET, add `version = expected` to the filter, and treat
@@ -2881,12 +3973,15 @@ class PowqlInterface {
2881
3973
  });
2882
3974
  }
2883
3975
  async updateMany(args) {
3976
+ args = this.normalizeArgs(args);
3977
+ (0, writes_js_1.assertNoBulkWritePaging)(this.table, 'updateMany', args);
3978
+ this.assertNoOptimisticLock(args, 'updateMany');
2884
3979
  return this.withMiddleware('updateMany', args, async () => {
2885
3980
  const params = [];
2886
- const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
3981
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout, args.skipGlobalFilters);
2887
3982
  let where = this.buildWhere(resolvedWhere, params);
2888
- this.assertCompiledWhere(where, (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'), 'updateMany');
2889
- where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
3983
+ this.assertCompiledWhere(where, (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'), 'updateMany', args.where);
3984
+ where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
2890
3985
  const setClause = this.buildUpdateAssignments(args.data, params);
2891
3986
  const filter = where ? ` filter ${where}` : '';
2892
3987
  const { rowCount } = await this.exec(`${this.qt}${filter} update { ${setClause} }`, params, args.timeout, 'updateMany');
@@ -2902,25 +3997,29 @@ class PowqlInterface {
2902
3997
  if ((0, utils_js_1.resolveRelationDef)(this.meta.relations, field)) {
2903
3998
  throw new errors_js_1.UnsupportedFeatureError('nested writes', 'PowDB', `relation "${field}", nested writes need create()/update()/upsert(), not updateMany()`);
2904
3999
  }
4000
+ // PowQL update assignments take values and the atomic operators only.
4001
+ if ((0, expr_js_1.isSqlExpr)(value) || (0, column_ref_js_1.isColumnRef)(value)) {
4002
+ throw new errors_js_1.UnsupportedFeatureError(`update expressions (expr / col() in update data, "${this.table}.${field}")`, 'powdb', 'Compute the value in JavaScript and write it as a plain value, or use the atomic operators ' +
4003
+ '(increment, decrement, multiply, divide).');
4004
+ }
2905
4005
  const colMeta = this.column(field);
2906
4006
  const ref = this.ref(field);
2907
4007
  const col = (0, powdb_shared_js_1.quotePowqlIdent)(colMeta.name);
2908
- if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
2909
- const opObj = value;
2910
- if ('set' in opObj)
2911
- parts.push(`${col} := ${this.writeRef(opObj.set, colMeta, params)}`);
2912
- else if ('increment' in opObj)
2913
- parts.push(`${col} := ${ref} + ${this.writeRef(opObj.increment, colMeta, params)}`);
2914
- else if ('decrement' in opObj)
2915
- parts.push(`${col} := ${ref} - ${this.writeRef(opObj.decrement, colMeta, params)}`);
2916
- else if ('multiply' in opObj)
2917
- parts.push(`${col} := ${ref} * ${this.writeRef(opObj.multiply, colMeta, params)}`);
2918
- else if ('divide' in opObj)
2919
- parts.push(`${col} := ${ref} / ${this.writeRef(opObj.divide, colMeta, params)}`);
2920
- else
2921
- throw new errors_js_1.ValidationError(`Unsupported update operator on "${field}".`);
4008
+ const op = this.setOperatorOf(colMeta, value);
4009
+ if (op === 'set') {
4010
+ parts.push(`${col} := ${this.writeRef(value.set, colMeta, params)}`);
4011
+ }
4012
+ else if (op !== null) {
4013
+ this.assertDecimalComparable(colMeta, `\`${op}\``);
4014
+ // Same rule and message as the SQL engines' buildSetClauses.
4015
+ const operand = value[op];
4016
+ if (typeof operand !== 'number' || !Number.isFinite(operand)) {
4017
+ throw new errors_js_1.ValidationError(`update operator "${op}" on "${this.table}.${field}" requires a finite number, got ${typeof operand}`);
4018
+ }
4019
+ parts.push(`${col} := ${ref} ${POWQL_ARITHMETIC[op]} ${this.writeRef(operand, colMeta, params)}`);
2922
4020
  }
2923
4021
  else {
4022
+ this.assertBindableSetValue(field, colMeta, value);
2924
4023
  parts.push(`${col} := ${this.writeRef(value, colMeta, params)}`);
2925
4024
  }
2926
4025
  }
@@ -2928,6 +4027,40 @@ class PowqlInterface {
2928
4027
  throw new errors_js_1.ValidationError(`update on "${this.table}" has no fields to set.`);
2929
4028
  return parts.join(', ');
2930
4029
  }
4030
+ /**
4031
+ * The atomic operator an update value names, or `null` for a plain value.
4032
+ * The SQL engines' `setOperatorOf` rule: a plain object with EXACTLY one key
4033
+ * from {@link UPDATE_OPERATOR_KEYS}, and on a json column only `set`, since
4034
+ * an object there IS the document (`{ increment: 1 }` is the document
4035
+ * `{"increment":1}`, and `{ set: doc }` writes a document shaped like
4036
+ * `{ set: ... }`).
4037
+ */
4038
+ setOperatorOf(colMeta, value) {
4039
+ if (!(0, filters_js_1.isUnmatchedPlainObject)(value))
4040
+ return null;
4041
+ const keys = Object.keys(value);
4042
+ if (keys.length !== 1 || !filters_js_1.UPDATE_OPERATOR_KEYS.has(keys[0]))
4043
+ return null;
4044
+ const op = keys[0];
4045
+ if (op !== 'set' && (0, powdb_shared_js_1.isJsonColumn)(colMeta, this.schema.enums))
4046
+ return null;
4047
+ return op;
4048
+ }
4049
+ /**
4050
+ * A one-key plain object that is not an update operator, written to a
4051
+ * non-json column, is a misspelled operator (`{ incremnt: 1 }`): refused with
4052
+ * the SQL engines' message rather than stored as text.
4053
+ */
4054
+ assertBindableSetValue(field, colMeta, value) {
4055
+ if (!(0, filters_js_1.isUnmatchedPlainObject)(value) || (0, powdb_shared_js_1.isJsonColumn)(colMeta, this.schema.enums))
4056
+ return;
4057
+ const keys = Object.keys(value);
4058
+ if (keys.length !== 1 || filters_js_1.UPDATE_OPERATOR_KEYS.has(keys[0]))
4059
+ return;
4060
+ throw new errors_js_1.ValidationError(`Unknown update operator "${keys[0]}" on "${this.table}.${field}". ` +
4061
+ `Supported: ${[...filters_js_1.UPDATE_OPERATOR_KEYS].join(', ')}. ` +
4062
+ 'A plain object is only written as a value on a json/jsonb column.');
4063
+ }
2931
4064
  // -------------------------------------------------------------------------
2932
4065
  // Nested writes, create/update whose `data` carries relation ops (create,
2933
4066
  // connect, connectOrCreate, disconnect, set, delete, update, upsert). Reuses
@@ -2984,7 +4117,9 @@ class PowqlInterface {
2984
4117
  this.pool);
2985
4118
  const ctx = {
2986
4119
  schema: this.schema,
2987
- tx: tx,
4120
+ // The nested engine's internal `updateMany` for `set` reaches the
4121
+ // privilege sentinel through the same seam the SQL engines use.
4122
+ tx: (0, builder_js_1.unlockNestedWriteTx)(tx),
2988
4123
  scopedConnect: this.options?.scopedConnect === true,
2989
4124
  };
2990
4125
  // Plant the single-writer re-entrancy marker for the implicit tx's
@@ -3021,19 +4156,21 @@ class PowqlInterface {
3021
4156
  const tx = {
3022
4157
  table: (name) => new PowqlInterface(this.pool, name, this.schema, this.middlewares, opts),
3023
4158
  };
3024
- return { schema: this.schema, tx: tx };
4159
+ return { schema: this.schema, tx: (0, builder_js_1.unlockNestedWriteTx)(tx) };
3025
4160
  }
3026
4161
  async delete(args) {
4162
+ args = this.normalizeArgs(args);
4163
+ this.assertNoOptimisticLock(args, 'delete');
3027
4164
  return this.withMiddleware('delete', args, async () => {
3028
4165
  const plan = this.writeReturnPlan(args);
3029
4166
  const allowFullTableScan = (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan');
3030
4167
  const userWhere = this.expandedWhere(args.where);
3031
4168
  const params = [];
3032
- const resolvedWhere = await this.resolveRelationFilters(userWhere, args.timeout);
4169
+ const resolvedWhere = await this.resolveRelationFilters(userWhere, args.timeout, args.skipGlobalFilters);
3033
4170
  let where = this.buildWhere(resolvedWhere, params);
3034
- this.assertCompiledWhere(where, allowFullTableScan, 'delete');
4171
+ this.assertCompiledWhere(where, allowFullTableScan, 'delete', userWhere);
3035
4172
  this.assertMutationIdentifiesOneRow(userWhere, allowFullTableScan, 'delete');
3036
- where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
4173
+ where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
3037
4174
  // `returning` hands back the deleted row(s), no separate pre-image reselect needed.
3038
4175
  const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
3039
4176
  const row = rows.length ? this.shapeWriteRow(this.shape(rows, native)[0], plan) : null;
@@ -3043,18 +4180,23 @@ class PowqlInterface {
3043
4180
  });
3044
4181
  }
3045
4182
  async deleteMany(args) {
4183
+ args = this.normalizeArgs(args);
4184
+ (0, writes_js_1.assertNoBulkWritePaging)(this.table, 'deleteMany', args);
4185
+ this.assertNoOptimisticLock(args, 'deleteMany');
3046
4186
  return this.withMiddleware('deleteMany', args, async () => {
3047
4187
  const params = [];
3048
- const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
4188
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout, args.skipGlobalFilters);
3049
4189
  let where = this.buildWhere(resolvedWhere, params);
3050
- this.assertCompiledWhere(where, (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'), 'deleteMany');
3051
- where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
4190
+ this.assertCompiledWhere(where, (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'), 'deleteMany', args.where);
4191
+ where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
3052
4192
  const filter = where ? ` filter ${where}` : '';
3053
4193
  const { rowCount } = await this.exec(`${this.qt}${filter} delete`, params, args.timeout, 'deleteMany');
3054
4194
  return { count: rowCount };
3055
4195
  });
3056
4196
  }
3057
4197
  async upsert(args) {
4198
+ args = this.normalizeArgs(args);
4199
+ this.assertNoOptimisticLock(args, 'upsert');
3058
4200
  return this.withMiddleware('upsert', args, async () => {
3059
4201
  // The single-row rule for `upsert`, shared with the SQL engines through
3060
4202
  // query/compound-unique.ts. PowqlInterface is a PARALLEL implementation,
@@ -3065,7 +4207,7 @@ class PowqlInterface {
3065
4207
  const upsertWhere = (this.expandedWhere(args.where) ?? {});
3066
4208
  this.assertMutationIdentifiesOneRow(upsertWhere, false, 'upsert');
3067
4209
  const plan = this.writeReturnPlan(args);
3068
- const createData = this.applyPkDefault(args.create);
4210
+ const createData = this.applyColumnDefaults(this.applyPkDefault(args.create));
3069
4211
  const pkCol = this.meta.primaryKey[0];
3070
4212
  const conflictColumns = Object.keys(upsertWhere)
3071
4213
  .filter((k) => upsertWhere[k] !== undefined)
@@ -3085,15 +4227,18 @@ class PowqlInterface {
3085
4227
  // as a NotFoundError. The lookup runs its find and its update through the
3086
4228
  // filtered interface, so another tenant's row is invisible to it: the
3087
4229
  // insert then collides on the key and fails, and nothing is overwritten.
3088
- const filtered = this.applyGlobalFilter('', [], args.skipGlobalFilters) !== '';
4230
+ const filtered = this.hasGlobalFilter(args.skipGlobalFilters);
4231
+ // The shape the SQL engines answer with ONE conflict statement (`where`
4232
+ // pinned to `create`, a non-empty scalar update).
4233
+ const conflictShape = (0, compound_unique_js_1.upsertWhereMatchesCreate)(this.meta, upsertWhere, (args.create ?? {})) &&
4234
+ !(0, nested_write_js_1.hasRelationFields)(args.create, this.meta) &&
4235
+ !(0, nested_write_js_1.hasRelationFields)(updateData, this.meta) &&
4236
+ Object.values(updateData).some((v) => v !== undefined);
3089
4237
  const native = this.meta.primaryKey.length === 1 &&
3090
4238
  pkCol !== undefined &&
3091
4239
  conflictColumns.length === 1 &&
3092
4240
  conflictColumns[0] === pkCol &&
3093
- (0, compound_unique_js_1.upsertWhereMatchesCreate)(this.meta, upsertWhere, (args.create ?? {})) &&
3094
- !(0, nested_write_js_1.hasRelationFields)(args.create, this.meta) &&
3095
- !(0, nested_write_js_1.hasRelationFields)(updateData, this.meta) &&
3096
- Object.values(updateData).some((v) => v !== undefined) &&
4241
+ conflictShape &&
3097
4242
  !filtered;
3098
4243
  if (!native) {
3099
4244
  return this.upsertLookupFirst(upsertWhere, createData, updateData, {
@@ -3101,6 +4246,9 @@ class PowqlInterface {
3101
4246
  omit: args.omit,
3102
4247
  skipGlobalFilters: args.skipGlobalFilters,
3103
4248
  timeout: args.timeout,
4249
+ // That statement carries the global filter on its conflict branch, so a
4250
+ // key held by a row the filter hides writes nothing there (E001).
4251
+ hiddenConflictIsNotFound: conflictShape && filtered,
3104
4252
  });
3105
4253
  }
3106
4254
  const params = [];
@@ -3145,7 +4293,7 @@ class PowqlInterface {
3145
4293
  * is refused whether or not the row exists (the SQL engines do the same).
3146
4294
  */
3147
4295
  async upsertLookupFirst(where, createData, updateData, shape) {
3148
- const { skipGlobalFilters, timeout, ...projection } = shape;
4296
+ const { skipGlobalFilters, timeout, hiddenConflictIsNotFound, ...projection } = shape;
3149
4297
  const skip = skipGlobalFilters === undefined ? {} : { skipGlobalFilters };
3150
4298
  const time = timeout === undefined ? {} : { timeout };
3151
4299
  this.scalarData((0, nested_write_js_1.extractRelationFields)(createData, this.meta).scalars);
@@ -3162,6 +4310,21 @@ class PowqlInterface {
3162
4310
  return existing;
3163
4311
  return (await tbl.update({ where, data: updateData, ...projection, ...skip, ...time }));
3164
4312
  }
4313
+ if (hiddenConflictIsNotFound) {
4314
+ // `where` is pinned to `create`, so the insert below collides exactly
4315
+ // when the global filter is what hid the row: say so, as the SQL engines do.
4316
+ const hidden = await tbl.findUnique({ where, skipGlobalFilters: types_js_1.UNSAFE, ...time });
4317
+ if (hidden) {
4318
+ throw new errors_js_1.NotFoundError({
4319
+ table: this.table,
4320
+ where,
4321
+ operation: 'upsert',
4322
+ message: `upsert on "${this.table}" wrote nothing: the row that conflicts on the given key is excluded by the ` +
4323
+ `configured global filter for "${this.table}", so it was neither updated (the filter hides it) nor ` +
4324
+ 'inserted (the key is taken). Pass `skipGlobalFilters: UNSAFE` to address it anyway.',
4325
+ });
4326
+ }
4327
+ }
3165
4328
  return (await tbl.create({ data: createData, ...projection, ...time }));
3166
4329
  };
3167
4330
  return this.isTxScoped() ? run(this.buildNestedCtx()) : this.runInImplicitTx(run);
@@ -3171,11 +4334,12 @@ class PowqlInterface {
3171
4334
  // -------------------------------------------------------------------------
3172
4335
  async count(args = {}) {
3173
4336
  this.assertNoForceCustomPlan(args);
4337
+ args = this.normalizeArgs(args);
3174
4338
  return this.withMiddleware('count', (args ?? {}), async () => {
3175
4339
  const params = [];
3176
- const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
4340
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout, args.skipGlobalFilters);
3177
4341
  let where = this.buildWhere(resolvedWhere, params);
3178
- where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
4342
+ where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
3179
4343
  const filter = where ? ` filter ${where}` : '';
3180
4344
  const { rows } = await this.exec(`count(${this.qt}${filter})`, params, args.timeout, 'count');
3181
4345
  return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
@@ -3235,12 +4399,14 @@ class PowqlInterface {
3235
4399
  }
3236
4400
  async aggregate(args) {
3237
4401
  this.assertNoForceCustomPlan(args);
4402
+ this.assertNoPercentile(args);
4403
+ args = this.normalizeArgs(args);
3238
4404
  return this.withMiddleware('aggregate', args, async () => {
3239
4405
  const result = {};
3240
4406
  const filterParams = [];
3241
- const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
4407
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout, args.skipGlobalFilters);
3242
4408
  let where = this.buildWhere(resolvedWhere, filterParams);
3243
- where = this.applyGlobalFilter(where, filterParams, args.skipGlobalFilters);
4409
+ where = this.applyGlobalFilter(where, filterParams, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
3244
4410
  const filter = where ? ` filter ${where}` : '';
3245
4411
  const plan = [];
3246
4412
  if (args._count) {
@@ -3277,6 +4443,8 @@ class PowqlInterface {
3277
4443
  result[fn] = acc;
3278
4444
  const powfn = fn.slice(1); // sum/avg/min/max
3279
4445
  for (const field of Object.keys(spec).filter((f) => spec[f])) {
4446
+ this.assertDecimalComparable(this.column(field), `\`${fn}\``);
4447
+ this.assertEnumAggregate(this.column(field), fn);
3280
4448
  // Same PII contract as the SQL engines: _min/_max return a stored cell.
3281
4449
  if (fn === '_min' || fn === '_max') {
3282
4450
  (0, aggregates_js_1.assertAggregatePiiOptIn)(this.table, this.meta, field, this.column(field).name, `aggregate ${fn}`, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'));
@@ -3285,6 +4453,8 @@ class PowqlInterface {
3285
4453
  plan.push({
3286
4454
  scalar: `${powfn}(${this.qt}${filter} { ${ref} })`,
3287
4455
  inner: `${powfn}(${ref})`,
4456
+ ...(fn === '_min' || fn === '_max' ? { col: this.column(field) } : {}),
4457
+ ...(fn === '_sum' && isInt8Column(this.column(field)) ? { exact: true } : {}),
3288
4458
  assign: (v) => {
3289
4459
  acc[field] = v;
3290
4460
  },
@@ -3293,10 +4463,16 @@ class PowqlInterface {
3293
4463
  }
3294
4464
  // A scalar cell and a grouped cell decode through the same wire decoder,
3295
4465
  // so one coercion serves both forms.
3296
- const toNumber = (v) => (v == null || v === 'null' ? null : Number(v));
4466
+ const decode = (p, cell, native) => p.col
4467
+ ? decodeAggregateCell(cell, p.col, native, this.schema.enums)
4468
+ : p.exact
4469
+ ? exactIntegerSumCell(cell)
4470
+ : cell == null || cell === 'null'
4471
+ ? null
4472
+ : Number(cell);
3297
4473
  if (plan.length > 1 && this.groupsAggregatesInOneStatement) {
3298
4474
  const proj = plan.map((p, i) => `agg_${i}: ${p.inner}`).join(', ');
3299
- const { rows } = await this.exec(`${this.qt}${filter} group 1 { ${proj} }`, [...filterParams], args.timeout, 'aggregate');
4475
+ const { rows, native } = await this.exec(`${this.qt}${filter} group 1 { ${proj} }`, [...filterParams], args.timeout, 'aggregate');
3300
4476
  // Zero groups means the filter matched no row. The per-field statements
3301
4477
  // below answer that case instead of a synthesized one, because what an
3302
4478
  // aggregate over NO rows returns is engine-versioned (`sum` answered 0
@@ -3305,19 +4481,23 @@ class PowqlInterface {
3305
4481
  const row = rows.length === 1 ? rows[0] : undefined;
3306
4482
  if (row) {
3307
4483
  for (const [i, p] of plan.entries())
3308
- p.assign(toNumber(row[`agg_${i}`]));
4484
+ p.assign(decode(p, row[`agg_${i}`], native));
3309
4485
  return result;
3310
4486
  }
3311
4487
  }
3312
4488
  for (const p of plan) {
3313
- const { rows } = await this.exec(p.scalar, [...filterParams], args.timeout, 'aggregate');
3314
- p.assign(toNumber(rows[0]?.value));
4489
+ const { rows, native } = await this.exec(p.scalar, [...filterParams], args.timeout, 'aggregate');
4490
+ p.assign(decode(p, rows[0]?.value, native));
3315
4491
  }
3316
4492
  return result;
3317
4493
  });
3318
4494
  }
3319
4495
  async groupBy(args) {
4496
+ // The SQL engines' `by` refusals, before anything reads `by`.
4497
+ (0, aggregates_js_1.assertGroupByKeys)(this.table, args?.by);
3320
4498
  this.assertNoForceCustomPlan(args);
4499
+ this.assertNoPercentile(args);
4500
+ args = this.normalizeArgs(args);
3321
4501
  return this.withMiddleware('groupBy', args, async () => {
3322
4502
  // DISTINCT ON has no PowQL equivalent (no DISTINCT ON row source).
3323
4503
  if (args.distinctOn) {
@@ -3338,9 +4518,9 @@ class PowqlInterface {
3338
4518
  // a per-call legacy fallback on a native-capable pool still decodes right.
3339
4519
  const emitNative = Boolean(this.capabilities.nativeRaw);
3340
4520
  const params = [];
3341
- const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
4521
+ const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout, args.skipGlobalFilters);
3342
4522
  let where = this.buildWhere(resolvedWhere, params);
3343
- where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
4523
+ where = this.applyGlobalFilter(where, params, await this.globalFilterWhere(args.skipGlobalFilters, args.timeout));
3344
4524
  const filter = where ? ` filter ${where}` : '';
3345
4525
  // Result-key namespace, mirroring the SQL builder's `claimResultKey`
3346
4526
  // (query/builder.ts): a group-key / aggregate output-name collision (with
@@ -3362,6 +4542,8 @@ class PowqlInterface {
3362
4542
  for (const entry of args.by) {
3363
4543
  if (typeof entry === 'string') {
3364
4544
  const col = this.column(entry);
4545
+ // Text grouping would put '7.5' and '7.50' in different groups.
4546
+ this.assertDecimalComparable(col, 'A groupBy key');
3365
4547
  (0, aggregates_js_1.assertAggregatePiiOptIn)(this.table, this.meta, entry, col.name, 'groupBy `by` key', (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'));
3366
4548
  // Keyed by the canonical FIELD, never by whichever of the column's
3367
4549
  // two legal spellings the caller wrote (`column` accepts both), so
@@ -3378,7 +4560,7 @@ class PowqlInterface {
3378
4560
  }
3379
4561
  else {
3380
4562
  const col = this.column(entry.field);
3381
- if (!(0, powdb_shared_js_1.isJsonColumn)(col)) {
4563
+ if (!(0, powdb_shared_js_1.isJsonColumn)(col, this.schema.enums)) {
3382
4564
  throw new errors_js_1.ValidationError(`groupBy JSON group key on "${entry.field}" (table "${this.table}") requires a json column.`);
3383
4565
  }
3384
4566
  (0, aggregates_js_1.assertAggregatePiiOptIn)(this.table, this.meta, entry.field, col.name, 'groupBy JSON `by` key', (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'));
@@ -3405,6 +4587,7 @@ class PowqlInterface {
3405
4587
  // Aggregates: `agg_N` internal aliases (PowQL rejects reserved-word
3406
4588
  // aliases like `count:`). `aggInner` lets HAVING re-emit the exact inner
3407
4589
  // expression by user key; `aggOrderExprs` lets orderBy reference the alias.
4590
+ // `col` is set for a column `_min` / `_max`: a stored cell, decoded by its column.
3408
4591
  const aggReaders = [];
3409
4592
  const aggOrderExprs = new Map();
3410
4593
  const aggInner = new Map();
@@ -3470,6 +4653,8 @@ class PowqlInterface {
3470
4653
  const alias = `agg_${aggN++}`;
3471
4654
  if (target === true) {
3472
4655
  const col = this.column(key);
4656
+ this.assertDecimalComparable(col, `\`${fn}\``);
4657
+ this.assertEnumAggregate(col, fn);
3473
4658
  if (fn === '_min' || fn === '_max') {
3474
4659
  (0, aggregates_js_1.assertAggregatePiiOptIn)(this.table, this.meta, key, col.name, `groupBy ${fn}`, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii'));
3475
4660
  }
@@ -3478,13 +4663,19 @@ class PowqlInterface {
3478
4663
  proj.push(`${alias}: ${powfn}(${inner})`);
3479
4664
  // Canonical field, so the result bucket and the keys `orderBy` /
3480
4665
  // `having` accept are the same whichever spelling was requested.
3481
- aggReaders.push({ alias, outKey: `${fn}:${col.field}`, numeric: true });
4666
+ aggReaders.push({
4667
+ alias,
4668
+ outKey: `${fn}:${col.field}`,
4669
+ numeric: fn === '_sum' || fn === '_avg',
4670
+ ...(fn === '_min' || fn === '_max' ? { col } : {}),
4671
+ ...(fn === '_sum' && isInt8Column(col) ? { exact: true } : {}),
4672
+ });
3482
4673
  aggOrderExprs.set(`${fn}:${col.field}`, `.${alias}`);
3483
4674
  aggInner.set(col.field, inner);
3484
4675
  }
3485
4676
  else {
3486
4677
  const col = this.column(target.field);
3487
- if (!(0, powdb_shared_js_1.isJsonColumn)(col)) {
4678
+ if (!(0, powdb_shared_js_1.isJsonColumn)(col, this.schema.enums)) {
3488
4679
  throw new errors_js_1.ValidationError(`groupBy ${fn} target "${key}" on "${target.field}" (table "${this.table}") requires a json column.`);
3489
4680
  }
3490
4681
  if (fn === '_min' || fn === '_max') {
@@ -3543,7 +4734,15 @@ class PowqlInterface {
3543
4734
  }
3544
4735
  for (const a of aggReaders) {
3545
4736
  const cell = raw[a.alias];
3546
- const v = cell == null || cell === 'null' ? null : a.numeric ? Number(cell) : cell;
4737
+ const v = a.col
4738
+ ? decodeAggregateCell(cell, a.col, resultNative, this.schema.enums)
4739
+ : a.exact
4740
+ ? exactIntegerSumCell(cell)
4741
+ : cell == null || cell === 'null'
4742
+ ? null
4743
+ : a.numeric
4744
+ ? Number(cell)
4745
+ : cell;
3547
4746
  if (a.outKey === '_count')
3548
4747
  out._count = v ?? 0;
3549
4748
  else {
@@ -3617,6 +4816,11 @@ class PowqlInterface {
3617
4816
  `Supported: ${[...POWQL_HAVING_AGG_FNS.keys()].join(', ')}.`);
3618
4817
  }
3619
4818
  const inner = this.lookupGroupKey(aggInner, key) ?? this.ref(key);
4819
+ const column = this.meta.columns.find((c) => c.field === key || c.name === key);
4820
+ if (column && fn !== 'count')
4821
+ this.assertDecimalComparable(column, `\`${aggKey}\` in having`);
4822
+ if (column && fn !== 'count')
4823
+ this.assertEnumAggregate(column, aggKey);
3620
4824
  conds.push(cmp(`${fn}(${inner})`, filter));
3621
4825
  }
3622
4826
  }
@@ -3679,6 +4883,9 @@ class PowqlInterface {
3679
4883
  if (!expr) {
3680
4884
  throw new errors_js_1.ValidationError(`Unknown field "${key}" in groupBy orderBy on table "${this.table}". Orderable keys: ${validKeys()}.`);
3681
4885
  }
4886
+ const keyColumn = this.meta.columns.find((c) => c.field === key || c.name === key);
4887
+ if (keyColumn)
4888
+ this.assertEnumOrderable(keyColumn, 'Ordering a groupBy');
3682
4889
  parts.push(`${expr} ${this.groupOrderDir(value, key)}`);
3683
4890
  }
3684
4891
  return parts.length ? ` order ${parts.join(', ')}` : '';
@@ -3701,15 +4908,68 @@ class PowqlInterface {
3701
4908
  async *findManyStream() {
3702
4909
  throw new errors_js_1.UnsupportedFeatureError('cursor streaming (findManyStream)', 'PowDB', 'PowDB has no server-side cursor; page with findMany({ limit, offset }) instead');
3703
4910
  }
3704
- // The batch-yielding sibling needs its OWN stub. `PowqlInterface` is a
3705
- // parallel implementation of QueryInterface's surface rather than a subclass,
3706
- // so a method that exists on one and not the other is not a type error here:
3707
- // it is `undefined` at runtime, and the caller gets `TypeError: not a
3708
- // function` instead of the typed E017 that tells them what to do instead.
3709
4911
  // biome-ignore lint/correctness/useYield: intentionally throws before yielding, PowDB has no server cursor.
3710
4912
  async *findManyStreamBatches() {
3711
4913
  throw new errors_js_1.UnsupportedFeatureError('cursor streaming (findManyStreamBatches)', 'PowDB', 'PowDB has no server-side cursor; page with findMany({ limit, offset }) instead');
3712
4914
  }
4915
+ // Deferred queries. `db.pipeline()` and `$transaction([...])` take a
4916
+ // prebuilt statement, and a PowDB read is several round trips (relation
4917
+ // filters resolve first, loaders run after), so there is no single statement
4918
+ // to hand over. Each stub throws the typed refusal when called, which is
4919
+ // before the pipeline or transaction starts.
4920
+ deferredUnsupported(method) {
4921
+ throw new errors_js_1.UnsupportedFeatureError(`deferred query (${method})`, 'PowDB', 'db.pipeline() and $transaction([...]) take deferred queries; run the calls inside ' +
4922
+ 'db.$transaction(async (tx) => ...) instead');
4923
+ }
4924
+ buildFindMany(_args) {
4925
+ return this.deferredUnsupported('buildFindMany');
4926
+ }
4927
+ buildFindFirst(_args) {
4928
+ return this.deferredUnsupported('buildFindFirst');
4929
+ }
4930
+ buildFindFirstOrThrow(_args) {
4931
+ return this.deferredUnsupported('buildFindFirstOrThrow');
4932
+ }
4933
+ buildFindUnique(_args) {
4934
+ return this.deferredUnsupported('buildFindUnique');
4935
+ }
4936
+ buildFindUniqueOrThrow(_args) {
4937
+ return this.deferredUnsupported('buildFindUniqueOrThrow');
4938
+ }
4939
+ buildCount(_args) {
4940
+ return this.deferredUnsupported('buildCount');
4941
+ }
4942
+ buildAggregate(_args) {
4943
+ return this.deferredUnsupported('buildAggregate');
4944
+ }
4945
+ buildGroupBy(_args) {
4946
+ return this.deferredUnsupported('buildGroupBy');
4947
+ }
4948
+ buildCreate(_args) {
4949
+ return this.deferredUnsupported('buildCreate');
4950
+ }
4951
+ buildCreateMany(_args) {
4952
+ return this.deferredUnsupported('buildCreateMany');
4953
+ }
4954
+ buildUpdate(_args) {
4955
+ return this.deferredUnsupported('buildUpdate');
4956
+ }
4957
+ buildUpdateMany(_args) {
4958
+ return this.deferredUnsupported('buildUpdateMany');
4959
+ }
4960
+ buildDelete(_args) {
4961
+ return this.deferredUnsupported('buildDelete');
4962
+ }
4963
+ buildDeleteMany(_args) {
4964
+ return this.deferredUnsupported('buildDeleteMany');
4965
+ }
4966
+ buildUpsert(_args) {
4967
+ return this.deferredUnsupported('buildUpsert');
4968
+ }
4969
+ /** SQL template cache counters. PowDB keeps no statement cache, so every counter is zero. */
4970
+ cacheStats() {
4971
+ return { hits: 0, misses: 0, hitRate: 0, size: 0 };
4972
+ }
3713
4973
  // -------------------------------------------------------------------------
3714
4974
  // Reselect helper (upsert only, PowDB's upsert has no `returning`)
3715
4975
  // -------------------------------------------------------------------------
@@ -3726,15 +4986,6 @@ class PowqlInterface {
3726
4986
  });
3727
4987
  return rows.length ? this.shape(rows, native)[0] : null;
3728
4988
  }
3729
- /**
3730
- * Empty-where guard, blocks accidental whole-table writes. Mirrors the SQL
3731
- * path's `assertMutationHasPredicate` (query/builder.ts): it gates on the
3732
- * *compiled* PowQL filter fragment, NOT the shape of the `where` object. A
3733
- * `where` whose conditions all evaporate during compilation, `{}`,
3734
- * `{ id: undefined }`, `{ OR: [] }`, `{ AND: [] }`, `{ NOT: {} }`,
3735
- * `{ OR: [{ f: undefined }] }`, compiles to the empty string and is refused,
3736
- * because emitting a filter-less write would hit every row.
3737
- */
3738
4989
  /**
3739
4990
  * AND this table's configured global filter (soft-delete / multi-tenancy)
3740
4991
  * onto an ALREADY-COMPILED user filter, pushing its params after the user's.
@@ -3754,8 +5005,7 @@ class PowqlInterface {
3754
5005
  * `alias` is forwarded so the nested/native-join paths qualify the filter's
3755
5006
  * column references the same way they qualify the user's.
3756
5007
  */
3757
- applyGlobalFilter(compiledUserWhere, params, skip, alias) {
3758
- const gf = (0, where_js_1.resolveGlobalFilterFrom)(this.options.globalFilters, this.table, (0, types_js_1.resolveSkipGlobalFilters)(skip));
5008
+ applyGlobalFilter(compiledUserWhere, params, gf, alias) {
3759
5009
  if (!gf)
3760
5010
  return compiledUserWhere;
3761
5011
  const gfWhere = this.buildWhere(gf, params, alias);
@@ -3765,17 +5015,312 @@ class PowqlInterface {
3765
5015
  return gfWhere;
3766
5016
  return `(${compiledUserWhere}) and (${gfWhere})`;
3767
5017
  }
3768
- assertCompiledWhere(compiledWhere, allow, action) {
5018
+ /**
5019
+ * Tables whose global filter is being resolved by an operation above this
5020
+ * interface. A relation filter inside a global filter runs its own query on
5021
+ * the related table, which applies THAT table's filter, so two filters that
5022
+ * reach each other through relations would resolve forever; each table's
5023
+ * filter applies once per chain instead.
5024
+ */
5025
+ get globalFilterStack() {
5026
+ return this.options._globalFilterStack ?? [];
5027
+ }
5028
+ /** Whether this table's global filter constrains an operation under `skip`. */
5029
+ hasGlobalFilter(skip) {
5030
+ if (this.globalFilterStack.includes(this.table))
5031
+ return false;
5032
+ const gf = (0, where_js_1.resolveGlobalFilterFrom)(this.options.globalFilters, this.table, (0, types_js_1.resolveSkipGlobalFilters)(skip), this.schema.tables);
5033
+ return gf != null && !this.compilesToNothing(gf);
5034
+ }
5035
+ /**
5036
+ * This table's global filter for one operation, ready for
5037
+ * {@link applyGlobalFilter}: read under SQL three-valued logic, with every
5038
+ * relation filter in it (`posts: { some: ... }`, `user: { is: ... }`)
5039
+ * resolved to a literal key list by the same client-side resolution a
5040
+ * caller's `where` gets. The related table's own global filter bounds those
5041
+ * lookups, as on the SQL engines, and `skip` reaches them too.
5042
+ */
5043
+ async globalFilterWhere(skip, timeout) {
5044
+ const stack = this.globalFilterStack;
5045
+ if (stack.includes(this.table))
5046
+ return undefined;
5047
+ const gf = (0, where_js_1.resolveGlobalFilterFrom)(this.options.globalFilters, this.table, (0, types_js_1.resolveSkipGlobalFilters)(skip), this.schema.tables);
5048
+ if (!gf)
5049
+ return undefined;
5050
+ if (!this.namesRelation(gf))
5051
+ return this.sqlTrueWhere(gf);
5052
+ const scoped = new PowqlInterface(this.pool, this.table, this.schema, [], {
5053
+ ...this.loaderChildOptions(),
5054
+ _globalFilterStack: [...stack, this.table],
5055
+ });
5056
+ return scoped.resolveRelationFilters(gf, timeout, skip);
5057
+ }
5058
+ /** Whether `where` holds a relation filter at any combinator depth. */
5059
+ namesRelation(where) {
5060
+ if (where === null || typeof where !== 'object' || Array.isArray(where))
5061
+ return false;
5062
+ for (const [key, value] of Object.entries(where)) {
5063
+ if (value === undefined)
5064
+ continue;
5065
+ if (isCombinatorKey(key)) {
5066
+ const branches = (0, where_compile_js_1.combinatorBranches)(key, value, this.table);
5067
+ if (branches?.some((w) => this.namesRelation(w)))
5068
+ return true;
5069
+ }
5070
+ else if ((0, utils_js_1.resolveRelationDef)(this.meta.relations, key)) {
5071
+ return true;
5072
+ }
5073
+ }
5074
+ return false;
5075
+ }
5076
+ /**
5077
+ * Empty-where guard, blocks accidental whole-table writes. Mirrors the SQL
5078
+ * path's `assertMutationHasPredicate` (query/where.ts): it gates on the
5079
+ * *compiled* PowQL filter fragment, NOT the shape of the `where` object, so a
5080
+ * `where` whose conditions all evaporate during compilation (`{}`,
5081
+ * `{ id: undefined }`, `{ AND: [] }`, `{ NOT: {} }`) is refused. A where that
5082
+ * compiles but is TRUE for every row (`NOT: { OR: [] }`, `id: { notIn: [] }`)
5083
+ * writes every row just the same, so `userWhere`, the caller's where before
5084
+ * relation filters were resolved, is folded by the SQL engines' rule
5085
+ * (`foldWhereFor`) and refused too.
5086
+ *
5087
+ * The fold also reads the schema's NOT NULL columns, as the SQL guard does:
5088
+ * `{ name: { not: null } }` or `NOT: { name: null }` on a NOT NULL column is
5089
+ * TRUE for every row (PowDB's `required` holds the same guarantee). It does
5090
+ * NOT trust foreign keys: PowDB declares none, so an orphan row is possible
5091
+ * and a belongsTo `is: {}` stays a condition.
5092
+ */
5093
+ assertCompiledWhere(compiledWhere, allow, action, userWhere) {
3769
5094
  if (allow === true)
3770
5095
  return;
3771
- if (compiledWhere.length > 0)
5096
+ if (compiledWhere.length > 0 &&
5097
+ (userWhere === undefined ||
5098
+ (0, where_compile_js_1.foldWhereFor)(this.schema.tables, this.table, userWhere, POWDB_GUARD_FOLD) !== 'true')) {
3772
5099
  return;
3773
- throw new errors_js_1.ValidationError(`${action} on "${this.table}" refused: the \`where\` clause is empty. ` +
3774
- "Pass `allowFullTableScan: UNSAFE` to opt in (import { UNSAFE } from 'turbine-orm'), " +
3775
- 'or check that your filter values are defined.');
5100
+ }
5101
+ throw new errors_js_1.ValidationError((0, where_js_1.emptyMutationWhereMessage)(action, this.table));
3776
5102
  }
3777
5103
  }
3778
5104
  exports.PowqlInterface = PowqlInterface;
5105
+ /**
5106
+ * The mass-mutation guard's fold options on PowDB (see `assertCompiledWhere`):
5107
+ * NOT NULL columns count, foreign keys do not (PowDB enforces none).
5108
+ */
5109
+ const POWDB_GUARD_FOLD = {
5110
+ notNull: { foreignKeys: false, hasGlobalFilter: () => true },
5111
+ };
5112
+ /** Marks a default expression {@link emulateColumnDefault} cannot evaluate. */
5113
+ const NO_DEFAULT = Symbol('turbine.powdb.noDefault');
5114
+ /**
5115
+ * Evaluate a column's SQL default expression client-side, for one insert.
5116
+ *
5117
+ * Covers what `defineSchema` accepts (NULL, TRUE / FALSE, numeric and quoted
5118
+ * string literals, now() / current_timestamp, current_date, gen_random_uuid())
5119
+ * plus the trailing `::type` cast and wrapping parentheses introspection adds.
5120
+ * Anything else returns {@link NO_DEFAULT}: a guessed value would be a silent
5121
+ * divergence from the engine the schema was written for.
5122
+ */
5123
+ function emulateColumnDefault(expr, col, enums) {
5124
+ let e = expr.trim();
5125
+ for (;;) {
5126
+ const unwrapped = e.replace(/::[a-z_][a-z0-9_ ]*(\(\d+(,\s*\d+)?\))?(\[\])?$/i, '').trim();
5127
+ const stripped = /^\((.*)\)$/s.test(unwrapped) ? unwrapped.slice(1, -1).trim() : unwrapped;
5128
+ if (stripped === e)
5129
+ break;
5130
+ e = stripped;
5131
+ }
5132
+ const ts = (0, powdb_shared_js_1.baseTsType)(col.tsType);
5133
+ const upper = e.toUpperCase();
5134
+ if (upper === 'NULL')
5135
+ return null;
5136
+ if (upper === 'TRUE' || upper === 'FALSE')
5137
+ return ts === 'boolean' ? upper === 'TRUE' : NO_DEFAULT;
5138
+ if (['NOW()', 'CURRENT_TIMESTAMP', 'LOCALTIMESTAMP', 'TRANSACTION_TIMESTAMP()', 'STATEMENT_TIMESTAMP()'].includes(upper)) {
5139
+ return ts === 'Date' ? new Date() : NO_DEFAULT;
5140
+ }
5141
+ if (upper === 'CURRENT_DATE') {
5142
+ if (ts !== 'Date')
5143
+ return NO_DEFAULT;
5144
+ const now = new Date();
5145
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
5146
+ }
5147
+ if (upper === 'GEN_RANDOM_UUID()' || upper === 'UUID_GENERATE_V4()')
5148
+ return ts === 'string' ? (0, node_crypto_1.randomUUID)() : NO_DEFAULT;
5149
+ const quoted = /^'((?:[^']|'')*)'$/s.exec(e);
5150
+ const literal = quoted ? quoted[1].replace(/''/g, "'") : /^-?\d+(\.\d+)?$/.test(e) ? e : undefined;
5151
+ if (literal === undefined)
5152
+ return NO_DEFAULT;
5153
+ // An enum default is one of its labels, stored as the label text.
5154
+ const labels = (0, powdb_shared_js_1.powdbEnumLabels)(col, enums);
5155
+ if (labels)
5156
+ return labels.includes(literal) ? literal : NO_DEFAULT;
5157
+ if ((0, powdb_shared_js_1.isJsonColumn)(col, enums)) {
5158
+ try {
5159
+ return JSON.parse(literal);
5160
+ }
5161
+ catch {
5162
+ return NO_DEFAULT;
5163
+ }
5164
+ }
5165
+ switch (ts) {
5166
+ case 'string':
5167
+ return literal;
5168
+ case 'number': {
5169
+ const n = Number(literal);
5170
+ if (literal.trim() === '' || !Number.isFinite(n))
5171
+ return NO_DEFAULT;
5172
+ // An int8 default past 2^53 keeps every digit, as the column does.
5173
+ return isInt8Column(col) && /^-?\d+$/.test(literal) && !Number.isSafeInteger(n) ? BigInt(literal) : n;
5174
+ }
5175
+ case 'bigint':
5176
+ return /^-?\d+$/.test(literal) ? BigInt(literal) : NO_DEFAULT;
5177
+ case 'boolean': {
5178
+ const b = literal.toLowerCase();
5179
+ if (['t', 'true', 'y', 'yes', 'on', '1'].includes(b))
5180
+ return true;
5181
+ if (['f', 'false', 'n', 'no', 'off', '0'].includes(b))
5182
+ return false;
5183
+ return NO_DEFAULT;
5184
+ }
5185
+ case 'Date': {
5186
+ // A zone-less literal is UTC, as Turbine reads a zone-less timestamp.
5187
+ const zoned = /^\d{4}-\d\d-\d\d$/.test(literal) || /([zZ]|[+-]\d\d(:?\d\d)?)$/.test(literal);
5188
+ const d = new Date(zoned ? literal : `${literal.replace(' ', 'T')}Z`);
5189
+ return quoted && !Number.isNaN(d.getTime()) ? d : NO_DEFAULT;
5190
+ }
5191
+ default:
5192
+ return NO_DEFAULT;
5193
+ }
5194
+ }
5195
+ /**
5196
+ * Decode a `_min` / `_max` cell, which is a stored value of `col`, through the
5197
+ * same per-column coercion a row read uses: a string stays a string, a Date
5198
+ * column's micros become a Date, a bigint keeps its digits. Reading it as a
5199
+ * number turned every text minimum into null and every date into micros.
5200
+ */
5201
+ /**
5202
+ * Whether `col` is a 64-bit integer column (`int8` / `bigint`), the type whose
5203
+ * `SUM` PostgreSQL widens to `numeric` and the SQL engines return as exact
5204
+ * decimal text rather than a JS number (query/aggregates.ts,
5205
+ * `isExactNumericType`). A column read from a live PowDB catalog reports the
5206
+ * engine's `int` and is not recognised, so its sum stays a number.
5207
+ */
5208
+ function isInt8Column(col) {
5209
+ const pgType = col.pgType ?? '';
5210
+ const paren = pgType.indexOf('(');
5211
+ const base = (paren === -1 ? pgType : pgType.slice(0, paren)).trim().toLowerCase();
5212
+ return base === 'int8' || base === 'bigint';
5213
+ }
5214
+ /**
5215
+ * A `_sum` over an int8 column, as PostgreSQL returns it: the exact decimal
5216
+ * text, so a total past 2^53 keeps every digit. The native wire delivers the
5217
+ * engine's i64 as a `bigint` and the legacy wire as text; both are exact.
5218
+ */
5219
+ function exactIntegerSumCell(cell) {
5220
+ if (cell === undefined || cell === null || cell === 'null')
5221
+ return null;
5222
+ if (typeof cell === 'bigint')
5223
+ return cell.toString();
5224
+ if (typeof cell === 'number' && Number.isInteger(cell))
5225
+ return BigInt(cell).toString();
5226
+ return String(cell);
5227
+ }
5228
+ /**
5229
+ * Whole-document equality on a json column (a bare object or array, or
5230
+ * `equals` with no `path`): PowQL has no document comparison, so it is an
5231
+ * engine gap (E017) on every spelling, never a value bound as data.
5232
+ */
5233
+ function jsonDocumentEqualityUnsupported(col) {
5234
+ return new errors_js_1.UnsupportedFeatureError('JSON document equality (equals without path)', 'PowDB', `column "${col.name}": pass a \`path\` to compare a specific json value; PowQL has no whole-document comparison`);
5235
+ }
5236
+ const INT64_MIN = -(2n ** 63n);
5237
+ const INT64_MAX = 2n ** 63n - 1n;
5238
+ /**
5239
+ * The value integer text names for an integer column: a number when it is a
5240
+ * safe integer, else a bigint, so an int8 past 2^53 keeps every digit. Text
5241
+ * that is not an integer is refused (E003), as PostgreSQL refuses it.
5242
+ */
5243
+ function integerTextValue(text, col, table) {
5244
+ const trimmed = text.trim();
5245
+ const shown = JSON.stringify(text.length > 40 ? `${text.slice(0, 40)}...` : text);
5246
+ if (!/^[+-]?\d+$/.test(trimmed)) {
5247
+ throw new errors_js_1.ValidationError(`Invalid value ${shown} for the integer column "${col.name}" on "${table}": expected an integer. ` +
5248
+ "Pass a number, a bigint, or integer text such as '42'.");
5249
+ }
5250
+ const big = BigInt(trimmed);
5251
+ if (big < INT64_MIN || big > INT64_MAX) {
5252
+ throw new errors_js_1.ValidationError(`Invalid value ${shown} for the integer column "${col.name}" on "${table}": it is outside the 64-bit range.`);
5253
+ }
5254
+ const n = Number(big);
5255
+ return Number.isSafeInteger(n) ? n : big;
5256
+ }
5257
+ /** ISO 8601 date or date-time text: date, optional time and fraction, optional zone. */
5258
+ const ISO_DATE_TEXT = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?$/i;
5259
+ /**
5260
+ * The epoch micros a Date column stores for `text`, read the way PostgreSQL
5261
+ * reads the same text against a `date` / `timestamp` column, so
5262
+ * `{ birthday: '2000-02-29' }` matches what `{ birthday: new Date(...) }` does.
5263
+ *
5264
+ * - A `date` column takes the calendar day and ignores any time or zone
5265
+ * (PostgreSQL's date input does the same), at UTC midnight, which is how
5266
+ * Turbine reads and writes a date.
5267
+ * - Any other Date column takes the full instant; text with no zone is UTC,
5268
+ * Turbine's zone-less convention. Up to microsecond precision is kept.
5269
+ *
5270
+ * Only ISO 8601 text is read. PostgreSQL also accepts words (`now`,
5271
+ * `infinity`) and other layouts; those are refused (E003) rather than guessed.
5272
+ */
5273
+ function isoTextMicros(text, col, table) {
5274
+ const refuse = (why) => new errors_js_1.ValidationError(`Invalid date text ${JSON.stringify(text.length > 40 ? `${text.slice(0, 40)}...` : text)} for the Date ` +
5275
+ `column "${col.name}" on "${table}": ${why}. PowDB stores the column as epoch microseconds, so text is ` +
5276
+ 'read as ISO 8601 (YYYY-MM-DD, optionally a time and a zone). Pass a Date for anything else.');
5277
+ const m = ISO_DATE_TEXT.exec(text.trim());
5278
+ if (!m)
5279
+ throw refuse('it is not ISO 8601');
5280
+ const [, y, mo, d, hh, mi, ss, frac, zone] = m;
5281
+ const isDate = (col.pgType ?? '').trim().toLowerCase() === 'date';
5282
+ const day = new Date(0);
5283
+ day.setUTCFullYear(Number(y), Number(mo) - 1, Number(d));
5284
+ if (day.getUTCFullYear() !== Number(y) || day.getUTCMonth() !== Number(mo) - 1 || day.getUTCDate() !== Number(d)) {
5285
+ throw refuse('it names no calendar day');
5286
+ }
5287
+ if (isDate)
5288
+ return BigInt(day.getTime()) * 1000n;
5289
+ // PostgreSQL reads zone-less text against timestamptz in the SESSION time
5290
+ // zone, a server setting PowDB has no counterpart for, so no single reading
5291
+ // matches it; a plain `timestamp` has no zone and is read as UTC.
5292
+ if (!zone && /^timestamptz$|with time zone/i.test((col.pgType ?? '').trim())) {
5293
+ throw refuse('it has no zone, and PostgreSQL would read it in the session time zone; add Z or an offset');
5294
+ }
5295
+ const hours = Number(hh ?? 0);
5296
+ const minutes = Number(mi ?? 0);
5297
+ const seconds = Number(ss ?? 0);
5298
+ if (hours > 23 || minutes > 59 || seconds > 59)
5299
+ throw refuse('its time is out of range');
5300
+ let offsetMinutes = 0;
5301
+ if (zone && zone.toUpperCase() !== 'Z') {
5302
+ const digits = zone.slice(1).replace(':', '');
5303
+ const oh = Number(digits.slice(0, 2));
5304
+ const om = Number(digits.slice(2) || 0);
5305
+ if (oh > 15 || om > 59)
5306
+ throw refuse('its zone offset is out of range');
5307
+ offsetMinutes = (zone[0] === '-' ? -1 : 1) * (oh * 60 + om);
5308
+ }
5309
+ const ms = day.getTime() + ((hours * 60 + minutes - offsetMinutes) * 60 + seconds) * 1000;
5310
+ // Microseconds past the second, from the first six fraction digits (PostgreSQL rounds the rest).
5311
+ const fracMicros = frac ? Math.round(Number(`0.${frac}`) * 1e6) : 0;
5312
+ return BigInt(ms) * 1000n + BigInt(fracMicros);
5313
+ }
5314
+ function decodeAggregateCell(cell, col, native, enums) {
5315
+ if (cell === undefined || cell === null)
5316
+ return null;
5317
+ if (native)
5318
+ return (0, powdb_shared_js_1.coerceNativeValue)(cell, col);
5319
+ // Legacy string wire: an aggregate over no rows renders the bareword `null`.
5320
+ if (typeof cell !== 'string')
5321
+ return cell;
5322
+ return cell === 'null' ? null : (0, powdb_shared_js_1.coerceValue)(cell, col, enums);
5323
+ }
3779
5324
  /** Coerce a group-key scalar string by the column's TS type (numbers/bools). */
3780
5325
  function coerceScalar(raw, tsType) {
3781
5326
  const ts = (0, powdb_shared_js_1.baseTsType)(tsType);