velocious 1.0.510 → 1.0.512

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 (46) hide show
  1. package/README.md +1 -0
  2. package/build/database/advisory-lock-runner.js +192 -0
  3. package/build/database/drivers/mysql/index.js +3 -0
  4. package/build/database/live-query.js +244 -0
  5. package/build/database/record/index.js +48 -111
  6. package/build/database/record-changes.js +135 -0
  7. package/build/database/use-live-query.js +118 -0
  8. package/build/src/configuration-types.d.ts +1 -1
  9. package/build/src/configuration.d.ts +1 -1
  10. package/build/src/database/advisory-lock-runner.d.ts +124 -0
  11. package/build/src/database/advisory-lock-runner.d.ts.map +1 -0
  12. package/build/src/database/advisory-lock-runner.js +176 -0
  13. package/build/src/database/drivers/mysql/index.d.ts +2 -2
  14. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  15. package/build/src/database/drivers/mysql/index.js +3 -1
  16. package/build/src/database/live-query.d.ts +168 -0
  17. package/build/src/database/live-query.d.ts.map +1 -0
  18. package/build/src/database/live-query.js +211 -0
  19. package/build/src/database/record/attachments/handle.d.ts +1 -1
  20. package/build/src/database/record/index.d.ts +24 -61
  21. package/build/src/database/record/index.d.ts.map +1 -1
  22. package/build/src/database/record/index.js +46 -108
  23. package/build/src/database/record-changes.d.ts +111 -0
  24. package/build/src/database/record-changes.d.ts.map +1 -0
  25. package/build/src/database/record-changes.js +115 -0
  26. package/build/src/database/use-live-query.d.ts +44 -0
  27. package/build/src/database/use-live-query.d.ts.map +1 -0
  28. package/build/src/database/use-live-query.js +97 -0
  29. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  30. package/build/src/sync/sync-api-client.js +17 -10
  31. package/build/src/sync/sync-envelope-replay-service.d.ts +1 -1
  32. package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
  33. package/build/src/sync/sync-realtime-bridge.js +9 -5
  34. package/build/src/sync/sync-replay-upsert-applier.d.ts +2 -2
  35. package/build/sync/sync-api-client.js +19 -11
  36. package/build/sync/sync-realtime-bridge.js +9 -4
  37. package/build/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +1 -1
  39. package/src/database/advisory-lock-runner.js +192 -0
  40. package/src/database/drivers/mysql/index.js +3 -0
  41. package/src/database/live-query.js +244 -0
  42. package/src/database/record/index.js +48 -111
  43. package/src/database/record-changes.js +135 -0
  44. package/src/database/use-live-query.js +118 -0
  45. package/src/sync/sync-api-client.js +19 -11
  46. package/src/sync/sync-realtime-bridge.js +9 -4
package/README.md CHANGED
@@ -11,6 +11,7 @@
11
11
  * Controllers and views for HTTP endpoints
12
12
  * Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, with structured per-attribute validation error responses (see [docs/frontend-models.md](docs/frontend-models.md))
13
13
  * Client-side offline sync mutation logs and frontend-model optimistic queueing primitives (see [docs/offline-sync.md](docs/offline-sync.md))
14
+ * Reactive `useLiveQuery(Model.where(...))` queries that stay current from committed local model changes across local writes, pulls, and realtime (see [docs/live-queries.md](docs/live-queries.md))
14
15
  * Server-side sync envelope replay orchestration for app-owned sync receivers (see [docs/sync-envelope-replay-service.md](docs/sync-envelope-replay-service.md))
15
16
  * SQLite web persistence that automatically prefers OPFS, then IndexedDB, and migrates legacy persisted bytes when possible (see [docs/sqlite-web-persistence.md](docs/sqlite-web-persistence.md))
16
17
  * Expo / Metro compatibility guidance and a real Expo export check (see [docs/expo-metro-compatibility.md](docs/expo-metro-compatibility.md))
