velocious 1.0.650 → 1.0.652

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.
@@ -0,0 +1,137 @@
1
+ // @ts-check
2
+
3
+ import Logger from "../../logger.js"
4
+ import {ensureError} from "typanic"
5
+
6
+ /** @typedef {(parent: import("./index.js").default, previousParent: import("./index.js").default | undefined) => void | Promise<void>} CounterCacheParentUpdateListener */
7
+ /** @typedef {{canonicalParentModelClass: typeof import("./index.js").default, listeners: CounterCacheParentUpdateListener[], parentId: ReturnType<typeof JSON.parse>, parentPrimaryKey: string, parentQuery: import("../query/model-class-query.js").default<typeof import("./index.js").default>, previousParent: import("./index.js").default | undefined}} PreparedCounterCacheParentUpdate */
8
+
9
+ /** @type {WeakMap<typeof import("./index.js").default, Set<CounterCacheParentUpdateListener>>} */
10
+ const listenersByParentModelClass = new WeakMap()
11
+
12
+ /**
13
+ * Registers an internal listener for committed counter-cache parent updates.
14
+ * @param {typeof import("./index.js").default} parentModelClass - Parent model class.
15
+ * @param {CounterCacheParentUpdateListener} listener - Committed-parent listener.
16
+ * @returns {() => void} - Listener removal callback.
17
+ */
18
+ export function registerCounterCacheParentUpdateListener(parentModelClass, listener) {
19
+ const canonicalParentModelClass = parentModelClass.canonicalRecordMetadataModelClass()
20
+ let listeners = listenersByParentModelClass.get(canonicalParentModelClass)
21
+
22
+ if (!listeners) {
23
+ listeners = new Set()
24
+ listenersByParentModelClass.set(canonicalParentModelClass, listeners)
25
+ }
26
+
27
+ listeners.add(listener)
28
+
29
+ return () => listeners.delete(listener)
30
+ }
31
+
32
+ /**
33
+ * Captures one counter-cache parent's pre-mutation state when listeners are registered.
34
+ * @param {object} args - Parent update arguments.
35
+ * @param {ReturnType<typeof JSON.parse>} args.parentId - Parent relationship identity.
36
+ * @param {typeof import("./index.js").default} args.parentModelClass - Parent model class.
37
+ * @param {string} args.parentPrimaryKey - Parent relationship primary key.
38
+ * @param {import("../query/model-class-query.js").default<typeof import("./index.js").default>} args.parentQuery - Source-owned parent query.
39
+ * @returns {Promise<PreparedCounterCacheParentUpdate | undefined>} - Prepared delivery, or undefined when no listener is registered.
40
+ */
41
+ export async function prepareCounterCacheParentUpdate({parentId, parentModelClass, parentPrimaryKey, parentQuery}) {
42
+ const canonicalParentModelClass = parentModelClass.canonicalRecordMetadataModelClass()
43
+ const registeredListeners = listenersByParentModelClass.get(canonicalParentModelClass)
44
+
45
+ if (!registeredListeners || registeredListeners.size == 0) return
46
+ const previousParent = await parentQuery.findBy({[parentPrimaryKey]: parentId}) || undefined
47
+
48
+ return {
49
+ canonicalParentModelClass,
50
+ listeners: [...registeredListeners],
51
+ parentId,
52
+ parentPrimaryKey,
53
+ parentQuery,
54
+ previousParent
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Schedules one non-coalesced parent reload and notification on the source record's commit lifecycle.
60
+ * @param {object} args - Parent update arguments.
61
+ * @param {PreparedCounterCacheParentUpdate | undefined} args.preparedUpdate - Pre-mutation parent delivery state.
62
+ * @param {import("./index.js").default} args.sourceRecord - Source record that owns the transaction lifecycle.
63
+ * @returns {Promise<void>} - Resolves after registration or immediate delivery.
64
+ */
65
+ export async function scheduleCounterCacheParentUpdate({preparedUpdate, sourceRecord}) {
66
+ if (!preparedUpdate) return
67
+
68
+ const {canonicalParentModelClass, listeners, parentId, parentPrimaryKey, parentQuery, previousParent} = preparedUpdate
69
+
70
+ await sourceRecord.connection().afterCommit(async () => {
71
+ let parent
72
+
73
+ try {
74
+ parent = await parentQuery.findBy({[parentPrimaryKey]: parentId})
75
+ } catch (error) {
76
+ await reportCounterCacheParentUpdateError(canonicalParentModelClass._getConfiguration(), error)
77
+ return
78
+ }
79
+
80
+ if (!parent) return
81
+
82
+ for (const listener of listeners) {
83
+ try {
84
+ await listener(parent, previousParent)
85
+ } catch (error) {
86
+ await reportCounterCacheParentUpdateError(parent._getConfiguration(), error)
87
+ }
88
+ }
89
+ })
90
+ }
91
+
92
+ /**
93
+ * Reports a post-commit delivery failure without rejecting the durable source operation.
94
+ * @param {import("../../configuration.js").default} configuration - Owning configuration.
95
+ * @param {ReturnType<typeof JSON.parse>} caughtError - Reload or listener failure.
96
+ * @returns {Promise<void>} - Resolves after best-effort reporting.
97
+ */
98
+ async function reportCounterCacheParentUpdateError(configuration, caughtError) {
99
+ const error = ensureError(caughtError)
100
+ const payload = {
101
+ context: {stage: "counter-cache-parent-update-after-commit"},
102
+ error
103
+ }
104
+ /** @type {ReturnType<typeof JSON.parse>[]} */
105
+ const reportingErrors = []
106
+ let errorEvents
107
+
108
+ try {
109
+ errorEvents = configuration.getErrorEvents()
110
+ } catch (reportingError) {
111
+ reportingErrors.push(reportingError)
112
+ }
113
+
114
+ if (errorEvents) {
115
+ try {
116
+ errorEvents.emit("framework-error", payload)
117
+ } catch (reportingError) {
118
+ reportingErrors.push(reportingError)
119
+ }
120
+
121
+ try {
122
+ errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
123
+ } catch (reportingError) {
124
+ reportingErrors.push(reportingError)
125
+ }
126
+ }
127
+
128
+ if (reportingErrors.length == 0) return
129
+
130
+ try {
131
+ const logger = new Logger("CounterCacheParentUpdates", {configuration})
132
+
133
+ await logger.error("Counter-cache parent update error reporting failed", {error, reportingErrors})
134
+ } catch {
135
+ console.error("Counter-cache parent update error reporting failed")
136
+ }
137
+ }
@@ -56,6 +56,7 @@ import {formatValue} from "../../utils/format-value.js"
56
56
  import {modelPrimaryKeyCacheKey, modelPrimaryKeyConditions, readModelPrimaryKeyValue, scalarModelPrimaryKey, scalarModelPrimaryKeyValue} from "../../utils/model-primary-key.js"
57
57
  import {captureCreateAuditChanges, captureUpdateAuditChanges, createAudit, createCreateAudit, createDestroyAudit, createUpdateAudit, initializeAuditing, registerAuditCallback, registerAuditing, withoutAudit} from "./auditing.js"
58
58
  import {registerMagnitudeCounterCache} from "./counter-cache-magnitude.js"
59
+ import {prepareCounterCacheParentUpdate, scheduleCounterCacheParentUpdate} from "./counter-cache-parent-updates.js"
59
60
  import {stateMachine} from "./state-machine.js"
60
61
  import ValidatorsFormat from "./validators/format.js"
61
62
  import ValidatorsLength from "./validators/length.js"
@@ -999,14 +1000,23 @@ class VelociousDatabaseRecord {
999
1000
  const parentTable = ParentModel.tableName()
1000
1001
  const childTable = ChildModel.tableName()
1001
1002
  const pkColumn = inflection.underscore(primaryKey)
1002
- const connection = record
1003
- .queryForModel(ParentModel)
1004
- .driver
1003
+ const parentQuery = record.queryForModel(ParentModel)
1004
+ const connection = parentQuery.driver
1005
1005
  const quoted = connection.quote(parentId)
1006
+ const preparedParentUpdate = await prepareCounterCacheParentUpdate({
1007
+ parentId,
1008
+ parentModelClass: ParentModel,
1009
+ parentPrimaryKey: primaryKey,
1010
+ parentQuery
1011
+ })
1006
1012
 
1007
1013
  const sql = `UPDATE ${connection.quoteTable(parentTable)} SET ${connection.quoteColumn(counterColumn)} = (SELECT COUNT(*) FROM ${connection.quoteTable(childTable)} WHERE ${connection.quoteColumn(fk)} = ${quoted}) WHERE ${connection.quoteColumn(pkColumn)} = ${quoted}`
1008
1014
 
1009
1015
  await connection.query(sql, {logName: `${ParentModel.name} Update`})
1016
+ await scheduleCounterCacheParentUpdate({
1017
+ preparedUpdate: preparedParentUpdate,
1018
+ sourceRecord: record
1019
+ })
1010
1020
  }
1011
1021
 
1012
1022
  /**
@@ -5,12 +5,14 @@ import {frontendModelResourcesWithBuiltInsForBackendProject} from "./built-in-re
5
5
  import {frontendModelResourceDefinitionIsClass} from "./resource-definition.js"
6
6
  import {serializeFrontendModelTransportValue} from "./transport-serialization.js"
7
7
  import {modelPrimaryKeyCacheKey, readModelPrimaryKeyValue} from "../utils/model-primary-key.js"
8
+ import {registerCounterCacheParentUpdateListener} from "../database/record/counter-cache-parent-updates.js"
8
9
 
9
10
  /** @typedef {{primaryKey: import("../utils/model-primary-key.js").ModelPrimaryKeyDefinition}} FrontendModelPublisherResource */
10
11
  /** @typedef {Record<string, import("./query.js").FrontendModelTransportValue>} FrontendModelDestroyAuthorizationRecord */
11
12
  /** @typedef {import("../database/record/index.js").default & {__frontendModelWebsocketAction?: "create" | "update", __frontendModelWebsocketDestroyAuthorizationRecord?: FrontendModelDestroyAuthorizationRecord, __frontendModelWebsocketPreviousIds?: Map<string, import("../utils/model-primary-key.js").ModelPrimaryKeyValue>}} FrontendModelWebsocketRecord */
12
13
 
13
14
  const modelClassesWithRegisteredHooks = new WeakSet()
15
+ const modelClassesWithRegisteredCounterCacheParentListeners = new WeakSet()
14
16
  const channelClassRegisteredConfigurations = new WeakSet()
15
17
  /** @type {WeakMap<import("../configuration.js").default, WeakMap<typeof import("../database/record/index.js").default, Map<string, FrontendModelPublisherResource>>>} */
16
18
  const publisherResourcesByConfiguration = new WeakMap()
@@ -127,6 +129,7 @@ export async function ensureFrontendModelWebsocketPublishersRegistered(configura
127
129
  if (!resourceClass.ModelClass) continue
128
130
 
129
131
  const modelClass = resourceClass.modelClass()
132
+ const canonicalModelClass = modelClass.canonicalRecordMetadataModelClass()
130
133
  const resourceConfiguration = resourceClass.resourceConfig()
131
134
  const configuredPrimaryKey = resourceConfiguration.primaryKey
132
135
  const modelPrimaryKey = modelClass.primaryKey()
@@ -140,38 +143,47 @@ export async function ensureFrontendModelWebsocketPublishersRegistered(configura
140
143
  publisherResourcesByConfiguration.set(configuration, publisherResourcesByModelClass)
141
144
  }
142
145
 
143
- let publisherResources = publisherResourcesByModelClass.get(modelClass)
146
+ let publisherResources = publisherResourcesByModelClass.get(canonicalModelClass)
144
147
 
145
148
  if (!publisherResources) {
146
149
  publisherResources = new Map()
147
- publisherResourcesByModelClass.set(modelClass, publisherResources)
150
+ publisherResourcesByModelClass.set(canonicalModelClass, publisherResources)
148
151
  }
149
152
 
150
153
  publisherResources.set(modelName, {
151
154
  primaryKey
152
155
  })
153
156
 
157
+ if (!modelClassesWithRegisteredCounterCacheParentListeners.has(canonicalModelClass)) {
158
+ modelClassesWithRegisteredCounterCacheParentListeners.add(canonicalModelClass)
159
+ registerCounterCacheParentUpdateListener(canonicalModelClass, (parent, previousParent) => {
160
+ const previousIds = previousParent ? frontendModelResourceIdentities(previousParent) : undefined
161
+
162
+ broadcastFrontendModelEvents(parent, "update", previousIds)
163
+ })
164
+ }
165
+
154
166
  // Register lifecycle hooks once per model class, not per configuration. A model class belongs to a
155
167
  // single backend project/config in production, so per-config registration only differs in tests where
156
168
  // the same model class is reachable from multiple configs — there it attaches duplicate beforeCreate/
157
169
  // afterSave/afterDestroy hooks that double-fire broadcasts (and leak across specs). The hooks read the
158
170
  // model's runtime configuration when broadcasting, so a single registration is sufficient.
159
- if (modelClassesWithRegisteredHooks.has(modelClass)) continue
171
+ if (modelClassesWithRegisteredHooks.has(canonicalModelClass)) continue
160
172
 
161
- modelClassesWithRegisteredHooks.add(modelClass)
173
+ modelClassesWithRegisteredHooks.add(canonicalModelClass)
162
174
 
163
- modelClass.beforeCreate((model) => {
175
+ canonicalModelClass.beforeCreate((model) => {
164
176
  /** @type {FrontendModelWebsocketRecord} */ (model).__frontendModelWebsocketAction = "create"
165
177
  })
166
178
 
167
- modelClass.beforeUpdate(async (model) => {
179
+ canonicalModelClass.beforeUpdate(async (model) => {
168
180
  const websocketModel = /** @type {FrontendModelWebsocketRecord} */ (model)
169
181
 
170
182
  websocketModel.__frontendModelWebsocketAction = "update"
171
183
  websocketModel.__frontendModelWebsocketPreviousIds = await frontendModelPreviousResourceIdentities(model)
172
184
  })
173
185
 
174
- modelClass.beforeDestroy(async (model) => {
186
+ canonicalModelClass.beforeDestroy(async (model) => {
175
187
  const websocketModel = /** @type {FrontendModelWebsocketRecord} */ (model)
176
188
  const persistedModel = await model
177
189
  .queryForModel(model.getModelClass())
@@ -183,7 +195,7 @@ export async function ensureFrontendModelWebsocketPublishersRegistered(configura
183
195
  websocketModel.__frontendModelWebsocketDestroyAuthorizationRecord = frontendModelDestroyAuthorizationRecord(persistedModel)
184
196
  })
185
197
 
186
- modelClass.afterSave((model) => {
198
+ canonicalModelClass.afterSave((model) => {
187
199
  const modelWithWebsocketAction = /** @type {FrontendModelWebsocketRecord} */ (model)
188
200
  const action = modelWithWebsocketAction.__frontendModelWebsocketAction
189
201
 
@@ -197,7 +209,7 @@ export async function ensureFrontendModelWebsocketPublishersRegistered(configura
197
209
  delete modelWithWebsocketAction.__frontendModelWebsocketPreviousIds
198
210
  })
199
211
 
200
- modelClass.afterDestroy((model) => {
212
+ canonicalModelClass.afterDestroy((model) => {
201
213
  const websocketModel = /** @type {FrontendModelWebsocketRecord} */ (model)
202
214
  const destroyAuthorizationRecord = websocketModel.__frontendModelWebsocketDestroyAuthorizationRecord
203
215
  const previousIds = websocketModel.__frontendModelWebsocketPreviousIds
@@ -217,7 +229,7 @@ export async function ensureFrontendModelWebsocketPublishersRegistered(configura
217
229
  * @returns {Promise<Map<string, import("../utils/model-primary-key.js").ModelPrimaryKeyValue>>} - Previous identities by resource name.
218
230
  */
219
231
  async function frontendModelPreviousResourceIdentities(model) {
220
- const publisherResources = publisherResourcesByConfiguration.get(model._getConfiguration())?.get(model.getModelClass())
232
+ const publisherResources = publisherResourcesForModel(model)
221
233
  /** @type {Map<string, import("../utils/model-primary-key.js").ModelPrimaryKeyValue>} */
222
234
  const previousIds = new Map()
223
235
 
@@ -252,7 +264,7 @@ async function frontendModelPreviousResourceIdentities(model) {
252
264
  * @returns {Map<string, import("../utils/model-primary-key.js").ModelPrimaryKeyValue>} - Identities by resource name.
253
265
  */
254
266
  function frontendModelResourceIdentities(model) {
255
- const publisherResources = publisherResourcesByConfiguration.get(model._getConfiguration())?.get(model.getModelClass())
267
+ const publisherResources = publisherResourcesForModel(model)
256
268
  /** @type {Map<string, import("../utils/model-primary-key.js").ModelPrimaryKeyValue>} */
257
269
  const identities = new Map()
258
270
 
@@ -267,6 +279,17 @@ function frontendModelResourceIdentities(model) {
267
279
  return identities
268
280
  }
269
281
 
282
+ /**
283
+ * Returns publisher resources through the backing model's canonical registry owner.
284
+ * @param {import("../database/record/index.js").default} model - Backing model instance.
285
+ * @returns {Map<string, FrontendModelPublisherResource> | undefined} - Publisher resources for the model.
286
+ */
287
+ function publisherResourcesForModel(model) {
288
+ const canonicalModelClass = model.getModelClass().canonicalRecordMetadataModelClass()
289
+
290
+ return publisherResourcesByConfiguration.get(model._getConfiguration())?.get(canonicalModelClass)
291
+ }
292
+
270
293
  /**
271
294
  * Serializes the persisted record for server-side destroy authorization. Binary values
272
295
  * use a dedicated byte-array marker because the shared transport serializer otherwise
@@ -337,7 +360,7 @@ function frontendModelResourceIdentity({model, previous = false, primaryKey}) {
337
360
  */
338
361
  function broadcastFrontendModelEvents(model, action, previousIds, destroyAuthorizationRecord) {
339
362
  const configuration = model._getConfiguration()
340
- const publisherResources = publisherResourcesByConfiguration.get(configuration)?.get(model.getModelClass())
363
+ const publisherResources = publisherResourcesForModel(model)
341
364
 
342
365
  if (!publisherResources) return
343
366
 
@@ -118,19 +118,51 @@ export default class VelociousWebsocketClient extends SnapReqWebSocketClient {
118
118
  if (this.gracefulClosePromise) return await this.gracefulClosePromise
119
119
 
120
120
  this.autoReconnect = false
121
+ const channelSubscriptions = [...this._channelSubscriptions.values()]
121
122
  const socket = this.socket
123
+
124
+ this._channelSubscriptions.clear()
125
+ const {promise: publishedClosePromise, reject: rejectPublishedClose, resolve: resolvePublishedClose} = Promise.withResolvers()
126
+
127
+ this.gracefulClosePromise = publishedClosePromise
128
+ // This internal bridge exists only for synchronous reentrancy. Its rejection
129
+ // duplicates closePromise, which remains the public error source below.
130
+ void publishedClosePromise.catch(() => {})
122
131
  const closePromise = (async () => {
123
- if (socket && socket.readyState === socket.OPEN) {
124
- await new Promise((resolve) => {
125
- socket.addEventListener("close", () => resolve(undefined), {once: true})
126
- socket.close(1000)
127
- })
132
+ /** @type {unknown[]} */
133
+ const closeErrors = []
134
+
135
+ for (const subscription of channelSubscriptions) {
136
+ try {
137
+ subscription._handleClosed("client_close")
138
+ } catch (error) {
139
+ closeErrors.push(error)
140
+ }
141
+ }
142
+
143
+ try {
144
+ if (socket && socket.readyState === socket.OPEN) {
145
+ await new Promise((resolve) => {
146
+ socket.addEventListener("close", () => resolve(undefined), {once: true})
147
+ socket.close(1000)
148
+ })
149
+ }
150
+ } catch (error) {
151
+ closeErrors.push(error)
128
152
  }
129
153
 
130
- await super.close()
154
+ try {
155
+ await super.close()
156
+ } catch (error) {
157
+ closeErrors.push(error)
158
+ }
159
+
160
+ if (closeErrors.length === 1) throw closeErrors[0]
161
+ if (closeErrors.length > 1) throw new AggregateError(closeErrors, "Failed to close WebSocket client")
131
162
  })()
132
163
 
133
164
  this.gracefulClosePromise = closePromise
165
+ void closePromise.then(resolvePublishedClose, rejectPublishedClose)
134
166
 
135
167
  try {
136
168
  await closePromise
@@ -148,12 +180,12 @@ export default class VelociousWebsocketClient extends SnapReqWebSocketClient {
148
180
  this.reconnectGeneration += 1
149
181
  await super.disconnectAndStopReconnect()
150
182
 
151
- if (this.runningReconnectTasks.size === 0) return
152
-
153
183
  while (this.runningReconnectTasks.size > 0) {
154
184
  await Promise.all(this.runningReconnectTasks)
155
185
  }
156
186
 
187
+ // A stale attempt may have finished during the first close after changing
188
+ // stopped state, even when the task set is already empty here.
157
189
  await super.disconnectAndStopReconnect()
158
190
  }
159
191
  }