velocious 1.0.643 → 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 (279) hide show
  1. package/README.md +21 -10
  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/assets/cache.d.ts +288 -0
  152. package/build/src/sync/assets/cache.d.ts.map +1 -0
  153. package/build/src/sync/assets/cache.js +883 -0
  154. package/build/src/sync/assets/types.d.ts +199 -0
  155. package/build/src/sync/assets/types.d.ts.map +1 -0
  156. package/build/src/sync/assets/types.js +56 -0
  157. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  158. package/build/src/sync/sync-api-client.js +4 -3
  159. package/build/src/sync/sync-client.d.ts.map +1 -1
  160. package/build/src/sync/sync-client.js +3 -2
  161. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  162. package/build/src/sync/sync-envelope-replay-service.js +6 -5
  163. package/build/src/sync/sync-publisher.d.ts.map +1 -1
  164. package/build/src/sync/sync-publisher.js +4 -3
  165. package/build/src/testing/browser-frontend-model-event-hook-scenarios.d.ts +2 -2
  166. package/build/src/testing/browser-frontend-model-event-hook-scenarios.d.ts.map +1 -1
  167. package/build/src/testing/browser-frontend-model-event-hook-scenarios.js +3 -3
  168. package/build/src/testing/test-profiler.d.ts +4 -4
  169. package/build/src/testing/test-profiler.d.ts.map +1 -1
  170. package/build/src/testing/test-profiler.js +3 -3
  171. package/build/src/testing/test-runner.d.ts +279 -117
  172. package/build/src/testing/test-runner.d.ts.map +1 -1
  173. package/build/src/testing/test-runner.js +503 -933
  174. package/build/src/testing/test.d.ts +11 -72
  175. package/build/src/testing/test.d.ts.map +1 -1
  176. package/build/src/testing/test.js +131 -284
  177. package/build/src/testing/velocious-attempt-executor.d.ts +54 -0
  178. package/build/src/testing/velocious-attempt-executor.d.ts.map +1 -0
  179. package/build/src/testing/velocious-attempt-executor.js +408 -0
  180. package/build/src/testing/velocious-runner-reporter.d.ts +67 -0
  181. package/build/src/testing/velocious-runner-reporter.d.ts.map +1 -0
  182. package/build/src/testing/velocious-runner-reporter.js +240 -0
  183. package/build/src/testing/velocious-suite-hook-executor.d.ts +19 -0
  184. package/build/src/testing/velocious-suite-hook-executor.d.ts.map +1 -0
  185. package/build/src/testing/velocious-suite-hook-executor.js +41 -0
  186. package/build/src/testing/velocious-test-arguments.d.ts +39 -0
  187. package/build/src/testing/velocious-test-arguments.d.ts.map +1 -0
  188. package/build/src/testing/velocious-test-arguments.js +55 -0
  189. package/build/src/utils/model-primary-key.d.ts +73 -0
  190. package/build/src/utils/model-primary-key.d.ts.map +1 -0
  191. package/build/src/utils/model-primary-key.js +144 -0
  192. package/build/src/utils/sha256-bytes-hex.d.ts +7 -0
  193. package/build/src/utils/sha256-bytes-hex.d.ts.map +1 -0
  194. package/build/src/utils/sha256-bytes-hex.js +117 -0
  195. package/build/src/utils/sha256-hex.d.ts +2 -4
  196. package/build/src/utils/sha256-hex.d.ts.map +1 -1
  197. package/build/src/utils/sha256-hex.js +6 -120
  198. package/build/sync/assets/cache.js +998 -0
  199. package/build/sync/assets/types.js +62 -0
  200. package/build/sync/sync-api-client.js +3 -2
  201. package/build/sync/sync-client.js +2 -1
  202. package/build/sync/sync-envelope-replay-service.js +5 -4
  203. package/build/sync/sync-publisher.js +3 -2
  204. package/build/testing/browser-frontend-model-event-hook-scenarios.js +2 -2
  205. package/build/testing/test-profiler.js +2 -2
  206. package/build/testing/test-runner.js +496 -990
  207. package/build/testing/test.js +164 -306
  208. package/build/testing/velocious-attempt-executor.js +441 -0
  209. package/build/testing/velocious-runner-reporter.js +264 -0
  210. package/build/testing/velocious-suite-hook-executor.js +43 -0
  211. package/build/testing/velocious-test-arguments.js +64 -0
  212. package/build/tsconfig.tsbuildinfo +1 -1
  213. package/build/utils/model-primary-key.js +171 -0
  214. package/build/utils/sha256-bytes-hex.js +132 -0
  215. package/build/utils/sha256-hex.js +7 -135
  216. package/package.json +5 -4
  217. package/src/authorization/ability.js +36 -13
  218. package/src/background-jobs/adapter.js +9 -0
  219. package/src/background-jobs/main.js +35 -10
  220. package/src/background-jobs/store.js +217 -38
  221. package/src/background-jobs/types.js +15 -0
  222. package/src/configuration-types.js +1 -1
  223. package/src/configuration.js +45 -9
  224. package/src/database/drivers/base-column.js +8 -0
  225. package/src/database/drivers/mssql/sql/update.js +1 -3
  226. package/src/database/drivers/mysql/sql/update.js +1 -3
  227. package/src/database/drivers/pgsql/column.js +22 -0
  228. package/src/database/drivers/pgsql/sql/update.js +1 -3
  229. package/src/database/drivers/sqlite/sql/alter-table.js +11 -1
  230. package/src/database/drivers/sqlite/sql/update.js +1 -3
  231. package/src/database/query/index.js +2 -2
  232. package/src/database/query/model-class-query.js +26 -10
  233. package/src/database/query/preloader/has-many.js +4 -1
  234. package/src/database/query/query-data.js +2 -1
  235. package/src/database/query/update-base.js +14 -0
  236. package/src/database/query/with-count.js +3 -1
  237. package/src/database/record/acts-as-list.js +37 -13
  238. package/src/database/record/attachments/store.js +336 -26
  239. package/src/database/record/auditing.js +6 -3
  240. package/src/database/record/index.js +197 -42
  241. package/src/database/record/instance-relationships/belongs-to.js +2 -1
  242. package/src/database/record/validators/uniqueness.js +8 -5
  243. package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +24 -8
  244. package/src/frontend-model-controller.js +136 -53
  245. package/src/frontend-model-resource/base-resource.js +87 -37
  246. package/src/frontend-model-resource/velocious-attachment-resource.js +98 -31
  247. package/src/frontend-models/base.js +307 -59
  248. package/src/frontend-models/preloader.js +6 -3
  249. package/src/frontend-models/query.js +15 -6
  250. package/src/frontend-models/resource-definition.js +21 -2
  251. package/src/frontend-models/use-destroyed-event.js +1 -1
  252. package/src/frontend-models/use-model-class-event.js +2 -2
  253. package/src/frontend-models/use-updated-event.js +1 -1
  254. package/src/frontend-models/websocket-channel.js +256 -93
  255. package/src/frontend-models/websocket-publishers.js +218 -18
  256. package/src/http-server/client/websocket-session.js +13 -1
  257. package/src/http-server/websocket-channel.js +15 -2
  258. package/src/sync/assets/cache.js +998 -0
  259. package/src/sync/assets/types.js +62 -0
  260. package/src/sync/sync-api-client.js +3 -2
  261. package/src/sync/sync-client.js +2 -1
  262. package/src/sync/sync-envelope-replay-service.js +5 -4
  263. package/src/sync/sync-publisher.js +3 -2
  264. package/src/testing/browser-frontend-model-event-hook-scenarios.js +2 -2
  265. package/src/testing/test-profiler.js +2 -2
  266. package/src/testing/test-runner.js +496 -990
  267. package/src/testing/test.js +164 -306
  268. package/src/testing/velocious-attempt-executor.js +441 -0
  269. package/src/testing/velocious-runner-reporter.js +264 -0
  270. package/src/testing/velocious-suite-hook-executor.js +43 -0
  271. package/src/testing/velocious-test-arguments.js +64 -0
  272. package/src/utils/model-primary-key.js +171 -0
  273. package/src/utils/sha256-bytes-hex.js +132 -0
  274. package/src/utils/sha256-hex.js +7 -135
  275. package/build/src/testing/testing-package-adapter.d.ts +0 -9
  276. package/build/src/testing/testing-package-adapter.d.ts.map +0 -1
  277. package/build/src/testing/testing-package-adapter.js +0 -69
  278. package/build/testing/testing-package-adapter.js +0 -76
  279. package/src/testing/testing-package-adapter.js +0 -76
