velocious 1.0.644 → 1.0.645

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 (259) hide show
  1. package/README.md +11 -9
  2. package/build/authorization/ability.js +36 -13
  3. package/build/background-jobs/adapter.js +9 -0
  4. package/build/background-jobs/main.js +35 -10
  5. package/build/background-jobs/store.js +217 -38
  6. package/build/background-jobs/types.js +15 -0
  7. package/build/configuration-types.js +1 -1
  8. package/build/configuration.js +45 -9
  9. package/build/database/drivers/base-column.js +8 -0
  10. package/build/database/drivers/mssql/sql/update.js +1 -3
  11. package/build/database/drivers/mysql/sql/update.js +1 -3
  12. package/build/database/drivers/pgsql/column.js +22 -0
  13. package/build/database/drivers/pgsql/sql/update.js +1 -3
  14. package/build/database/drivers/sqlite/sql/alter-table.js +11 -1
  15. package/build/database/drivers/sqlite/sql/update.js +1 -3
  16. package/build/database/query/index.js +2 -2
  17. package/build/database/query/model-class-query.js +26 -10
  18. package/build/database/query/preloader/has-many.js +4 -1
  19. package/build/database/query/query-data.js +2 -1
  20. package/build/database/query/update-base.js +14 -0
  21. package/build/database/query/with-count.js +3 -1
  22. package/build/database/record/acts-as-list.js +37 -13
  23. package/build/database/record/attachments/store.js +336 -26
  24. package/build/database/record/auditing.js +6 -3
  25. package/build/database/record/index.js +197 -42
  26. package/build/database/record/instance-relationships/belongs-to.js +2 -1
  27. package/build/database/record/validators/uniqueness.js +8 -5
  28. package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +24 -8
  29. package/build/frontend-model-controller.js +136 -53
  30. package/build/frontend-model-resource/base-resource.js +87 -37
  31. package/build/frontend-model-resource/velocious-attachment-resource.js +98 -31
  32. package/build/frontend-models/base.js +307 -59
  33. package/build/frontend-models/preloader.js +6 -3
  34. package/build/frontend-models/query.js +15 -6
  35. package/build/frontend-models/resource-definition.js +21 -2
  36. package/build/frontend-models/use-destroyed-event.js +1 -1
  37. package/build/frontend-models/use-model-class-event.js +2 -2
  38. package/build/frontend-models/use-updated-event.js +1 -1
  39. package/build/frontend-models/websocket-channel.js +256 -93
  40. package/build/frontend-models/websocket-publishers.js +218 -18
  41. package/build/http-server/client/websocket-session.js +13 -1
  42. package/build/http-server/websocket-channel.js +15 -2
  43. package/build/src/authorization/ability.d.ts +9 -3
  44. package/build/src/authorization/ability.d.ts.map +1 -1
  45. package/build/src/authorization/ability.js +33 -14
  46. package/build/src/background-jobs/adapter.d.ts +6 -0
  47. package/build/src/background-jobs/adapter.d.ts.map +1 -1
  48. package/build/src/background-jobs/adapter.js +9 -1
  49. package/build/src/background-jobs/main.d.ts +6 -0
  50. package/build/src/background-jobs/main.d.ts.map +1 -1
  51. package/build/src/background-jobs/main.js +34 -11
  52. package/build/src/background-jobs/store.d.ts +66 -12
  53. package/build/src/background-jobs/store.d.ts.map +1 -1
  54. package/build/src/background-jobs/store.js +191 -35
  55. package/build/src/background-jobs/types.d.ts +55 -0
  56. package/build/src/background-jobs/types.d.ts.map +1 -1
  57. package/build/src/background-jobs/types.js +16 -1
  58. package/build/src/configuration-types.d.ts +3 -3
  59. package/build/src/configuration-types.d.ts.map +1 -1
  60. package/build/src/configuration-types.js +2 -2
  61. package/build/src/configuration.d.ts +18 -8
  62. package/build/src/configuration.d.ts.map +1 -1
  63. package/build/src/configuration.js +38 -7
  64. package/build/src/database/drivers/base-column.d.ts +5 -0
  65. package/build/src/database/drivers/base-column.d.ts.map +1 -1
  66. package/build/src/database/drivers/base-column.js +8 -1
  67. package/build/src/database/drivers/mssql/sql/update.d.ts.map +1 -1
  68. package/build/src/database/drivers/mssql/sql/update.js +2 -4
  69. package/build/src/database/drivers/mysql/sql/update.d.ts.map +1 -1
  70. package/build/src/database/drivers/mysql/sql/update.js +2 -4
  71. package/build/src/database/drivers/pgsql/column.d.ts +5 -0
  72. package/build/src/database/drivers/pgsql/column.d.ts.map +1 -1
  73. package/build/src/database/drivers/pgsql/column.js +19 -1
  74. package/build/src/database/drivers/pgsql/sql/update.d.ts.map +1 -1
  75. package/build/src/database/drivers/pgsql/sql/update.js +2 -4
  76. package/build/src/database/drivers/sqlite/sql/alter-table.d.ts.map +1 -1
  77. package/build/src/database/drivers/sqlite/sql/alter-table.js +6 -2
  78. package/build/src/database/drivers/sqlite/sql/update.d.ts.map +1 -1
  79. package/build/src/database/drivers/sqlite/sql/update.js +2 -4
  80. package/build/src/database/query/index.d.ts +4 -4
  81. package/build/src/database/query/index.d.ts.map +1 -1
  82. package/build/src/database/query/index.js +3 -3
  83. package/build/src/database/query/model-class-query.d.ts +8 -2
  84. package/build/src/database/query/model-class-query.d.ts.map +1 -1
  85. package/build/src/database/query/model-class-query.js +25 -11
  86. package/build/src/database/query/preloader/has-many.d.ts.map +1 -1
  87. package/build/src/database/query/preloader/has-many.js +4 -2
  88. package/build/src/database/query/query-data.d.ts.map +1 -1
  89. package/build/src/database/query/query-data.js +3 -2
  90. package/build/src/database/query/update-base.d.ts +7 -0
  91. package/build/src/database/query/update-base.d.ts.map +1 -1
  92. package/build/src/database/query/update-base.js +13 -1
  93. package/build/src/database/query/with-count.d.ts.map +1 -1
  94. package/build/src/database/query/with-count.js +3 -2
  95. package/build/src/database/record/acts-as-list.d.ts.map +1 -1
  96. package/build/src/database/record/acts-as-list.js +35 -14
  97. package/build/src/database/record/attachments/store.d.ts +67 -0
  98. package/build/src/database/record/attachments/store.d.ts.map +1 -1
  99. package/build/src/database/record/attachments/store.js +301 -25
  100. package/build/src/database/record/auditing.d.ts.map +1 -1
  101. package/build/src/database/record/auditing.js +7 -4
  102. package/build/src/database/record/index.d.ts +78 -26
  103. package/build/src/database/record/index.d.ts.map +1 -1
  104. package/build/src/database/record/index.js +171 -42
  105. package/build/src/database/record/instance-relationships/belongs-to.d.ts.map +1 -1
  106. package/build/src/database/record/instance-relationships/belongs-to.js +3 -2
  107. package/build/src/database/record/validators/uniqueness.d.ts.map +1 -1
  108. package/build/src/database/record/validators/uniqueness.js +8 -6
  109. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts +4 -4
  110. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
  111. package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +24 -12
  112. package/build/src/frontend-model-controller.d.ts +29 -8
  113. package/build/src/frontend-model-controller.d.ts.map +1 -1
  114. package/build/src/frontend-model-controller.js +122 -51
  115. package/build/src/frontend-model-resource/base-resource.d.ts +51 -24
  116. package/build/src/frontend-model-resource/base-resource.d.ts.map +1 -1
  117. package/build/src/frontend-model-resource/base-resource.js +83 -37
  118. package/build/src/frontend-model-resource/velocious-attachment-resource.d.ts +45 -26
  119. package/build/src/frontend-model-resource/velocious-attachment-resource.d.ts.map +1 -1
  120. package/build/src/frontend-model-resource/velocious-attachment-resource.js +89 -31
  121. package/build/src/frontend-models/base.d.ts +53 -20
  122. package/build/src/frontend-models/base.d.ts.map +1 -1
  123. package/build/src/frontend-models/base.js +275 -59
  124. package/build/src/frontend-models/preloader.d.ts.map +1 -1
  125. package/build/src/frontend-models/preloader.js +6 -4
  126. package/build/src/frontend-models/query.d.ts +2 -2
  127. package/build/src/frontend-models/query.d.ts.map +1 -1
  128. package/build/src/frontend-models/query.js +14 -7
  129. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  130. package/build/src/frontend-models/resource-definition.js +20 -3
  131. package/build/src/frontend-models/use-destroyed-event.d.ts +1 -1
  132. package/build/src/frontend-models/use-destroyed-event.d.ts.map +1 -1
  133. package/build/src/frontend-models/use-destroyed-event.js +2 -2
  134. package/build/src/frontend-models/use-model-class-event.d.ts +2 -2
  135. package/build/src/frontend-models/use-model-class-event.d.ts.map +1 -1
  136. package/build/src/frontend-models/use-model-class-event.js +3 -3
  137. package/build/src/frontend-models/use-updated-event.d.ts +1 -1
  138. package/build/src/frontend-models/use-updated-event.d.ts.map +1 -1
  139. package/build/src/frontend-models/use-updated-event.js +2 -2
  140. package/build/src/frontend-models/websocket-channel.d.ts +60 -41
  141. package/build/src/frontend-models/websocket-channel.d.ts.map +1 -1
  142. package/build/src/frontend-models/websocket-channel.js +225 -85
  143. package/build/src/frontend-models/websocket-publishers.d.ts +9 -0
  144. package/build/src/frontend-models/websocket-publishers.d.ts.map +1 -1
  145. package/build/src/frontend-models/websocket-publishers.js +190 -19
  146. package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
  147. package/build/src/http-server/client/websocket-session.js +11 -2
  148. package/build/src/http-server/websocket-channel.d.ts +20 -8
  149. package/build/src/http-server/websocket-channel.d.ts.map +1 -1
  150. package/build/src/http-server/websocket-channel.js +15 -3
  151. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  152. package/build/src/sync/sync-api-client.js +4 -3
  153. package/build/src/sync/sync-client.d.ts.map +1 -1
  154. package/build/src/sync/sync-client.js +3 -2
  155. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  156. package/build/src/sync/sync-envelope-replay-service.js +6 -5
  157. package/build/src/sync/sync-publisher.d.ts.map +1 -1
  158. package/build/src/sync/sync-publisher.js +4 -3
  159. package/build/src/testing/browser-frontend-model-event-hook-scenarios.d.ts +2 -2
  160. package/build/src/testing/browser-frontend-model-event-hook-scenarios.d.ts.map +1 -1
  161. package/build/src/testing/browser-frontend-model-event-hook-scenarios.js +3 -3
  162. package/build/src/testing/test-profiler.d.ts +4 -4
  163. package/build/src/testing/test-profiler.d.ts.map +1 -1
  164. package/build/src/testing/test-profiler.js +3 -3
  165. package/build/src/testing/test-runner.d.ts +271 -117
  166. package/build/src/testing/test-runner.d.ts.map +1 -1
  167. package/build/src/testing/test-runner.js +496 -427
  168. package/build/src/testing/test.d.ts +11 -72
  169. package/build/src/testing/test.d.ts.map +1 -1
  170. package/build/src/testing/test.js +131 -284
  171. package/build/src/testing/velocious-attempt-executor.d.ts +14 -29
  172. package/build/src/testing/velocious-attempt-executor.d.ts.map +1 -1
  173. package/build/src/testing/velocious-attempt-executor.js +52 -46
  174. package/build/src/testing/velocious-runner-reporter.d.ts +29 -34
  175. package/build/src/testing/velocious-runner-reporter.d.ts.map +1 -1
  176. package/build/src/testing/velocious-runner-reporter.js +145 -57
  177. package/build/src/testing/velocious-suite-hook-executor.d.ts +5 -23
  178. package/build/src/testing/velocious-suite-hook-executor.d.ts.map +1 -1
  179. package/build/src/testing/velocious-suite-hook-executor.js +22 -47
  180. package/build/src/testing/velocious-test-arguments.d.ts +11 -6
  181. package/build/src/testing/velocious-test-arguments.d.ts.map +1 -1
  182. package/build/src/testing/velocious-test-arguments.js +18 -10
  183. package/build/src/utils/model-primary-key.d.ts +73 -0
  184. package/build/src/utils/model-primary-key.d.ts.map +1 -0
  185. package/build/src/utils/model-primary-key.js +144 -0
  186. package/build/sync/sync-api-client.js +3 -2
  187. package/build/sync/sync-client.js +2 -1
  188. package/build/sync/sync-envelope-replay-service.js +5 -4
  189. package/build/sync/sync-publisher.js +3 -2
  190. package/build/testing/browser-frontend-model-event-hook-scenarios.js +2 -2
  191. package/build/testing/test-profiler.js +2 -2
  192. package/build/testing/test-runner.js +493 -449
  193. package/build/testing/test.js +164 -306
  194. package/build/testing/velocious-attempt-executor.js +55 -45
  195. package/build/testing/velocious-runner-reporter.js +156 -58
  196. package/build/testing/velocious-suite-hook-executor.js +22 -50
  197. package/build/testing/velocious-test-arguments.js +19 -10
  198. package/build/tsconfig.tsbuildinfo +1 -1
  199. package/build/utils/model-primary-key.js +171 -0
  200. package/package.json +5 -4
  201. package/src/authorization/ability.js +36 -13
  202. package/src/background-jobs/adapter.js +9 -0
  203. package/src/background-jobs/main.js +35 -10
  204. package/src/background-jobs/store.js +217 -38
  205. package/src/background-jobs/types.js +15 -0
  206. package/src/configuration-types.js +1 -1
  207. package/src/configuration.js +45 -9
  208. package/src/database/drivers/base-column.js +8 -0
  209. package/src/database/drivers/mssql/sql/update.js +1 -3
  210. package/src/database/drivers/mysql/sql/update.js +1 -3
  211. package/src/database/drivers/pgsql/column.js +22 -0
  212. package/src/database/drivers/pgsql/sql/update.js +1 -3
  213. package/src/database/drivers/sqlite/sql/alter-table.js +11 -1
  214. package/src/database/drivers/sqlite/sql/update.js +1 -3
  215. package/src/database/query/index.js +2 -2
  216. package/src/database/query/model-class-query.js +26 -10
  217. package/src/database/query/preloader/has-many.js +4 -1
  218. package/src/database/query/query-data.js +2 -1
  219. package/src/database/query/update-base.js +14 -0
  220. package/src/database/query/with-count.js +3 -1
  221. package/src/database/record/acts-as-list.js +37 -13
  222. package/src/database/record/attachments/store.js +336 -26
  223. package/src/database/record/auditing.js +6 -3
  224. package/src/database/record/index.js +197 -42
  225. package/src/database/record/instance-relationships/belongs-to.js +2 -1
  226. package/src/database/record/validators/uniqueness.js +8 -5
  227. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +24 -8
  228. package/src/frontend-model-controller.js +136 -53
  229. package/src/frontend-model-resource/base-resource.js +87 -37
  230. package/src/frontend-model-resource/velocious-attachment-resource.js +98 -31
  231. package/src/frontend-models/base.js +307 -59
  232. package/src/frontend-models/preloader.js +6 -3
  233. package/src/frontend-models/query.js +15 -6
  234. package/src/frontend-models/resource-definition.js +21 -2
  235. package/src/frontend-models/use-destroyed-event.js +1 -1
  236. package/src/frontend-models/use-model-class-event.js +2 -2
  237. package/src/frontend-models/use-updated-event.js +1 -1
  238. package/src/frontend-models/websocket-channel.js +256 -93
  239. package/src/frontend-models/websocket-publishers.js +218 -18
  240. package/src/http-server/client/websocket-session.js +13 -1
  241. package/src/http-server/websocket-channel.js +15 -2
  242. package/src/sync/sync-api-client.js +3 -2
  243. package/src/sync/sync-client.js +2 -1
  244. package/src/sync/sync-envelope-replay-service.js +5 -4
  245. package/src/sync/sync-publisher.js +3 -2
  246. package/src/testing/browser-frontend-model-event-hook-scenarios.js +2 -2
  247. package/src/testing/test-profiler.js +2 -2
  248. package/src/testing/test-runner.js +493 -449
  249. package/src/testing/test.js +164 -306
  250. package/src/testing/velocious-attempt-executor.js +55 -45
  251. package/src/testing/velocious-runner-reporter.js +156 -58
  252. package/src/testing/velocious-suite-hook-executor.js +22 -50
  253. package/src/testing/velocious-test-arguments.js +19 -10
  254. package/src/utils/model-primary-key.js +171 -0
  255. package/build/src/testing/testing-package-adapter.d.ts +0 -9
  256. package/build/src/testing/testing-package-adapter.d.ts.map +0 -1
  257. package/build/src/testing/testing-package-adapter.js +0 -69
  258. package/build/testing/testing-package-adapter.js +0 -76
  259. package/src/testing/testing-package-adapter.js +0 -76
