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.
- package/README.md +1 -0
- package/build/database/advisory-lock-runner.js +192 -0
- package/build/database/drivers/mysql/index.js +3 -0
- package/build/database/live-query.js +244 -0
- package/build/database/record/index.js +48 -111
- package/build/database/record-changes.js +135 -0
- package/build/database/use-live-query.js +118 -0
- package/build/src/configuration-types.d.ts +1 -1
- package/build/src/configuration.d.ts +1 -1
- package/build/src/database/advisory-lock-runner.d.ts +124 -0
- package/build/src/database/advisory-lock-runner.d.ts.map +1 -0
- package/build/src/database/advisory-lock-runner.js +176 -0
- package/build/src/database/drivers/mysql/index.d.ts +2 -2
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +3 -1
- package/build/src/database/live-query.d.ts +168 -0
- package/build/src/database/live-query.d.ts.map +1 -0
- package/build/src/database/live-query.js +211 -0
- package/build/src/database/record/attachments/handle.d.ts +1 -1
- package/build/src/database/record/index.d.ts +24 -61
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +46 -108
- package/build/src/database/record-changes.d.ts +111 -0
- package/build/src/database/record-changes.d.ts.map +1 -0
- package/build/src/database/record-changes.js +115 -0
- package/build/src/database/use-live-query.d.ts +44 -0
- package/build/src/database/use-live-query.d.ts.map +1 -0
- package/build/src/database/use-live-query.js +97 -0
- package/build/src/sync/sync-api-client.d.ts.map +1 -1
- package/build/src/sync/sync-api-client.js +17 -10
- package/build/src/sync/sync-envelope-replay-service.d.ts +1 -1
- package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
- package/build/src/sync/sync-realtime-bridge.js +9 -5
- package/build/src/sync/sync-replay-upsert-applier.d.ts +2 -2
- package/build/sync/sync-api-client.js +19 -11
- package/build/sync/sync-realtime-bridge.js +9 -4
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/database/advisory-lock-runner.js +192 -0
- package/src/database/drivers/mysql/index.js +3 -0
- package/src/database/live-query.js +244 -0
- package/src/database/record/index.js +48 -111
- package/src/database/record-changes.js +135 -0
- package/src/database/use-live-query.js +118 -0
- package/src/sync/sync-api-client.js +19 -11
- 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
|
|
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
|
|
2679
|
-
*
|
|
2680
|
-
*
|
|
2681
|
-
*
|
|
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
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
}
|
|
2648
|
+
const runner = new AdvisoryLockRunner({
|
|
2649
|
+
configuration: this._getConfiguration(),
|
|
2650
|
+
connectionProvider: () => this.connection(),
|
|
2651
|
+
databaseIdentifier: this.getDatabaseIdentifier()
|
|
2652
|
+
})
|
|
2703
2653
|
|
|
2704
|
-
|
|
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
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
}
|
|
2674
|
+
const runner = new AdvisoryLockRunner({
|
|
2675
|
+
configuration: this._getConfiguration(),
|
|
2676
|
+
connectionProvider: () => this.connection(),
|
|
2677
|
+
databaseIdentifier: this.getDatabaseIdentifier()
|
|
2678
|
+
})
|
|
2734
2679
|
|
|
2735
|
-
|
|
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
|
|
2745
|
-
*
|
|
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
|
-
|
|
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
|
/**
|