@@ -1,12 +1,19 @@
1
1
  // @ts-check
2
2
 
3
3
  import VelociousWebsocketChannel from "../http-server/websocket-channel.js"
4
+ import {Buffer} from "node:buffer"
4
5
  import Response from "../http-server/client/response.js"
5
- import {serializeFrontendModelTransportValue} from "./transport-serialization.js"
6
+ import {frontendModelResourcesWithBuiltInsForBackendProject} from "./built-in-resources.js"
7
+ import {frontendModelResourceClassFromDefinition} from "./resource-definition.js"
8
+ import {deserializeFrontendModelTransportValue, serializeFrontendModelTransportValue} from "./transport-serialization.js"
9
+ import {modelPrimaryKeyConditions} from "../utils/model-primary-key.js"
6
10
 
7
11
  /**
8
12
  * Defines this typedef.
9
- * @typedef {{action?: string, id?: string | number, matchedEventFilterKeys?: string[], record?: import("./query.js").FrontendModelTransportValue, [key: string]: import("./query.js").FrontendModelTransportValue | string[] | undefined}} FrontendModelLifecycleBroadcastBody
13
+ * @typedef {{action?: string, id?: import("../utils/model-primary-key.js").ModelPrimaryKeyValue, matchedEventFilterKeys?: string[], previousId?: import("../utils/model-primary-key.js").ModelPrimaryKeyValue, record?: import("./query.js").FrontendModelTransportValue, [key: string]: import("./query.js").FrontendModelTransportValue | string[] | undefined}} FrontendModelLifecycleBroadcastBody
14
+ */
15
+ /**
16
+ * @typedef {Record<string, import("./query.js").FrontendModelTransportValue>} DestroyAuthorizationRecord
10
17
  */