@@ -189,9 +189,10 @@ export default class VelociousAuthorizationAbility {
189
189
  * @param {string} args.action - Requested action.
190
190
  * @param {typeof import("../database/record/index.js").default} args.modelClass - Model class.
191
191
  * @param {import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>} args.query - Query.
192
+ * @param {() => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>} [args.ruleQueryFactory] - Optional factory for the queries that evaluate individual conditional rules.
192
193
  * @returns {import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>} - Authorized query.
193
194
  */
194
- applyToQuery({action, modelClass, query}) {
195
+ applyToQuery({action, modelClass, query, ruleQueryFactory}) {
195
196
  this.loadAbilitiesForModelClass(modelClass)
196
197
 
197
198
  const applicableRules = this.rulesFor({action, modelClass})
@@ -203,18 +204,18 @@ export default class VelociousAuthorizationAbility {
203
204
  }
204
205
 
205
206
  if (allowRules.some((rule) => !rule.conditions)) {
206
- this.applyDenyRules({action, denyRules, modelClass, query})
207
+ this.applyDenyRules({action, denyRules, modelClass, query, ruleQueryFactory})
207
208
  return query
208
209
  }
209
210
 
210
- const allowSqlParts = this.conditionSqlParts({action, modelClass, query, rules: allowRules})
211
+ const allowSqlParts = this.conditionSqlParts({action, modelClass, query, ruleQueryFactory, rules: allowRules})
211
212
 
212
213
  if (allowSqlParts.length === 0) {
213
214
  return query.where("1=0")
214
215
  }
215
216
 
216
217
  query.where(`(${allowSqlParts.join(" OR ")})`)
217
- this.applyDenyRules({action, denyRules, modelClass, query})
218
+ this.applyDenyRules({action, denyRules, modelClass, query, ruleQueryFactory})
218
219
 
219
220
  return query
220
221
  }
@@ -240,19 +241,20 @@ export default class VelociousAuthorizationAbility {
240
241
  * @param {string} args.action - Action.
241
242
  * @param {typeof import("../database/record/index.js").default} args.modelClass - Model class.
242
243
  * @param {import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>} args.query - Base query.
244
+ * @param {() => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>} [args.ruleQueryFactory] - Optional conditional-rule query factory.
243
245
  * @param {AbilityRuleType[]} args.rules - Rules.
244
246
  * @returns {string[]} - SQL condition parts.
245
247
  */
246
- conditionSqlParts({action, modelClass, query, rules}) {
247
- const pk = modelClass.primaryKey()
248
- const quotedBaseTable = query.driver.quoteTable(modelClass.tableName())
249
- const quotedPk = query.driver.quoteColumn(pk)
248
+ conditionSqlParts({action, modelClass, query, ruleQueryFactory, rules}) {
249
+ const primaryKey = modelClass.primaryKey()
250
+ const primaryKeyAttributes = Array.isArray(primaryKey) ? primaryKey : [primaryKey]
251
+ const quotedBaseTable = query.driver.quoteTable(query.getTableReferenceForJoin())
250
252
  const sqlParts = []
251
253
 
252
254
  for (const rule of rules) {
253
255
  if (!rule.conditions) continue
254
256
 
255
- const scopedQuery = modelClass._newQuery()
257
+ const scopedQuery = ruleQueryFactory ? ruleQueryFactory() : modelClass._newQuery()
256
258
  const resultQuery = this.applyRuleCondition({
257
259
  action,
258
260
  conditions: rule.conditions,
@@ -260,7 +262,16 @@ export default class VelociousAuthorizationAbility {
260
262
  query: scopedQuery
261
263
  })
262
264
  const finalQuery = resultQuery || scopedQuery
263
- const selectedPkSql = `${quotedBaseTable}.${quotedPk}`
265
+
266
+ if (ruleQueryFactory && finalQuery !== scopedQuery) {
267
+ const finalFroms = finalQuery.getFroms()
268
+
269
+ finalFroms.splice(0, finalFroms.length, ...scopedQuery.getFroms())
270
+ }
271
+
272
+ const quotedScopedTable = query.driver.quoteTable(finalQuery.getTableReferenceForJoin())
273
+ const primaryKeyColumns = primaryKeyAttributes.map((attributeName) => modelClass.getColumnNameForAttributeName(attributeName))
274
+ const selectedPkSql = primaryKeyColumns.map((columnName) => `${quotedScopedTable}.${query.driver.quoteColumn(columnName)}`)
264
275
 
265
276
  if (finalQuery._distinct) {
266
277
  query.distinct(true)
@@ -268,7 +279,18 @@ export default class VelociousAuthorizationAbility {
268
279
 
269
280
  finalQuery.select(selectedPkSql)
270
281
 
271
- sqlParts.push(`${quotedBaseTable}.${quotedPk} IN (${finalQuery.toSql()})`)
282
+ if (Array.isArray(primaryKey)) {
283
+ const authorizedRowsAlias = query.driver.quoteTable("velocious_authorized_rows")
284
+ const identitySql = primaryKeyColumns.map((columnName) => {
285
+ const quotedColumn = query.driver.quoteColumn(columnName)
286
+
287
+ return `${authorizedRowsAlias}.${quotedColumn} = ${quotedBaseTable}.${quotedColumn}`
288
+ }).join(" AND ")
289
+
290
+ sqlParts.push(`EXISTS (SELECT 1 FROM (${finalQuery.toSql()}) AS ${authorizedRowsAlias} WHERE ${identitySql})`)
291
+ } else {
292
+ sqlParts.push(`${quotedBaseTable}.${query.driver.quoteColumn(primaryKeyColumns[0])} IN (${finalQuery.toSql()})`)
293
+ }
272
294
  }
273
295
 
274
296
  return sqlParts
@@ -281,9 +303,10 @@ export default class VelociousAuthorizationAbility {
281
303
  * @param {AbilityRuleType[]} args.denyRules - Deny rules.
282
304
  * @param {typeof import("../database/record/index.js").default} args.modelClass - Model class.
283
305
  * @param {import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>} args.query - Query.
306
+ * @param {() => import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>} [args.ruleQueryFactory] - Optional conditional-rule query factory.
284
307
  * @returns {void} - No return value.
285
308
  */
286
- applyDenyRules({action, denyRules, modelClass, query}) {
309
+ applyDenyRules({action, denyRules, modelClass, query, ruleQueryFactory}) {
287
310
  if (denyRules.length === 0) return
288
311
 
289
312
  if (denyRules.some((rule) => !rule.conditions)) {
@@ -291,7 +314,7 @@ export default class VelociousAuthorizationAbility {
291
314
  return
292
315
  }
293
316
 
294
- const denySqlParts = this.conditionSqlParts({action, modelClass, query, rules: denyRules})
317
+ const denySqlParts = this.conditionSqlParts({action, modelClass, query, ruleQueryFactory, rules: denyRules})
295
318
 
296
319
  if (denySqlParts.length > 0) {
297
320
  query.where(`NOT (${denySqlParts.join(" OR ")})`)
@@ -48,6 +48,15 @@ export default class BackgroundJobsAdapter {
48
48
  */
49
49
  async reconcileQueueConcurrency() { throw new Error("BackgroundJobsAdapter#reconcileQueueConcurrency is not implemented") }
50
50
 
51
+ /**
52
+ * Repairs drift in adapter-owned durable active concurrency counts. Adapters
53
+ * without duplicate active-count persistence can keep this no-op result.
54
+ * @returns {Promise<import("./types.js").BackgroundJobConcurrencyReconciliation>} - Repair summary.
55
+ */
56
+ async reconcileActiveConcurrency() {
57
+ return {candidateCount: 0, checkedCount: 0, repairedCount: 0, repairs: [], repairsTruncatedCount: 0}
58
+ }
59
+
51
60
  /**
52
61
  * Enqueues a job.
53
62
  * @param {{jobName: string, args: Array<ReturnType<typeof JSON.parse>>, options?: import("./types.js").BackgroundJobOptions}} _args - Job request.
@@ -2063,23 +2063,25 @@ export default class BackgroundJobsMain {
2063
2063
  handoffs.set(job.id, handoff.handoffId)
2064
2064
 
2065
2065
  try {
2066
+ const dispatchedJob = handoff.job || job
2067
+
2066
2068
  worker.send({
2067
2069
  type: "job",
2068
2070
  payload: {
2069
- id: job.id,
2070
- jobName: job.jobName,
2071
- args: job.args,
2071
+ id: dispatchedJob.id,
2072
+ jobName: dispatchedJob.jobName,
2073
+ args: dispatchedJob.args,
2072
2074
  handoffId: handoff.handoffId,
2073
2075
  workerId: worker.workerId,
2074
2076
  handedOffAtMs: handoff.handedOffAtMs,
2075
2077
  options: {
2076
- concurrencyKey: job.concurrencyKey || undefined,
2077
- executionMode: job.executionMode,
2078
- maxConcurrency: job.maxConcurrency ?? undefined,
2079
- maxRetries: job.maxRetries ?? undefined,
2080
- queue: job.queue,
2081
- scheduledAtMs: job.scheduledAtMs ?? undefined,
2082
- ...(job.timeoutMs === null ? {} : {timeoutMs: job.timeoutMs})
2078
+ concurrencyKey: dispatchedJob.concurrencyKey || undefined,
2079
+ executionMode: dispatchedJob.executionMode,
2080
+ maxConcurrency: dispatchedJob.maxConcurrency ?? undefined,
2081
+ maxRetries: dispatchedJob.maxRetries ?? undefined,
2082
+ queue: dispatchedJob.queue,
2083
+ scheduledAtMs: dispatchedJob.scheduledAtMs ?? undefined,
2084
+ ...(dispatchedJob.timeoutMs === null ? {} : {timeoutMs: dispatchedJob.timeoutMs})
2083
2085
  }
2084
2086
  }
2085
2087
  })
@@ -2354,6 +2356,29 @@ export default class BackgroundJobsMain {
2354
2356
  errorEvents.emit("framework-error", payload)
2355
2357
  errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
2356
2358
  }
2359
+
2360
+ if (this.lifecycleState === "active") await this._reconcileActiveConcurrency()
2361
+ }
2362
+
2363
+ /**
2364
+ * Repairs durable admission counters on the active main's maintenance cadence
2365
+ * and immediately retries dispatch when capacity was recovered.
2366
+ * @returns {Promise<void>} - Resolves after repair and any resulting drain.
2367
+ */
2368
+ async _reconcileActiveConcurrency() {
2369
+ try {
2370
+ const result = await this.store.reconcileActiveConcurrency()
2371
+
2372
+ if (result.repairedCount > 0) await this._drain()
2373
+ } catch (error) {
2374
+ const normalizedError = error instanceof Error ? error : new Error(String(error))
2375
+ const payload = {context: {generationId: this.generationId, stage: "background-job-concurrency-reconciliation"}, error: normalizedError}
2376
+ const errorEvents = this.configuration.getErrorEvents()
2377
+
2378
+ this.logger.error(() => ["Failed to reconcile background job active-concurrency counts:", normalizedError])
2379
+ errorEvents.emit("framework-error", payload)
2380
+ errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
2381
+ }
2357
2382
  }
2358
2383
 
2359
2384
  /**
@@ -56,6 +56,20 @@ import {
56
56
  * @property {{failureMessage: string, name: string}} [advisoryLock] - Session lock held around the transaction.
57
57
  */
58
58
 
59
+ /**
60
+ * BackgroundJobConcurrencyCountRow type.
61
+ * @typedef {object} BackgroundJobConcurrencyCountRow
62
+ * @property {number | string} active_count - Persisted or aggregated active count.
63
+ * @property {string} concurrency_key - Durable cap identity.
64
+ */
65
+
66
+ /**
67
+ * BackgroundJobQueuedConcurrency type.
68
+ * @typedef {object} BackgroundJobQueuedConcurrency
69
+ * @property {string | null} concurrencyKey - Current concurrency key for queued work.
70
+ * @property {number | null} maxConcurrency - Current concurrency cap for queued work.
71
+ */
72
+
59
73
  const MIGRATIONS_TABLE = "velocious_internal_migrations"
60
74
  const MIGRATION_SCOPE = "background_jobs"
61
75
  const MIGRATION_VERSION = "20250215000000"
@@ -89,6 +103,8 @@ const SCHEDULE_KEYS_TABLE = "background_job_schedule_keys"
89
103
  const CONCURRENCY_TABLE = "background_job_concurrency"
90
104
  const COUNTS_REVISION_TABLE = "background_job_count_revisions"
91
105
  const COUNTS_REVISION_KEY = "counts"
106
+ const CONCURRENCY_RECONCILIATION_LOCK = "background-jobs:queue-concurrency-reconcile"
107
+ const CONCURRENCY_REPAIR_SAMPLE_LIMIT = 10
92
108
  export const BACKGROUND_JOB_COUNTS_CHANNEL = "velocious-background-job-counts"
93
109
  export const BACKGROUND_JOB_COUNT_BUCKETS = ["all", "queued", "handed_off", "completed", "failed", "orphaned"]
94
110
  const COUNTED_JOB_STATUSES = BACKGROUND_JOB_COUNT_BUCKETS.slice(1)
@@ -222,8 +238,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
222
238
  await this.ensureReady()
223
239
 
224
240
  await this._withDb(async (db) => {
225
- const lockName = "background-jobs:queue-concurrency-reconcile"
226
- const acquired = await db.acquireAdvisoryLock(lockName)
241
+ const acquired = await db.acquireAdvisoryLock(CONCURRENCY_RECONCILIATION_LOCK)
227
242
 
228
243
  if (!acquired) throw new Error("Failed to acquire background job queue-concurrency reconcile lock")
229
244
 
@@ -236,7 +251,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
236
251
  // the counts (adoption itself is idempotent).
237
252
  this._queueConcurrencyReconciled = true
238
253
  } finally {
239
- await db.releaseAdvisoryLock(lockName)
254
+ await db.releaseAdvisoryLock(CONCURRENCY_RECONCILIATION_LOCK)
240
255
  }
241
256
  })
242
257
 
@@ -246,6 +261,44 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
246
261
  ])
247
262
  }
248
263
 
264
+ /**
265
+ * Repairs durable active-count drift while a main process remains live. The
266
+ * initial snapshot is read-only; only suspected mismatches take their
267
+ * counter lock and re-count inside the serialized transaction path.
268
+ * @returns {Promise<import("./types.js").BackgroundJobConcurrencyReconciliation>} - Repair summary.
269
+ */
270
+ async reconcileActiveConcurrency() {
271
+ const databaseIdentifier = this.getDatabaseIdentifier()
272
+ const startedAtMs = Date.now()
273
+
274
+ await this.ensureReady()
275
+
276
+ const result = await this._serializedConnectionMutation(
277
+ async (db) => await this._reconcileConcurrency(db),
278
+ {
279
+ advisoryLock: {
280
+ failureMessage: "Failed to acquire background job active-concurrency reconcile lock",
281
+ name: CONCURRENCY_RECONCILIATION_LOCK
282
+ }
283
+ }
284
+ )
285
+
286
+ if (result.repairedCount > 0) {
287
+ await this.logger.warn(() => [
288
+ "Repaired background jobs active-concurrency count drift",
289
+ {
290
+ databaseIdentifier,
291
+ durationMs: Date.now() - startedAtMs,
292
+ repairedCount: result.repairedCount,
293
+ repairs: result.repairs,
294
+ repairsTruncatedCount: result.repairsTruncatedCount
295
+ }
296
+ ])
297
+ }
298
+
299
+ return result
300
+ }
301
+
249
302
  /**
250
303
  * Runs enqueue.
251
304
  * @param {object} args - Options.
@@ -949,8 +1002,11 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
949
1002
  const handedOffAtMs = Date.now()
950
1003
 
951
1004
  return await this._serializedCountMutation(async (db) => {
952
- const queuedJob = await this._getJobRowById(db, jobId)
953
- if (!queuedJob || queuedJob.status !== "queued") return null
1005
+ const selectedJob = await this._getJobRowById(db, jobId)
1006
+ if (!selectedJob || selectedJob.status !== "queued") return null
1007
+ const queuedJob = await this._reconcileQueuedJobConcurrency(db, selectedJob)
1008
+
1009
+ if (!queuedJob) return null
954
1010
  if (queuedJob.concurrencyKey && !(await this._reserveConcurrency(db, queuedJob.concurrencyKey))) return null
955
1011
  const affectedRows = await this._updateAffectedRows(db, {
956
1012
  tableName: JOBS_TABLE,
@@ -960,7 +1016,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
960
1016
  handoff_id: handoffId,
961
1017
  worker_id: workerId || null
962
1018
  },
963
- conditions: {id: jobId, status: "queued"}
1019
+ conditions: {concurrency_key: queuedJob.concurrencyKey, id: jobId, status: "queued"}
964
1020
  })
965
1021
 
966
1022
  if (affectedRows !== 1) {
@@ -969,7 +1025,16 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
969
1025
  }
970
1026
 
971
1027
  await this._recordStatusTransition(db, "queued", "handed_off")
972
- return {handedOffAtMs, handoffId}
1028
+ /** @type {import("./types.js").BackgroundJobRow} */
1029
+ const handedOffJob = {
1030
+ ...queuedJob,
1031
+ handedOffAtMs,
1032
+ handoffId,
1033
+ status: "handed_off",
1034
+ workerId: workerId || null
1035
+ }
1036
+
1037
+ return {handedOffAtMs, handoffId, job: handedOffJob}
973
1038
  })
974
1039
  }
975
1040
 
@@ -2355,6 +2420,41 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2355
2420
  })
2356
2421
  }
2357
2422
 
2423
+ /**
2424
+ * Applies the active generation's queue policy immediately before handoff.
2425
+ * Explicit concurrency remains owned by the enqueue request.
2426
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
2427
+ * @param {import("./types.js").BackgroundJobRow} job - Queued job snapshot.
2428
+ * @returns {Promise<import("./types.js").BackgroundJobRow | null>} - Reconciled job, or null when its queued-state fence lost.
2429
+ */
2430
+ async _reconcileQueuedJobConcurrency(db, job) {
2431
+ if (job.concurrencyKey && !job.concurrencyKey.startsWith(QUEUE_CONCURRENCY_KEY_PREFIX)) {
2432
+ return job
2433
+ }
2434
+
2435
+ const concurrency = this._resolveConcurrency({}, job.queue)
2436
+ /** @type {BackgroundJobQueuedConcurrency} */
2437
+ const current = concurrency
2438
+ ? {concurrencyKey: concurrency.concurrencyKey, maxConcurrency: concurrency.maxConcurrency}
2439
+ : {concurrencyKey: null, maxConcurrency: null}
2440
+
2441
+ if (concurrency) await this._ensureQueueConcurrencyKey(db, concurrency)
2442
+ if (job.concurrencyKey === current.concurrencyKey && job.maxConcurrency === current.maxConcurrency) return job
2443
+
2444
+ const affectedRows = await this._updateAffectedRows(db, {
2445
+ tableName: JOBS_TABLE,
2446
+ data: {
2447
+ concurrency_key: current.concurrencyKey,
2448
+ max_concurrency: current.maxConcurrency
2449
+ },
2450
+ conditions: {concurrency_key: job.concurrencyKey, id: job.id, status: "queued"}
2451
+ })
2452
+
2453
+ if (affectedRows !== 1) return null
2454
+
2455
+ return {...job, concurrencyKey: current.concurrencyKey, maxConcurrency: current.maxConcurrency}
2456
+ }
2457
+
2358
2458
  /**
2359
2459
  * Reads the configured max concurrency for a queue from the background-jobs config.
2360
2460
  * @param {string} queue - Queue name.
@@ -2763,14 +2863,19 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2763
2863
  /**
2764
2864
  * Rebuilds durable counts from active handoffs.
2765
2865
  * @param {import("../database/drivers/base.js").default} db - Database connection.
2766
- * @returns {Promise<void>} - Resolves when reconciled.
2866
+ * @param {{insideTransaction?: boolean}} [options] - Reuse an enclosing transaction.
2867
+ * @returns {Promise<import("./types.js").BackgroundJobConcurrencyReconciliation>} - Repair summary.
2767
2868
  */
2768
- async _reconcileConcurrency(db) {
2769
- if (!(await db.tableExists(CONCURRENCY_TABLE))) return
2869
+ async _reconcileConcurrency(db, {insideTransaction = false} = {}) {
2870
+ if (!(await db.tableExists(CONCURRENCY_TABLE))) {
2871
+ return {candidateCount: 0, checkedCount: 0, repairedCount: 0, repairs: [], repairsTruncatedCount: 0}
2872
+ }
2873
+
2770
2874
  const activeRows = await db
2771
2875
  .newQuery()
2772
2876
  .from(JOBS_TABLE)
2773
2877
  .select("concurrency_key")
2878
+ .select("COUNT(*) AS active_count")
2774
2879
  .where({status: "handed_off"})
2775
2880
  .where(`${db.quoteColumn("concurrency_key")} IS NOT NULL`)
2776
2881
  .group("concurrency_key")
@@ -2779,18 +2884,49 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2779
2884
  .newQuery()
2780
2885
  .from(CONCURRENCY_TABLE)
2781
2886
  .select("concurrency_key")
2887
+ .select("active_count")
2782
2888
  .where(`${db.quoteColumn("active_count")} != 0`)
2783
2889
  .results()
2784
- const concurrencyKeys = new Set(
2785
- [...activeRows, ...staleRows].map((row) =>
2786
- String(/** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (row).concurrency_key)
2787
- )
2788
- )
2890
+ /** @type {Map<string, number>} */
2891
+ const activeCounts = new Map()
2892
+ /** @type {Map<string, number>} */
2893
+ const persistedCounts = new Map()
2789
2894
 
2790
- for (const concurrencyKey of [...concurrencyKeys].sort()) {
2791
- await this._transactionResult(db, async () => {
2792
- await this._reconcileConcurrencyKey(db, concurrencyKey)
2793
- })
2895
+ for (const rawRow of activeRows) {
2896
+ const row = /** @type {BackgroundJobConcurrencyCountRow} */ (rawRow)
2897
+ activeCounts.set(row.concurrency_key, this._validatedConcurrencyCount(row.active_count, row.concurrency_key))
2898
+ }
2899
+
2900
+ for (const rawRow of staleRows) {
2901
+ const row = /** @type {BackgroundJobConcurrencyCountRow} */ (rawRow)
2902
+ persistedCounts.set(row.concurrency_key, this._validatedConcurrencyCount(row.active_count, row.concurrency_key))
2903
+ }
2904
+
2905
+ const concurrencyKeys = [...new Set([...activeCounts.keys(), ...persistedCounts.keys()])].sort()
2906
+ const candidateKeys = concurrencyKeys.filter((concurrencyKey) => {
2907
+ return (activeCounts.get(concurrencyKey) || 0) !== (persistedCounts.get(concurrencyKey) || 0)
2908
+ })
2909
+ /** @type {import("./types.js").BackgroundJobConcurrencyRepair[]} */
2910
+ const repairs = []
2911
+ let repairedCount = 0
2912
+
2913
+ for (const concurrencyKey of candidateKeys) {
2914
+ const repair = insideTransaction
2915
+ ? await this._reconcileConcurrencyKey(db, concurrencyKey)
2916
+ : await this._transactionResult(db, async () => await this._reconcileConcurrencyKey(db, concurrencyKey))
2917
+
2918
+ if (!repair) continue
2919
+
2920
+ repairedCount++
2921
+ if (repairs.length < CONCURRENCY_REPAIR_SAMPLE_LIMIT) repairs.push(repair)
2922
+ }
2923
+
2924
+ return {
2925
+ candidateCount: candidateKeys.length,
2926
+ checkedCount: concurrencyKeys.length,
2927
+ repairedCount,
2928
+ repairs,
2929
+ repairsTruncatedCount: repairedCount - repairs.length
2794
2930
  }
2795
2931
  }
2796
2932
 
@@ -2799,29 +2935,57 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2799
2935
  * lock order used by handoff and completion transitions.
2800
2936
  * @param {import("../database/drivers/base.js").default} db - Database connection.
2801
2937
  * @param {string} concurrencyKey - Counter key.
2802
- * @returns {Promise<void>} - Resolves when reconciled.
2938
+ * @returns {Promise<import("./types.js").BackgroundJobConcurrencyRepair | null>} - Applied repair.
2803
2939
  */
2804
2940
  async _reconcileConcurrencyKey(db, concurrencyKey) {
2805
2941
  await this._lockConcurrencyRow(db, concurrencyKey)
2942
+ const persistedRows = await db
2943
+ .newQuery()
2944
+ .from(CONCURRENCY_TABLE)
2945
+ .select("active_count")
2946
+ .select("concurrency_key")
2947
+ .where({concurrency_key: concurrencyKey})
2948
+ .limit(1)
2949
+ .results()
2950
+
2951
+ if (!persistedRows[0]) throw new Error(`Missing background job concurrency counter for ${concurrencyKey}`)
2952
+
2953
+ const persistedRow = /** @type {BackgroundJobConcurrencyCountRow} */ (persistedRows[0])
2954
+ const previousActiveCount = this._validatedConcurrencyCount(persistedRow.active_count, concurrencyKey)
2806
2955
  const rows = await db
2807
2956
  .newQuery()
2808
2957
  .from(JOBS_TABLE)
2809
2958
  .select("COUNT(*) AS active_count")
2810
2959
  .where({concurrency_key: concurrencyKey, status: "handed_off"})
2811
2960
  .results()
2812
- const activeCount = this._normalizeNumber(
2813
- /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (rows[0] || {}).active_count
2814
- )
2961
+ const countRow = /** @type {{active_count: number | string}} */ (rows[0])
2962
+ const activeCount = this._validatedConcurrencyCount(countRow.active_count, concurrencyKey)
2815
2963
 
2816
- if (activeCount === null || !Number.isSafeInteger(activeCount) || activeCount < 0) {
2817
- throw new Error(`Invalid reconciled background job concurrency count for ${concurrencyKey}: ${activeCount}`)
2818
- }
2964
+ if (activeCount === previousActiveCount) return null
2819
2965
 
2820
2966
  await db.update({
2821
2967
  tableName: CONCURRENCY_TABLE,
2822
2968
  data: {active_count: activeCount},
2823
2969
  conditions: {concurrency_key: concurrencyKey}
2824
2970
  })
2971
+
2972
+ return {activeCount, concurrencyKey, previousActiveCount}
2973
+ }
2974
+
2975
+ /**
2976
+ * Validates a database count before it participates in reconciliation.
2977
+ * @param {number | string} value - Raw count.
2978
+ * @param {string} concurrencyKey - Counter key for diagnostics.
2979
+ * @returns {number} - Safe non-negative count.
2980
+ */
2981
+ _validatedConcurrencyCount(value, concurrencyKey) {
2982
+ const count = this._normalizeNumber(value)
2983
+
2984
+ if (count === null || !Number.isSafeInteger(count) || count < 0) {
2985
+ throw new Error(`Invalid reconciled background job concurrency count for ${concurrencyKey}: ${count}`)
2986
+ }
2987
+
2988
+ return count
2825
2989
  }
2826
2990
 
2827
2991
  /**
@@ -2836,11 +3000,12 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2836
3000
  * while a backlog exists otherwise leaves persisted rows stale: pre-cap jobs
2837
3001
  * keep a null key and bypass the cap, post-removal jobs stay capped under a
2838
3002
  * now-unconfigured key, and a changed numeric cap stays stale until the next
2839
- * enqueue. Bring the durable state in line with config: sync each configured
2840
- * queue's stored cap, adopt not-yet-keyed non-terminal jobs onto their queue
2841
- * key, and release non-terminal jobs from queue keys whose queue is no
2842
- * longer capped. Runs before {@link _reconcileConcurrency} so the rebuilt
2843
- * active counts reflect the adopted/released keys.
3003
+ * enqueue. Bring queued durable state in line with config: sync each configured
3004
+ * queue's stored cap, adopt not-yet-keyed queued jobs onto their queue key,
3005
+ * and release queued jobs from queue keys whose queue is no longer capped.
3006
+ * Existing handoffs retain the policy and reservation they started with, so
3007
+ * reconciliation cannot race their completion/retry transitions. Runs before
3008
+ * {@link _reconcileConcurrency} so any pre-existing active counts are exact.
2844
3009
  * @param {import("../database/drivers/base.js").default} db - Database connection.
2845
3010
  * @returns {Promise<void>} - Resolves when reconciled.
2846
3011
  */
@@ -2853,7 +3018,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2853
3018
  const keyColumn = db.quoteColumn("concurrency_key")
2854
3019
  const capColumn = db.quoteColumn("max_concurrency")
2855
3020
  const queueColumn = db.quoteColumn("queue")
2856
- const nonTerminal = `${db.quoteColumn("status")} IN (${db.quote("queued")}, ${db.quote("handed_off")})`
3021
+ const queued = `${db.quoteColumn("status")} = ${db.quote("queued")}`
2857
3022
  /** @type {Set<string>} */
2858
3023
  const cappedQueues = new Set()
2859
3024
 
@@ -2868,7 +3033,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2868
3033
  await this._ensureQueueConcurrencyKey(db, {concurrencyKey, maxConcurrency: cap})
2869
3034
  await db.query(
2870
3035
  `UPDATE ${jobsTable} SET ${keyColumn} = ${db.quote(concurrencyKey)}, ${capColumn} = ${Number(cap)} ` +
2871
- `WHERE ${queueColumn} = ${db.quote(queue)} AND ${keyColumn} IS NULL AND ${nonTerminal}`
3036
+ `WHERE ${queueColumn} = ${db.quote(queue)} AND ${keyColumn} IS NULL AND ${queued}`
2872
3037
  )
2873
3038
  }
2874
3039
 
@@ -2887,7 +3052,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2887
3052
 
2888
3053
  await db.query(
2889
3054
  `UPDATE ${jobsTable} SET ${keyColumn} = NULL, ${capColumn} = NULL ` +
2890
- `WHERE ${keyColumn} = ${db.quote(concurrencyKey)} AND ${nonTerminal}`
3055
+ `WHERE ${keyColumn} = ${db.quote(concurrencyKey)} AND ${queued}`
2891
3056
  )
2892
3057
  }
2893
3058
  }
@@ -3025,15 +3190,29 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
3025
3190
  }
3026
3191
 
3027
3192
  /**
3028
- * Admits transactions to the process-local FIFO before they check out a
3029
- * connection. Cross-process ordering remains the responsibility of durable
3030
- * row/advisory locks and unique constraints acquired around the callback.
3193
+ * Runs a serialized callback inside one transaction.
3031
3194
  * @template T
3032
3195
  * @param {(db: import("../database/drivers/base.js").default) => Promise<T>} callback - Transaction callback.
3033
3196
  * @param {BackgroundJobTransactionSerializationOptions} [options] - Serialization options.
3034
3197
  * @returns {Promise<T>} Callback result.
3035
3198
  */
3036
3199
  async _serializedTransactionMutation(callback, options = {}) {
3200
+ return await this._serializedConnectionMutation(
3201
+ async (db) => await this._transactionResult(db, async () => await callback(db)),
3202
+ options
3203
+ )
3204
+ }
3205
+
3206
+ /**
3207
+ * Admits mutation callbacks to the process-local FIFO before they check out a
3208
+ * connection. Cross-process ordering remains the responsibility of durable
3209
+ * row/advisory locks and unique constraints acquired around the callback.
3210
+ * @template T
3211
+ * @param {(db: import("../database/drivers/base.js").default) => Promise<T>} callback - Connection callback.
3212
+ * @param {BackgroundJobTransactionSerializationOptions} [options] - Serialization options.
3213
+ * @returns {Promise<T>} Callback result.
3214
+ */
3215
+ async _serializedConnectionMutation(callback, options = {}) {
3037
3216
  const identifier = this.getDatabaseIdentifier() || "default"
3038
3217
  const previous = transactionMutationChains.get(identifier) || Promise.resolve()
3039
3218
  let resolveRun = () => {}
@@ -3057,7 +3236,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
3057
3236
  }
3058
3237
 
3059
3238
  try {
3060
- return await this._transactionResult(db, async () => await callback(db))
3239
+ return await callback(db)
3061
3240
  } finally {
3062
3241
  if (advisoryLock) await db.releaseAdvisoryLock(advisoryLock.name)
3063
3242
  }
@@ -51,6 +51,20 @@
51
51
  * @property {number} maxConcurrency - Positive cap.
52
52
  * @property {boolean} queueDerived - Whether queue configuration owns the cap.
53
53
  */
54
+ /**
55
+ * @typedef {object} BackgroundJobConcurrencyRepair
56
+ * @property {number} activeCount - Exact handed-off job count persisted by the repair.
57
+ * @property {string} concurrencyKey - Durable cap identity.
58
+ * @property {number} previousActiveCount - Persisted count replaced by the repair.
59
+ */
60
+ /**
61
+ * @typedef {object} BackgroundJobConcurrencyReconciliation
62
+ * @property {number} candidateCount - Snapshot mismatches rechecked under their counter locks.
63
+ * @property {number} checkedCount - Active or nonzero durable counters compared in the initial snapshot.
64
+ * @property {number} repairedCount - Counters whose persisted values were changed.
65
+ * @property {BackgroundJobConcurrencyRepair[]} repairs - Bounded deterministic sample of applied repairs.
66
+ * @property {number} repairsTruncatedCount - Applied repairs omitted from the sample.
67
+ */
54
68
  /**
55
69
  * @typedef {object} PreparedLocalBackgroundJob
56
70
  * @property {string} argsDigest - Fixed-width digest of the serialized arguments.
@@ -78,6 +92,7 @@
78
92
  * @typedef {object} BackgroundJobHandoff
79
93
  * @property {string} handoffId - Unique handoff lease id.
80
94
  * @property {number} handedOffAtMs - Time handed to a worker in ms.
95
+ * @property {BackgroundJobRow} [job] - Exact committed job snapshot when the adapter changes dispatch data during the claim.
81
96
  */
82
97
  /**
83
98
  * @typedef {object} BackgroundJobHandoffSnapshot
@@ -450,7 +450,7 @@
450
450
  * @property {string[]} [builtInMemberCommands] - Built-in member command names (`find`, `update`, `destroy`, `attach`, `download`, `url`).
451
451
  * @property {string} [modelName] - Frontend model name override.
452
452
  * @property {string[]} [relationships] - Relationship names to expose in frontend models. Type and target model are inferred from the backend model's registered relationships.
453
- * @property {string} [primaryKey] - Primary key attribute name.
453
+ * @property {string | string[]} [primaryKey] - Primary key attribute name or ordered composite primary-key attribute names.
454
454
  * @property {FrontendModelResourceServerConfiguration} [server] - Optional legacy backend behavior overrides for built-in frontend actions.
455
455
  * @property {FrontendModelResourceSyncConfiguration | boolean} [sync] - Optional safe local/offline sync policy metadata. `policy` participates in the hash but is not exposed to generated frontend config/manifest.
456
456
  */