@@ -0,0 +1,192 @@
1
+ // @ts-check
2
+
3
+ import timeout from "awaitery/build/timeout.js"
4
+
5
+ /**
6
+ * Thrown when an advisory lock could not be acquired before `timeoutMs` elapsed.
7
+ */
8
+ class AdvisoryLockTimeoutError extends Error {
9
+ /**
10
+ * Runs constructor.
11
+ * @param {string} message - Error message.
12
+ * @param {{name: string}} args - The advisory lock name that timed out.
13
+ */
14
+ constructor(message, {name}) {
15
+ super(message)
16
+ this.name = "AdvisoryLockTimeoutError"
17
+ this.lockName = name
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Thrown when `withAdvisoryLockOrFail` finds the lock already held.
23
+ */
24
+ class AdvisoryLockBusyError extends Error {
25
+ /**
26
+ * Runs constructor.
27
+ * @param {string} message - Error message.
28
+ * @param {{name: string}} args - The advisory lock name that was already held.
29
+ */
30
+ constructor(message, {name}) {
31
+ super(message)
32
+ this.name = "AdvisoryLockBusyError"
33
+ this.lockName = name
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Thrown when a callback holds an advisory lock longer than `holdTimeoutMs`.
39
+ */
40
+ class AdvisoryLockHoldTimeoutError extends Error {
41
+ /**
42
+ * Runs constructor.
43
+ * @param {string} message - Error message.
44
+ * @param {{name: string}} args - The advisory lock name whose hold timed out.
45
+ */
46
+ constructor(message, {name}) {
47
+ super(message)
48
+ this.name = "AdvisoryLockHoldTimeoutError"
49
+ this.lockName = name
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Runs advisory locks on the caller connection by default, using a dedicated
55
+ * lock connection only when a positive hold timeout needs separate ownership.
56
+ */
57
+ export default class AdvisoryLockRunner {
58
+ /**
59
+ * Creates an advisory-lock runner for one model database identifier.
60
+ * @param {{configuration: import("../configuration.js").default, connectionProvider: () => import("./drivers/base.js").default, databaseIdentifier: string}} args - Runner dependencies.
61
+ */
62
+ constructor({configuration, connectionProvider, databaseIdentifier}) {
63
+ this.configuration = configuration
64
+ this.connectionProvider = connectionProvider
65
+ this.databaseIdentifier = databaseIdentifier
66
+ }
67
+
68
+ /**
69
+ * Runs a callback after acquiring the advisory lock, waiting up to `timeoutMs`.
70
+ * @template T
71
+ * @param {string} name - Lock name.
72
+ * @param {() => Promise<T>} callback - Callback to invoke while the lock is held.
73
+ * @param {{timeoutMs?: number | null, holdTimeoutMs?: number | null}} [args] - Lock and hold timeout options.
74
+ * @returns {Promise<T>} - Resolves with the callback result.
75
+ */
76
+ async withAdvisoryLock(name, callback, args = {}) {
77
+ return await this.withLockConnection(args.holdTimeoutMs, async (connection) => {
78
+ const acquired = await connection.acquireAdvisoryLock(name, args)
79
+
80
+ if (!acquired) {
81
+ throw new AdvisoryLockTimeoutError(`Timed out waiting for advisory lock ${JSON.stringify(name)}`, {name})
82
+ }
83
+
84
+ return await this.runLockedCallback({callback, connection, holdTimeoutMs: args.holdTimeoutMs, name})
85
+ })
86
+ }
87
+
88
+ /**
89
+ * Runs a callback only if the advisory lock can be acquired immediately.
90
+ * @template T
91
+ * @param {string} name - Lock name.
92
+ * @param {() => Promise<T>} callback - Callback to invoke while the lock is held.
93
+ * @param {{holdTimeoutMs?: number | null}} [args] - Hold timeout options.
94
+ * @returns {Promise<T>} - Resolves with the callback result.
95
+ */
96
+ async withAdvisoryLockOrFail(name, callback, args = {}) {
97
+ return await this.withLockConnection(args.holdTimeoutMs, async (connection) => {
98
+ const acquired = await connection.tryAcquireAdvisoryLock(name)
99
+
100
+ if (!acquired) {
101
+ throw new AdvisoryLockBusyError(`Advisory lock ${JSON.stringify(name)} is already held`, {name})
102
+ }
103
+
104
+ return await this.runLockedCallback({callback, connection, holdTimeoutMs: args.holdTimeoutMs, name})
105
+ })
106
+ }
107
+
108
+ /**
109
+ * Runs the lock holder callback and releases the lock from its owning connection.
110
+ * @template T
111
+ * @param {{callback: () => Promise<T>, connection: import("./drivers/base.js").default, holdTimeoutMs?: number | null, name: string}} args - Locked callback args.
112
+ * @returns {Promise<T>} - Resolves with the callback result.
113
+ */
114
+ async runLockedCallback({callback, connection, holdTimeoutMs, name}) {
115
+ try {
116
+ return await AdvisoryLockRunner.runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs)
117
+ } finally {
118
+ await connection.releaseAdvisoryLock(name)
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Runs lock work on the caller connection unless a positive hold timeout needs
124
+ * its own lock connection.
125
+ * @template T
126
+ * @param {number | null | undefined} holdTimeoutMs - Max hold time; positive values use a dedicated lock connection.
127
+ * @param {(connection: import("./drivers/base.js").default) => Promise<T>} callback - Callback receiving the connection that owns the advisory lock.
128
+ * @returns {Promise<T>} - Resolves with the callback result.
129
+ */
130
+ async withLockConnection(holdTimeoutMs, callback) {
131
+ if (holdTimeoutMs && holdTimeoutMs > 0) {
132
+ return await this.withDedicatedConnection(callback)
133
+ }
134
+
135
+ return await callback(this.connectionProvider())
136
+ }
137
+
138
+ /**
139
+ * Spawns a hold-timeout lock connection and closes it after lock work completes when
140
+ * the spawned driver owns the underlying physical connection.
141
+ * @template T
142
+ * @param {(connection: import("./drivers/base.js").default) => Promise<T>} callback - Callback that receives the dedicated lock connection.
143
+ * @returns {Promise<T>} - Resolves with the callback result.
144
+ */
145
+ async withDedicatedConnection(callback) {
146
+ const connection = await this.configuration.getDatabasePool(this.databaseIdentifier).spawnConnection()
147
+
148
+ try {
149
+ return await callback(connection)
150
+ } finally {
151
+ if (!connection.getArgs().getConnection) {
152
+ await connection.close()
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Runs `callback`, rejecting with `AdvisoryLockHoldTimeoutError` if it has
159
+ * not settled within `holdTimeoutMs`. The callback is not cancelled; callers
160
+ * use a dedicated advisory-lock connection so the lock can still be released.
161
+ * @template T
162
+ * @param {string} name - Lock name (for the error message).
163
+ * @param {() => Promise<T>} callback - Callback holding the lock.
164
+ * @param {number | null} [holdTimeoutMs] - Max hold time; falsy disables the timeout.
165
+ * @returns {Promise<T>} - Resolves with the callback result.
166
+ */
167
+ static async runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs) {
168
+ if (!holdTimeoutMs || holdTimeoutMs <= 0) {
169
+ return await callback()
170
+ }
171
+
172
+ let callbackSettled = false
173
+
174
+ try {
175
+ return await timeout({timeout: holdTimeoutMs}, async () => {
176
+ try {
177
+ return await callback()
178
+ } finally {
179
+ callbackSettled = true
180
+ }
181
+ })
182
+ } catch (error) {
183
+ if (!callbackSettled) {
184
+ throw new AdvisoryLockHoldTimeoutError(`Advisory lock ${JSON.stringify(name)} held longer than ${holdTimeoutMs}ms`, {name})
185
+ }
186
+
187
+ throw error
188
+ }
189
+ }
190
+ }
191
+
192
+ export {AdvisoryLockBusyError, AdvisoryLockHoldTimeoutError, AdvisoryLockTimeoutError}
@@ -31,6 +31,9 @@ import Update from "./sql/update.js"
31
31
  const MYSQL_INDEFINITE_LOCK_TIMEOUT_SECONDS = 60 * 60 * 24 * 365
32
32
 
33
33
  export default class VelociousDatabaseDriversMysql extends Base{
34
+ /** @type {import("mysql").Pool | undefined} */
35
+ pool = undefined
36
+
34
37
  /** @type {string | null} */
35
38
  _desiredSessionTimeZone = "+00:00"
36
39
 
@@ -0,0 +1,244 @@
1
+ // @ts-check
2
+
3
+ import debounceFunction from "debounce"
4
+
5
+ import recordChanges from "./record-changes.js"
6
+ import restArgsError from "../utils/rest-args-error.js"
7
+
8
+ /**
9
+ * RecordModelClass type.
10
+ * @typedef {typeof import("./record/index.js").default} RecordModelClass */
11
+
12
+ /**
13
+ * The minimal query contract a live query needs: a root model class to observe
14
+ * for committed changes and a way to run the query and return the current rows.
15
+ * `Model.where({...})` (a model-class query) satisfies this directly.
16
+ * @template T
17
+ * @typedef {object} LiveQuerySource
18
+ * @property {() => RecordModelClass} getModelClass - Root model class the query reads.
19
+ * @property {() => Promise<T[]>} toArray - Runs the query and resolves the current rows.
20
+ */
21
+
22
+ /**
23
+ * LiveQueryState type.
24
+ * @template T
25
+ * @typedef {object} LiveQueryState
26
+ * @property {T[]} results - Current query results.
27
+ * @property {boolean} loading - Whether the initial results are still loading.
28
+ * @property {Error | null} error - The last run error, or null when the last run succeeded.
29
+ */
30
+
31
+ /**
32
+ * A reactive query controller: fetches once, subscribes to committed changes of
33
+ * its model class(es), and re-runs whenever a watched model changes. Re-runs are
34
+ * coalesced (microtask by default, or a trailing debounce) and protected against
35
+ * stale responses by a monotonically increasing request id, so an in-flight run
36
+ * superseded by a newer change never overwrites fresher results. Framework-level
37
+ * and React-free so it can be unit tested and wrapped by `useLiveQuery`.
38
+ *
39
+ * Cost model: invalidation is by model class. A change to model M schedules one
40
+ * re-run of every live query observing M (no per-condition matching); a batch of
41
+ * changes coalesces into a single re-run.
42
+ * @template T
43
+ */
44
+ class LiveQuery {
45
+ /**
46
+ * Builds a live query controller for a query source.
47
+ * @param {object} args - Options.
48
+ * @param {LiveQuerySource<T>} args.query - Query source providing model class and `toArray`.
49
+ * @param {RecordModelClass[]} [args.models] - Model classes to observe. Defaults to `[query.getModelClass()]`; pass this to also react to joined models.
50
+ * @param {number} [args.debounce] - Trailing debounce in ms for re-runs. Defaults to microtask coalescing.
51
+ */
52
+ constructor({query, ...restArgs}) {
53
+ const {debounce, models, ...unknownArgs} = restArgs
54
+
55
+ restArgsError(unknownArgs)
56
+
57
+ if (!query) throw new Error("No query given to LiveQuery")
58
+
59
+ /** @type {LiveQuerySource<T>} */
60
+ this._query = query
61
+
62
+ /** @type {RecordModelClass[]} */
63
+ this._modelClasses = models ?? [query.getModelClass()]
64
+
65
+ /** @type {LiveQueryState<T>} */
66
+ this._state = {error: null, loading: true, results: []}
67
+
68
+ /**
69
+ * State-change listeners notified after every state transition.
70
+ * @type {Set<() => void>} */
71
+ this._listeners = new Set()
72
+
73
+ /**
74
+ * Record-change unsubscribe callbacks registered on `start`.
75
+ * @type {Array<() => void>} */
76
+ this._unsubscribes = []
77
+
78
+ /** @type {number} */
79
+ this._requestId = 0
80
+
81
+ /** @type {boolean} */
82
+ this._closed = false
83
+
84
+ /** @type {boolean} */
85
+ this._started = false
86
+
87
+ /** @type {boolean} */
88
+ this._runScheduled = false
89
+
90
+ /**
91
+ * Promise for the currently in-flight run, or null when idle.
92
+ * @type {Promise<void> | null} */
93
+ this._runningPromise = null
94
+
95
+ /**
96
+ * Schedules a coalesced re-run: a trailing debounce when configured, else microtask coalescing.
97
+ * @type {(() => void) & {clear?: () => void}} */
98
+ this._scheduleRun = typeof debounce === "number"
99
+ ? debounceFunction(() => this._run(), debounce)
100
+ : () => this._scheduleMicrotaskRun()
101
+
102
+ /**
103
+ * Record-change listener scheduling a re-run while the controller is open.
104
+ * @type {() => void} */
105
+ this._onRecordChange = () => {
106
+ if (!this._closed) this._scheduleRun()
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Subscribes to record changes and runs the initial query. Idempotent.
112
+ * @returns {void}
113
+ */
114
+ start() {
115
+ if (this._closed || this._started) return
116
+
117
+ this._started = true
118
+
119
+ for (const modelClass of this._modelClasses) {
120
+ this._unsubscribes.push(recordChanges.subscribe(modelClass, this._onRecordChange))
121
+ }
122
+
123
+ this._run()
124
+ }
125
+
126
+ /**
127
+ * Returns the current state. The reference only changes when the state changes,
128
+ * so it is safe to use as a React external-store snapshot.
129
+ * @returns {LiveQueryState<T>} Current live-query state.
130
+ */
131
+ getState() {
132
+ return this._state
133
+ }
134
+
135
+ /**
136
+ * Subscribes a listener notified after every state change.
137
+ * @param {() => void} listener - State-change listener.
138
+ * @returns {() => void} Unsubscribe callback.
139
+ */
140
+ subscribe(listener) {
141
+ this._listeners.add(listener)
142
+
143
+ return () => {
144
+ this._listeners.delete(listener)
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Unsubscribes from record changes, drops listeners, and prevents further runs.
150
+ * @returns {void}
151
+ */
152
+ close() {
153
+ if (this._closed) return
154
+
155
+ this._closed = true
156
+
157
+ for (const unsubscribe of this._unsubscribes) {
158
+ unsubscribe()
159
+ }
160
+
161
+ this._unsubscribes = []
162
+ this._listeners.clear()
163
+
164
+ if (this._scheduleRun.clear) this._scheduleRun.clear()
165
+ }
166
+
167
+ /**
168
+ * Awaits any scheduled or in-flight run so callers (tests) can observe settled
169
+ * results. Bounded so a continuous change stream cannot loop forever.
170
+ * @returns {Promise<void>}
171
+ */
172
+ async whenSettled() {
173
+ for (let attempt = 0; attempt < 100; attempt++) {
174
+ if (this._closed) return
175
+ if (!this._runScheduled && !this._runningPromise) return
176
+
177
+ if (this._runningPromise) await this._runningPromise
178
+
179
+ await new Promise((resolve) => queueMicrotask(() => resolve(undefined)))
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Schedules a microtask-coalesced re-run, collapsing a synchronous burst of
185
+ * change events into a single run.
186
+ * @returns {void}
187
+ */
188
+ _scheduleMicrotaskRun() {
189
+ if (this._runScheduled) return
190
+
191
+ this._runScheduled = true
192
+
193
+ queueMicrotask(() => {
194
+ this._runScheduled = false
195
+
196
+ if (!this._closed) this._run()
197
+ })
198
+ }
199
+
200
+ /**
201
+ * Runs the query and applies its results unless a newer run superseded it or the
202
+ * controller was closed. A run error surfaces in state (with the previous
203
+ * results kept) rather than rejecting a background promise.
204
+ * @returns {Promise<void>}
205
+ */
206
+ _run() {
207
+ const requestId = ++this._requestId
208
+ const runningPromise = (async () => {
209
+ try {
210
+ const results = await this._query.toArray()
211
+
212
+ if (this._closed || requestId !== this._requestId) return
213
+
214
+ this._setState({error: null, loading: false, results})
215
+ } catch (error) {
216
+ if (this._closed || requestId !== this._requestId) return
217
+
218
+ this._setState({error: /** @type {Error} */ (error), loading: false, results: this._state.results})
219
+ }
220
+ })()
221
+
222
+ this._runningPromise = runningPromise
223
+ void runningPromise.then(() => {
224
+ if (this._runningPromise === runningPromise) this._runningPromise = null
225
+ })
226
+
227
+ return runningPromise
228
+ }
229
+
230
+ /**
231
+ * Replaces the state and notifies listeners.
232
+ * @param {LiveQueryState<T>} state - Next state.
233
+ * @returns {void}
234
+ */
235
+ _setState(state) {
236
+ this._state = state
237
+
238
+ for (const listener of Array.from(this._listeners)) {
239
+ listener()
240
+ }
241
+ }
242
+ }
243
+
244
+ export default LiveQuery
@@ -24,7 +24,7 @@
24
24
 
25
25
  /** @typedef {import("../../configuration-types.js").TenantDatabaseProviderType} TenantDatabaseProviderType */
26
26
 
27
- import timeout from "awaitery/build/timeout.js"
27
+ import AdvisoryLockRunner, {AdvisoryLockBusyError, AdvisoryLockHoldTimeoutError, AdvisoryLockTimeoutError} from "../advisory-lock-runner.js"
28
28
  import BelongsToInstanceRelationship from "./instance-relationships/belongs-to.js"
29
29
  import BelongsToRelationship from "./relationships/belongs-to.js"
30
30
  import Configuration from "../../configuration.js"
@@ -41,6 +41,7 @@ import deburrColumnName from "../../utils/deburr-column-name.js"
41
41
  import ModelClassQuery from "../query/model-class-query.js"
42
42
  import Preloader from "../query/preloader.js"
43
43
  import {readPayloadAssociationCount, readPayloadComputedAbility, readPayloadQueryData, setPayloadAssociationCount, setPayloadComputedAbility, setPayloadQueryData} from "../../record-payload-values.js"
44
+ import recordChanges from "../record-changes.js"
44
45
  import restArgsError from "../../utils/rest-args-error.js"
45
46
  import singularizeModelName from "../../utils/singularize-model-name.js"
46
47
  import {defineModelScope} from "../../utils/model-scope.js"
@@ -164,60 +165,6 @@ function buildRelatedRecordWithInverse(parent, relationshipName, attributes, all
164
165
  return record
165
166
  }
166
167
 
167
- /**
168
- * Thrown by `Record.withAdvisoryLock` when the caller supplied a
169
- * `timeoutMs` and the lock was not granted before it elapsed.
170
- */
171
- class AdvisoryLockTimeoutError extends Error {
172
- /**
173
- * Runs constructor.
174
- * @param {string} message - Error message.
175
- * @param {{name: string}} args - The advisory lock name that timed out.
176
- */
177
- constructor(message, {name}) {
178
- super(message)
179
- this.name = "AdvisoryLockTimeoutError"
180
- this.lockName = name
181
- }
182
- }
183
-
184
- /**
185
- * Thrown by `Record.withAdvisoryLockOrFail` when the lock is already held
186
- * by another session at the moment of the call.
187
- */
188
- class AdvisoryLockBusyError extends Error {
189
- /**
190
- * Runs constructor.
191
- * @param {string} message - Error message.
192
- * @param {{name: string}} args - The advisory lock name that was already held.
193
- */
194
- constructor(message, {name}) {
195
- super(message)
196
- this.name = "AdvisoryLockBusyError"
197
- this.lockName = name
198
- }
199
- }
200
-
201
- /**
202
- * Thrown by `Record.withAdvisoryLock` / `withAdvisoryLockOrFail` when the
203
- * caller supplied a `holdTimeoutMs` and the callback ran longer than it. The
204
- * lock is released before this is thrown, so a hung holder can't block other
205
- * sessions indefinitely. Note: the callback itself is not cancelled — pass an
206
- * AbortSignal to the work if it needs to stop.
207
- */
208
- class AdvisoryLockHoldTimeoutError extends Error {
209
- /**
210
- * Runs constructor.
211
- * @param {string} message - Error message.
212
- * @param {{name: string}} args - The advisory lock name whose hold timed out.
213
- */
214
- constructor(message, {name}) {
215
- super(message)
216
- this.name = "AdvisoryLockHoldTimeoutError"
217
- this.lockName = name
218
- }
219
- }
220
-
221
168
  class TenantDatabaseScopeError extends Error {
222
169
  /**
223
170
  * Runs constructor.
@@ -2471,6 +2418,7 @@ class VelociousDatabaseRecord {
2471
2418
  await this._autoSaveHasManyAndHasOneRelationships({isNewRecord})
2472
2419
  await this._autoSaveAttachments()
2473
2420
  await this._runLifecycleCallbacks("afterSave")
2421
+ await this._emitRecordChangeAfterCommit(isNewRecord ? "create" : "update")
2474
2422
  })
2475
2423
  })
2476
2424
 
@@ -2675,10 +2623,13 @@ class VelociousDatabaseRecord {
2675
2623
  }
2676
2624
 
2677
2625
  /**
2678
- * Runs the callback while holding a named advisory lock on the current
2679
- * connection. Advisory locks are cooperative and connection-scoped: they
2680
- * serialize callers that opt into the same `name`, without touching row
2681
- * or table locks, so unrelated traffic is free to proceed.
2626
+ * Runs the callback while holding a named advisory lock. Calls without
2627
+ * a positive `holdTimeoutMs` use the caller connection; calls with a positive
2628
+ * `holdTimeoutMs` use a dedicated lock connection so timeout cleanup can
2629
+ * release the lock even when callback database work is stuck. Advisory locks
2630
+ * are cooperative and session-scoped: they serialize callers that opt into
2631
+ * the same `name`, without touching row or table locks, so unrelated traffic
2632
+ * is free to proceed.
2682
2633
  *
2683
2634
  * The lock is acquired before the callback runs and released in a
2684
2635
  * `finally` block afterwards, so the callback's return value is
@@ -2694,18 +2645,13 @@ class VelociousDatabaseRecord {
2694
2645
  static async withAdvisoryLock(name, callback, args = {}) {
2695
2646
  await this.ensureInitialized()
2696
2647
 
2697
- const connection = this.connection()
2698
- const acquired = await connection.acquireAdvisoryLock(name, args)
2699
-
2700
- if (!acquired) {
2701
- throw new AdvisoryLockTimeoutError(`Timed out waiting for advisory lock ${JSON.stringify(name)}`, {name})
2702
- }
2648
+ const runner = new AdvisoryLockRunner({
2649
+ configuration: this._getConfiguration(),
2650
+ connectionProvider: () => this.connection(),
2651
+ databaseIdentifier: this.getDatabaseIdentifier()
2652
+ })
2703
2653
 
2704
- try {
2705
- return await this.runWithAdvisoryLockHoldTimeout(name, callback, args.holdTimeoutMs)
2706
- } finally {
2707
- await connection.releaseAdvisoryLock(name)
2708
- }
2654
+ return await runner.withAdvisoryLock(name, callback, args)
2709
2655
  }
2710
2656
 
2711
2657
  /**
@@ -2725,31 +2671,19 @@ class VelociousDatabaseRecord {
2725
2671
  static async withAdvisoryLockOrFail(name, callback, args = {}) {
2726
2672
  await this.ensureInitialized()
2727
2673
 
2728
- const connection = this.connection()
2729
- const acquired = await connection.tryAcquireAdvisoryLock(name)
2730
-
2731
- if (!acquired) {
2732
- throw new AdvisoryLockBusyError(`Advisory lock ${JSON.stringify(name)} is already held`, {name})
2733
- }
2674
+ const runner = new AdvisoryLockRunner({
2675
+ configuration: this._getConfiguration(),
2676
+ connectionProvider: () => this.connection(),
2677
+ databaseIdentifier: this.getDatabaseIdentifier()
2678
+ })
2734
2679
 
2735
- try {
2736
- return await this.runWithAdvisoryLockHoldTimeout(name, callback, args.holdTimeoutMs)
2737
- } finally {
2738
- await connection.releaseAdvisoryLock(name)
2739
- }
2680
+ return await runner.withAdvisoryLockOrFail(name, callback, args)
2740
2681
  }
2741
2682
 
2742
2683
  /**
2743
2684
  * Runs `callback`, rejecting with `AdvisoryLockHoldTimeoutError` if it has
2744
- * not settled within `holdTimeoutMs`. The caller's `finally` then releases
2745
- * the lock, so a hung holder can't block other sessions forever. The
2746
- * callback is not cancelled — this is a safety net, not cancellation.
2747
- *
2748
- * Uses `awaitery`'s shared `timeout` helper for the hard timeout, then
2749
- * translates its timeout into the typed `AdvisoryLockHoldTimeoutError` so
2750
- * callers can catch it like the other advisory-lock errors. A `callbackSettled`
2751
- * flag distinguishes the timeout from a rejection thrown by the callback
2752
- * itself, which is rethrown unchanged.
2685
+ * not settled within `holdTimeoutMs`. The callback is not cancelled — this is
2686
+ * a safety net, not cancellation.
2753
2687
  * @template T
2754
2688
  * @param {string} name - Lock name (for the error message).
2755
2689
  * @param {() => Promise<T>} callback - Callback holding the lock.
@@ -2757,27 +2691,7 @@ class VelociousDatabaseRecord {
2757
2691
  * @returns {Promise<T>}
2758
2692
  */
2759
2693
  static async runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs) {
2760
- if (!holdTimeoutMs || holdTimeoutMs <= 0) {
2761
- return await callback()
2762
- }
2763
-
2764
- let callbackSettled = false
2765
-
2766
- try {
2767
- return await timeout({timeout: holdTimeoutMs}, async () => {
2768
- try {
2769
- return await callback()
2770
- } finally {
2771
- callbackSettled = true
2772
- }
2773
- })
2774
- } catch (error) {
2775
- if (!callbackSettled) {
2776
- throw new AdvisoryLockHoldTimeoutError(`Advisory lock ${JSON.stringify(name)} held longer than ${holdTimeoutMs}ms`, {name})
2777
- }
2778
-
2779
- throw error
2780
- }
2694
+ return await AdvisoryLockRunner.runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs)
2781
2695
  }
2782
2696
 
2783
2697
  /**
@@ -3700,6 +3614,29 @@ class VelociousDatabaseRecord {
3700
3614
 
3701
3615
  await this._connection().query(sql, {logName: `${this.getModelClass().name} Destroy`})
3702
3616
  await this._runLifecycleCallbacks("afterDestroy")
3617
+ await this._emitRecordChangeAfterCommit("destroy")
3618
+ }
3619
+
3620
+ /**
3621
+ * Emits a committed record-change event after the surrounding transaction
3622
+ * commits, so live queries re-run uniformly for local writes, pull applies, and
3623
+ * realtime applies (which all end as local saves/destroys). Registered through
3624
+ * the connection's afterCommit hook so a rolled-back save emits nothing, and
3625
+ * skipped entirely when nothing observes this model class so server-side saves
3626
+ * stay free of live-query overhead.
3627
+ * @param {import("../record-changes.js").RecordChangeOperation} operation - The committed operation.
3628
+ * @returns {Promise<void>}
3629
+ */
3630
+ async _emitRecordChangeAfterCommit(operation) {
3631
+ const modelClass = this.getModelClass()
3632
+
3633
+ if (!recordChanges.hasListeners(modelClass)) return
3634
+
3635
+ const record = this
3636
+
3637
+ await this._connection().afterCommit(() => {
3638
+ recordChanges.emit({modelClass, operation, record})
3639
+ })
3703
3640
  }
3704
3641
 
3705
3642
  /**