11
18
  /**
12
19
  * Defines this typedef.
@@ -22,6 +29,69 @@ const EVENT_FILTER_KEYS = new Set(["joins", "key", "searches", "where"])
22
29
  // to avoid the configuration → logger → websocket-publishers import cycle.
23
30
  const FRONTEND_MODELS_CHANNEL_NAME = "frontend-models"
24
31
 
32
+ /**
33
+ * Checks whether a server-side broadcast value is a destroy-authorization record.
34
+ * @param {import("./query.js").FrontendModelTransportValue | undefined} value - Candidate value.
35
+ * @returns {value is DestroyAuthorizationRecord} - Whether the value is a column-keyed record.
36
+ */
37
+ function isDestroyAuthorizationRecord(value) {
38
+ return Boolean(value && typeof value === "object" && !Array.isArray(value))
39
+ }
40
+
41
+ /**
42
+ * Checks whether a captured value is a serialized binary-column marker.
43
+ * @param {import("./query.js").FrontendModelTransportValue} value - Captured column value.
44
+ * @returns {value is {__velociousDestroyAuthorizationType: "binary", value: number[]}} - Whether the value contains serialized bytes.
45
+ */
46
+ function isDestroyAuthorizationBinary(value) {
47
+ return Boolean(
48
+ value
49
+ && typeof value === "object"
50
+ && !Array.isArray(value)
51
+ && "__velociousDestroyAuthorizationType" in value
52
+ && value.__velociousDestroyAuthorizationType === "binary"
53
+ && "value" in value
54
+ && Array.isArray(value.value)
55
+ && value.value.every((byte) => typeof byte === "number")
56
+ )
57
+ }
58
+
59
+ /**
60
+ * Builds a PostgreSQL array expression whose elements are quoted by the active driver.
61
+ * @param {import("../database/drivers/base.js").default} driver - Active PostgreSQL driver.
62
+ * @param {import("./query.js").FrontendModelTransportValue[]} values - Captured array values.
63
+ * @returns {string} - PostgreSQL array expression.
64
+ */
65
+ function pgsqlArrayValueSql(driver, values) {
66
+ const elements = values.map((value) => {
67
+ if (Array.isArray(value)) return pgsqlArrayValueSql(driver, value)
68
+ if (value === null) return "NULL"
69
+
70
+ return driver.quote(value)
71
+ })
72
+
73
+ return `ARRAY[${elements.join(", ")}]`
74
+ }
75
+
76
+ /**
77
+ * Resolves frontend resource identity attributes to backing database columns.
78
+ * @param {typeof import("../database/record/index.js").default} ModelClass - Backing model class.
79
+ * @param {import("../utils/model-primary-key.js").ModelPrimaryKeyDefinition} primaryKey - Frontend resource identity definition.
80
+ * @param {import("../utils/model-primary-key.js").ModelPrimaryKeyValue} id - Frontend resource identity.
81
+ * @returns {Record<string, import("../utils/model-primary-key.js").ModelPrimaryKeyScalar>} - Backing column conditions.
82
+ */
83
+ function frontendModelPrimaryKeyDatabaseConditions(ModelClass, primaryKey, id) {
84
+ const resourceConditions = modelPrimaryKeyConditions(primaryKey, id)
85
+ /** @type {Record<string, import("../utils/model-primary-key.js").ModelPrimaryKeyScalar>} */
86
+ const databaseConditions = {}
87
+
88
+ for (const [attributeName, value] of Object.entries(resourceConditions)) {
89
+ databaseConditions[ModelClass.getColumnNameForAttributeName(attributeName)] = value
90
+ }
91
+
92
+ return databaseConditions
93
+ }
94
+
25
95
  /**
26
96
  * Runs transport serialization options for a configuration.
27
97
  * @param {import("../configuration.js").default} configuration - Configuration instance.
@@ -37,15 +107,11 @@ function transportSerializationOptionsForConfiguration(configuration) {
37
107
  * Per-session channel subscription for frontend-model lifecycle events.
38
108
  * Replaces the legacy `FrontendModelWebsocketChannel` (Phase 3).
39
109
  *
40
- * Auth model: subscribe-time only. `canSubscribe` resolves the caller's
41
- * ability once, checks that at least one `allow` rule exists for
42
- * `read` on the requested model class, and then delivers future
43
- * lifecycle broadcasts for that model without re-authorizing per event.
44
- * This matches the explicit design decision in Phase 3 to trade
45
- * per-record visibility guarantees for massively cheaper broadcast fan-out.
46
- * Subscriber-provided event filters can still narrow which create/update
47
- * events are delivered, but they are matching predicates rather than
48
- * per-record authorization checks.
110
+ * `canSubscribe` resolves the caller's ability once and requires a read rule
111
+ * for the requested model class. Create/update delivery then reloads each
112
+ * record through that ability and serializes it through the subscribed
113
+ * frontend resource. Subscriber-provided event filters can further narrow
114
+ * those authorized events.
49
115
  *
50
116
  * Wire: subscribe with `subscribeChannel("frontend-models", {params: {model: ModelName}})`.
51
117
  * Backend publishes `{action, id, record}` via
@@ -69,8 +135,7 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
69
135
  this._eventFilters()
70
136
 
71
137
  const configuration = this.session.configuration
72
- const modelClasses = configuration.getModelClasses()
73
- const ModelClass = modelClasses[modelName]
138
+ const ModelClass = this._modelClass(modelName)
74
139
 
75
140
  if (!ModelClass) return false
76
141
 
@@ -98,10 +163,28 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
98
163
  return readRules.some((/** @type {{effect: string}} */ rule) => rule.effect === "allow")
99
164
  }
100
165
 
166
+ /**
167
+ * Resolves a subscription name through frontend resources before falling back to a backing model name.
168
+ * @param {string} modelName - Frontend resource name.
169
+ * @returns {typeof import("../database/record/index.js").default | undefined} - Backing model class.
170
+ */
171
+ _modelClass(modelName) {
172
+ const configuration = this.session.configuration
173
+
174
+ for (const backendProject of configuration.getBackendProjects()) {
175
+ const resourceDefinition = frontendModelResourcesWithBuiltInsForBackendProject(backendProject)[modelName]
176
+ const resourceClass = resourceDefinition ? frontendModelResourceClassFromDefinition(resourceDefinition) : null
177
+
178
+ if (resourceClass?.ModelClass) return resourceClass.modelClass()
179
+ }
180
+
181
+ return configuration.getModelClasses()[modelName]
182
+ }
183
+
101
184
  /**
102
185
  * Runs deliver broadcast.
103
186
  * @param {FrontendModelLifecycleBroadcastBody} body - Broadcast body.
104
- * @param {{eventId?: string}} [meta] - Optional event metadata.
187
+ * @param {import("../http-server/websocket-channel.js").WebsocketBroadcastMetadata} [meta] - Optional server-side broadcast metadata.
105
188
  * @returns {Promise<void>} Resolves after delivery.
106
189
  */
107
190
  async deliverBroadcast(body, meta) {
@@ -111,33 +194,38 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
111
194
  /**
112
195
  * Runs deliver broadcast.
113
196
  * @param {FrontendModelLifecycleBroadcastBody} body - Broadcast body.
114
- * @param {{eventId?: string}} [meta] - Optional event metadata.
197
+ * @param {import("../http-server/websocket-channel.js").WebsocketBroadcastMetadata} [meta] - Optional server-side broadcast metadata.
115
198
  * @returns {Promise<void>} Resolves after delivery.
116
199
  */
117
200
  async _deliverBroadcast(body, meta) {
118
201
  const hasEventFilters = this._hasEventFilterParams()
119
202
 
120
- if (!this._hasProjectionParams() && !hasEventFilters) {
121
- // Even unfiltered subscriptions must respect the subscriber's ability. A create/update carries
122
- // the record, so only deliver it when the record is within the authenticated ability's scope.
123
- // Destroys (and bodies without a usable id) carry no record, so pass them through unchanged.
124
- if (body && typeof body === "object" && (body.action === "create" || body.action === "update") && body.id !== undefined && body.id !== null) {
125
- const FrontendModelController = await this._frontendModelControllerClass()
126
-
127
- if (!await this._eventIsAccessible(body.id, FrontendModelController)) return
128
- }
129
-
130
- this.sendMessage(body, meta)
131
- return
132
- }
133
-
134
203
  if (!body || typeof body !== "object") {
135
204
  if (!hasEventFilters || this._hasUnfilteredEventDelivery()) this.sendMessage(body, meta)
136
205
  return
137
206
  }
138
207
 
208
+ if (typeof body.model === "string" && body.model !== this._modelName()) return
209
+
139
210
  if (body.action === "destroy") {
140
- if (!hasEventFilters || this._hasDestroyEventDelivery() || this._hasUnfilteredEventDelivery()) this.sendMessage(body, meta)
211
+ if (body.id === undefined || body.id === null) return
212
+
213
+ const FrontendModelController = await this._frontendModelControllerClass()
214
+ const authorized = await this._destroyEventIsAuthorized(
215
+ body,
216
+ FrontendModelController,
217
+ meta?.broadcastParams?.destroyAuthorizationRecord
218
+ )
219
+
220
+ if (!authorized) return
221
+
222
+ if (!hasEventFilters || this._hasDestroyEventDelivery() || this._hasUnfilteredEventDelivery()) {
223
+ this.sendMessage({
224
+ action: body.action,
225
+ id: body.id,
226
+ ...(typeof body.model === "string" ? {model: body.model} : {})
227
+ }, meta)
228
+ }
141
229
  return
142
230
  }
143
231
 
@@ -147,34 +235,42 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
147
235
  }
148
236
 
149
237
  const FrontendModelController = await this._frontendModelControllerClass()
150
- const matchedEventFilterKeys = await this._matchedEventFilterKeysForEventId(body.id, FrontendModelController)
238
+ const matchedEventFilterKeys = hasEventFilters
239
+ ? await this._matchedEventFilterKeysForEventId(body.id, FrontendModelController)
240
+ : []
241
+ const isIdentityTransition = body.action === "update" && body.previousId !== undefined && body.previousId !== null
151
242
 
152
- if (hasEventFilters && matchedEventFilterKeys.length === 0 && !this._hasUnfilteredEventDelivery()) {
243
+ if (hasEventFilters && matchedEventFilterKeys.length === 0 && !this._hasUnfilteredEventDelivery() && !isIdentityTransition) {
153
244
  return
154
245
  }
155
246
 
156
- /**
157
- * Deliver body.
158
- * @type {FrontendModelLifecycleBroadcastBody} */
159
- let deliverBody = body
160
-
161
- if (this._hasProjectionParams()) {
162
- const projectedRecord = await this._projectedRecordForEventId(body.id, FrontendModelController)
163
-
164
- if (!projectedRecord) {
165
- return
247
+ const projectedRecord = await this._projectedRecordForEventId(body.id, FrontendModelController)
248
+
249
+ if (!projectedRecord) {
250
+ if (isIdentityTransition) {
251
+ this.sendMessage({
252
+ action: body.action,
253
+ id: body.id,
254
+ ...(hasEventFilters ? {matchedEventFilterKeys} : {}),
255
+ ...(typeof body.model === "string" ? {model: body.model} : {}),
256
+ previousId: body.previousId
257
+ }, meta)
166
258
  }
259
+ return
260
+ }
167
261
 
168
- const configuration = this.session.configuration
262
+ const configuration = this.session.configuration
169
263
 
170
- if (!configuration) {
171
- throw new Error("Frontend model websocket channel has no configuration for transport serialization")
172
- }
264
+ if (!configuration) {
265
+ throw new Error("Frontend model websocket channel has no configuration for transport serialization")
266
+ }
173
267
 
174
- deliverBody = {
175
- ...deliverBody,
176
- record: /** @type {import("./query.js").FrontendModelTransportValue} */ (serializeFrontendModelTransportValue(projectedRecord, transportSerializationOptionsForConfiguration(configuration)))
177
- }
268
+ /**
269
+ * Deliver body.
270
+ * @type {FrontendModelLifecycleBroadcastBody} */
271
+ let deliverBody = {
272
+ ...body,
273
+ record: /** @type {import("./query.js").FrontendModelTransportValue} */ (serializeFrontendModelTransportValue(projectedRecord, transportSerializationOptionsForConfiguration(configuration)))
178
274
  }
179
275
 
180
276
  if (hasEventFilters) {
@@ -187,6 +283,103 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
187
283
  this.sendMessage(deliverBody, meta)
188
284
  }
189
285
 
286
+ /**
287
+ * Requires a resync for relevant destroy events because their authorization
288
+ * snapshots are intentionally excluded from the persisted replay payload.
289
+ * @param {import("../http-server/websocket-channel.js").WebsocketJsonValue} body - Persisted broadcast payload.
290
+ * @returns {boolean} - Whether replay cannot safely authorize this event.
291
+ */
292
+ _requiresReplayGap(body) {
293
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false
294
+ if (!("action" in body) || body.action !== "destroy") return false
295
+ if (!("id" in body) || body.id === undefined || body.id === null) return false
296
+
297
+ return !("model" in body) || typeof body.model !== "string" || body.model === this._modelName()
298
+ }
299
+
300
+ /**
301
+ * Checks a destroy against the subscriber's ordinary authorized query by
302
+ * replacing the deleted backing table with the captured pre-delete row. Values
303
+ * are quoted on this trusted database connection; no broadcast-provided SQL is run.
304
+ * @param {FrontendModelLifecycleBroadcastBody} body - Destroy broadcast body.
305
+ * @param {typeof import("../frontend-model-controller.js").default} FrontendModelController - Server-side frontend-model controller class.
306
+ * @param {import("./query.js").FrontendModelTransportValue | undefined} destroyAuthorizationRecord - Server-only pre-delete record from live broadcast metadata.
307
+ * @returns {Promise<boolean>} - Whether the subscriber could read the record before deletion.
308
+ */
309
+ async _destroyEventIsAuthorized(body, FrontendModelController, destroyAuthorizationRecord) {
310
+ const id = body.id
311
+
312
+ if (id === undefined || id === null || !isDestroyAuthorizationRecord(destroyAuthorizationRecord)) return false
313
+
314
+ return await this._withEventTenant(id, async () => {
315
+ const controller = this._frontendModelController(FrontendModelController)
316
+
317
+ await controller.ensureFrontendModelClassInitialized()
318
+
319
+ const ModelClass = controller.frontendModelClass()
320
+ const primaryKey = controller.frontendModelPrimaryKey()
321
+ const ruleQueryFactory = () => this._destroyAuthorizationQuery(ModelClass, destroyAuthorizationRecord)
322
+ const query = controller.frontendModelAuthorizedQuery("find", {ruleQueryFactory})
323
+
324
+ this._applyDestroyAuthorizationRecordToQuery(query, ModelClass, destroyAuthorizationRecord)
325
+ query.where({
326
+ [ModelClass.tableName()]: frontendModelPrimaryKeyDatabaseConditions(ModelClass, primaryKey, id)
327
+ })
328
+
329
+ return Boolean(await query.first())
330
+ })
331
+ }
332
+
333
+ /**
334
+ * Builds a backing-model query whose source is the captured pre-delete row.
335
+ * @param {typeof import("../database/record/index.js").default} ModelClass - Backing model class.
336
+ * @param {DestroyAuthorizationRecord} destroyAuthorizationRecord - Captured pre-delete record.
337
+ * @returns {import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>} - One-row model query.
338
+ */
339
+ _destroyAuthorizationQuery(ModelClass, destroyAuthorizationRecord) {
340
+ const query = ModelClass._newQuery()
341
+
342
+ this._applyDestroyAuthorizationRecordToQuery(query, ModelClass, destroyAuthorizationRecord)
343
+
344
+ return query
345
+ }
346
+
347
+ /**
348
+ * Replaces a query's backing table with a safely quoted one-row derived table.
349
+ * @param {import("../database/query/model-class-query.js").default<typeof import("../database/record/index.js").default>} query - Query to update.
350
+ * @param {typeof import("../database/record/index.js").default} ModelClass - Backing model class.
351
+ * @param {DestroyAuthorizationRecord} destroyAuthorizationRecord - Captured pre-delete record.
352
+ * @returns {void}
353
+ */
354
+ _applyDestroyAuthorizationRecordToQuery(query, ModelClass, destroyAuthorizationRecord) {
355
+ const selectedColumns = Object.entries(destroyAuthorizationRecord).map(([columnName, serializedValue]) => {
356
+ const value = isDestroyAuthorizationBinary(serializedValue)
357
+ ? Buffer.from(serializedValue.value)
358
+ : deserializeFrontendModelTransportValue(serializedValue)
359
+ const column = ModelClass.getColumnsHash()[columnName]
360
+
361
+ if (!column) throw new Error(`Cannot authorize a destroyed ${ModelClass.name} with unknown column ${columnName}`)
362
+ const quotedValue = query.driver.getType() == "pgsql" && column.getType() === "ARRAY" && Array.isArray(value)
363
+ ? pgsqlArrayValueSql(query.driver, value)
364
+ : value === null ? "NULL" : query.driver.quote(value)
365
+
366
+ const selectedValue = query.driver.getType() == "pgsql"
367
+ ? `CAST(${quotedValue} AS ${column.getDatabaseType()})`
368
+ : quotedValue
369
+
370
+ return `${selectedValue} AS ${query.driver.quoteColumn(columnName)}`
371
+ })
372
+
373
+ if (selectedColumns.length === 0) {
374
+ throw new Error(`Cannot authorize a destroyed ${ModelClass.name} without captured attributes`)
375
+ }
376
+
377
+ const froms = query.getFroms()
378
+
379
+ froms.splice(0, froms.length)
380
+ query.from(`(SELECT ${selectedColumns.join(", ")}) AS ${query.driver.quoteTable(ModelClass.tableName())}`)
381
+ }
382
+
190
383
  /**
191
384
  * Runs matches.
192
385
  * @param {Record<string, import("./query.js").FrontendModelTransportValue>} broadcastParams - Params from `broadcastToChannel`.
@@ -227,19 +420,6 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
227
420
  : null
228
421
  }
229
422
 
230
- /**
231
- * Runs has projection params.
232
- * @returns {boolean} - Whether this subscription requested per-event record projection.
233
- */
234
- _hasProjectionParams() {
235
- return this.params.select !== undefined
236
- || this.params.selectsExtra !== undefined
237
- || this.params.preload !== undefined
238
- || this.params.withCount !== undefined
239
- || this.params.abilities !== undefined
240
- || this.params.queryData !== undefined
241
- }
242
-
243
423
  /**
244
424
  * Runs has event filter params.
245
425
  * @returns {boolean} - Whether this subscription requested event query filters.
@@ -359,7 +539,7 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
359
539
 
360
540
  /**
361
541
  * Resolves tenant for event.
362
- * @param {string | number} id - Event record id.
542
+ * @param {import("../utils/model-primary-key.js").ModelPrimaryKeyValue} id - Event record id.
363
543
  * @returns {Promise<ReturnType<typeof JSON.parse>>} - Resolved tenant.
364
544
  */
365
545
  async _resolveEventTenant(id) {
@@ -390,7 +570,7 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
390
570
  * the subscriber's own tenant/ability scope. When no tenant resolves (non-multitenant configs), the
391
571
  * callback runs directly so the ambient context is preserved.
392
572
  * @template T
393
- * @param {string | number} id - Event record id.
573
+ * @param {import("../utils/model-primary-key.js").ModelPrimaryKeyValue} id - Event record id.
394
574
  * @param {() => Promise<T>} callback - Authorized-query callback.
395
575
  * @returns {Promise<T>} - Callback result.
396
576
  */
@@ -412,30 +592,9 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
412
592
  })
413
593
  }
414
594
 
415
- /**
416
- * Whether the broadcast record is within the subscriber's authenticated ability scope. Used to gate
417
- * unfiltered/unprojected create/update delivery so a scoped token never receives a record it cannot read.
418
- * @param {string | number} id - Event record id.
419
- * @param {typeof import("../frontend-model-controller.js").default} FrontendModelController - Server-side frontend-model controller class.
420
- * @returns {Promise<boolean>} True when the record is readable by this subscription.
421
- */
422
- async _eventIsAccessible(id, FrontendModelController) {
423
- return await this._withEventTenant(id, async () => {
424
- const controller = this._frontendModelController(FrontendModelController)
425
-
426
- await controller.ensureFrontendModelClassInitialized()
427
-
428
- const ModelClass = controller.frontendModelClass()
429
- const primaryKey = ModelClass.primaryKey()
430
- const query = controller.frontendModelAuthorizedQuery("find").where({[ModelClass.tableName()]: {[primaryKey]: id}})
431
-
432
- return Boolean(await query.first())
433
- })
434
- }
435
-
436
595
  /**
437
596
  * Runs matched event filter keys for event id.
438
- * @param {string | number} id - Event record id.
597
+ * @param {import("../utils/model-primary-key.js").ModelPrimaryKeyValue} id - Event record id.
439
598
  * @param {typeof import("../frontend-model-controller.js").default} FrontendModelController - Server-side frontend-model controller class.
440
599
  * @returns {Promise<string[]>} - Event filter keys matched by the record.
441
600
  */
@@ -463,7 +622,7 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
463
622
  * @param {object} args - Filter args.
464
623
  * @param {typeof import("../frontend-model-controller.js").default} args.FrontendModelController - Server-side frontend-model controller class.
465
624
  * @param {import("./query.js").FrontendModelEventFilterPayloadEntry} args.eventFilter - Event filter payload.
466
- * @param {string | number} args.id - Event record id.
625
+ * @param {import("../utils/model-primary-key.js").ModelPrimaryKeyValue} args.id - Event record id.
467
626
  * @returns {Promise<boolean>} Whether the record matches the filter.
468
627
  */
469
628
  async _eventMatchesFilter({FrontendModelController, eventFilter, id}) {
@@ -477,12 +636,14 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
477
636
  await controller.ensureFrontendModelClassInitialized()
478
637
 
479
638
  const ModelClass = controller.frontendModelClass()
480
- const primaryKey = ModelClass.primaryKey()
639
+ const primaryKey = controller.frontendModelPrimaryKey()
481
640
  const where = controller.frontendModelWhere()
482
641
  const joins = controller.frontendModelJoins()
483
642
  // Start from the subscriber's authorized scope so a filter can only ever match records the
484
643
  // subscription's ability permits to read.
485
- let query = controller.frontendModelAuthorizedQuery("find").where({[ModelClass.tableName()]: {[primaryKey]: id}})
644
+ let query = controller.frontendModelAuthorizedQuery("find").where({
645
+ [ModelClass.tableName()]: frontendModelPrimaryKeyDatabaseConditions(ModelClass, primaryKey, id)
646
+ })
486
647
 
487
648
  if (where) controller.applyFrontendModelWhere({query, where})
488
649
  if (joins) controller.applyFrontendModelJoins({joins, query})
@@ -497,7 +658,7 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
497
658
 
498
659
  /**
499
660
  * Runs projected record for event id.
500
- * @param {string | number} id - Event record id.
661
+ * @param {import("../utils/model-primary-key.js").ModelPrimaryKeyValue} id - Event record id.
501
662
  * @param {typeof import("../frontend-model-controller.js").default} FrontendModelController - Server-side frontend-model controller class.
502
663
  * @returns {Promise<Record<string, import("./query.js").FrontendModelTransportValue> | null>} - Serialized projected record.
503
664
  */
@@ -508,10 +669,12 @@ export default class FrontendModelWebsocketChannel extends VelociousWebsocketCha
508
669
  await controller.ensureFrontendModelClassInitialized()
509
670
 
510
671
  const ModelClass = controller.frontendModelClass()
511
- const primaryKey = ModelClass.primaryKey()
672
+ const primaryKey = controller.frontendModelPrimaryKey()
512
673
  // Reload through the subscriber's authorized scope so projected records are only ever sent for
513
674
  // rows the subscription's ability permits to read.
514
- let query = controller.frontendModelAuthorizedQuery("find").where({[ModelClass.tableName()]: {[primaryKey]: id}})
675
+ let query = controller.frontendModelAuthorizedQuery("find").where({
676
+ [ModelClass.tableName()]: frontendModelPrimaryKeyDatabaseConditions(ModelClass, primaryKey, id)
677
+ })
515
678
  const preload = controller.frontendModelPreload()
516
679
 
517
680
  if (preload) query = query.preload(preload)