velocious 1.0.511 → 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/live-query.js +244 -0
- package/build/database/record/index.js +25 -0
- package/build/database/record-changes.js +135 -0
- package/build/database/use-live-query.js +118 -0
- 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/index.d.ts +11 -0
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +23 -1
- 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-realtime-bridge.d.ts.map +1 -1
- package/build/src/sync/sync-realtime-bridge.js +9 -5
- 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/live-query.js +244 -0
- package/src/database/record/index.js +25 -0
- 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,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
|
|
@@ -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"
|
|
@@ -2417,6 +2418,7 @@ class VelociousDatabaseRecord {
|
|
|
2417
2418
|
await this._autoSaveHasManyAndHasOneRelationships({isNewRecord})
|
|
2418
2419
|
await this._autoSaveAttachments()
|
|
2419
2420
|
await this._runLifecycleCallbacks("afterSave")
|
|
2421
|
+
await this._emitRecordChangeAfterCommit(isNewRecord ? "create" : "update")
|
|
2420
2422
|
})
|
|
2421
2423
|
})
|
|
2422
2424
|
|
|
@@ -3612,6 +3614,29 @@ class VelociousDatabaseRecord {
|
|
|
3612
3614
|
|
|
3613
3615
|
await this._connection().query(sql, {logName: `${this.getModelClass().name} Destroy`})
|
|
3614
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
|
+
})
|
|
3615
3640
|
}
|
|
3616
3641
|
|
|
3617
3642
|
/**
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import EventEmitter from "../utils/event-emitter.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RecordModelClass type.
|
|
7
|
+
* @typedef {typeof import("./record/index.js").default} RecordModelClass */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* RecordChangeOperation type.
|
|
11
|
+
* @typedef {"create" | "update" | "destroy"} RecordChangeOperation */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* RecordChangeEvent type.
|
|
15
|
+
* @typedef {object} RecordChangeEvent
|
|
16
|
+
* @property {RecordModelClass} modelClass - Model class whose row changed.
|
|
17
|
+
* @property {RecordChangeOperation} operation - The committed operation.
|
|
18
|
+
* @property {InstanceType<RecordModelClass>} record - The committed record instance.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* RecordChangeListener type.
|
|
23
|
+
* @typedef {(event: RecordChangeEvent) => void} RecordChangeListener */
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Framework-level bus for committed local model changes. Records emit here once
|
|
27
|
+
* per commit (see `VelociousDatabaseRecord.save`/`destroy`), so local writes,
|
|
28
|
+
* pull applies, and realtime applies converge on one uniform signal that live
|
|
29
|
+
* queries subscribe to. Emission is keyed by model name; a `batch(...)` window
|
|
30
|
+
* coalesces a burst of commits into a single event per model class.
|
|
31
|
+
*/
|
|
32
|
+
class RecordChanges {
|
|
33
|
+
/**
|
|
34
|
+
* Underlying event bus keyed by model name.
|
|
35
|
+
* @type {import("eventemitter3").EventEmitter} */
|
|
36
|
+
_emitter = new EventEmitter()
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Number of open batch windows; while positive, emits buffer instead of dispatching.
|
|
40
|
+
* @type {number} */
|
|
41
|
+
_batchDepth = 0
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Latest buffered event per model name, dispatched once when the outermost batch ends.
|
|
45
|
+
* @type {Map<string, RecordChangeEvent>} */
|
|
46
|
+
_bufferedEvents = new Map()
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Subscribes a listener to committed changes of a model class.
|
|
50
|
+
* @param {RecordModelClass} modelClass - Model class to observe.
|
|
51
|
+
* @param {RecordChangeListener} listener - Listener called with each change event.
|
|
52
|
+
* @returns {() => void} Unsubscribe callback.
|
|
53
|
+
*/
|
|
54
|
+
subscribe(modelClass, listener) {
|
|
55
|
+
const eventName = modelClass.getModelName()
|
|
56
|
+
|
|
57
|
+
this._emitter.on(eventName, listener)
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
this._emitter.off(eventName, listener)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether any listener is currently observing the given model class. Callers on
|
|
66
|
+
* the write path use this to skip emitting entirely when nothing is watching,
|
|
67
|
+
* keeping server-side saves free of live-query overhead.
|
|
68
|
+
* @param {RecordModelClass} modelClass - Model class to check.
|
|
69
|
+
* @returns {boolean} Whether listeners exist for the model class.
|
|
70
|
+
*/
|
|
71
|
+
hasListeners(modelClass) {
|
|
72
|
+
return this._emitter.listenerCount(modelClass.getModelName()) > 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Emits a committed change. While a batch window is open the event is buffered
|
|
77
|
+
* and deduplicated by model class, so a batch of N commits dispatches a single
|
|
78
|
+
* event per model class when the outermost batch ends.
|
|
79
|
+
* @param {RecordChangeEvent} event - Change event to dispatch.
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
emit(event) {
|
|
83
|
+
if (this._batchDepth > 0) {
|
|
84
|
+
this._bufferedEvents.set(event.modelClass.getModelName(), event)
|
|
85
|
+
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this._emitter.emit(event.modelClass.getModelName(), event)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Runs a callback with change dispatch coalesced: every change committed while
|
|
94
|
+
* the callback runs buffers, and the outermost batch flushes a single event per
|
|
95
|
+
* changed model class after it resolves. Nested batches share one flush. Sync
|
|
96
|
+
* appliers wrap their per-row apply loop in this so a large pull or realtime
|
|
97
|
+
* push triggers one re-run per live query instead of one per applied row.
|
|
98
|
+
* @template T
|
|
99
|
+
* @param {() => Promise<T> | T} callback - Work whose committed changes should coalesce.
|
|
100
|
+
* @returns {Promise<T>} The callback result.
|
|
101
|
+
*/
|
|
102
|
+
async batch(callback) {
|
|
103
|
+
this._batchDepth++
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
return await callback()
|
|
107
|
+
} finally {
|
|
108
|
+
this._batchDepth--
|
|
109
|
+
|
|
110
|
+
if (this._batchDepth === 0) this._flushBufferedEvents()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Dispatches and clears the buffered per-model events collected during a batch.
|
|
116
|
+
* @returns {void}
|
|
117
|
+
*/
|
|
118
|
+
_flushBufferedEvents() {
|
|
119
|
+
const bufferedEvents = this._bufferedEvents
|
|
120
|
+
|
|
121
|
+
this._bufferedEvents = new Map()
|
|
122
|
+
|
|
123
|
+
for (const event of bufferedEvents.values()) {
|
|
124
|
+
this._emitter.emit(event.modelClass.getModelName(), event)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Shared singleton so record commits and live queries meet on one bus.
|
|
131
|
+
* @type {RecordChanges} */
|
|
132
|
+
const recordChanges = new RecordChanges()
|
|
133
|
+
|
|
134
|
+
export default recordChanges
|
|
135
|
+
export {RecordChanges}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {useCallback, useEffect, useMemo, useRef, useSyncExternalStore} from "react"
|
|
4
|
+
|
|
5
|
+
import LiveQuery from "./live-query.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* RecordModelClass type.
|
|
9
|
+
* @typedef {typeof import("./record/index.js").default} RecordModelClass */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* LiveQuerySource type.
|
|
13
|
+
* @template T
|
|
14
|
+
* @typedef {import("./live-query.js").LiveQuerySource<T>} LiveQuerySource */
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* LiveQueryState type.
|
|
18
|
+
* @template T
|
|
19
|
+
* @typedef {import("./live-query.js").LiveQueryState<T>} LiveQueryState */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* UseLiveQueryOptions type.
|
|
23
|
+
* @typedef {object} UseLiveQueryOptions
|
|
24
|
+
* @property {boolean} [active] - Whether the query is active. Default true; pass false to pause and return the empty state.
|
|
25
|
+
* @property {number} [debounce] - Trailing debounce in ms for re-runs. Defaults to microtask coalescing.
|
|
26
|
+
* @property {RecordModelClass[]} [models] - Model classes to observe. Defaults to the query's model class; pass this to also react to joined models.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stable empty state returned while there is no active query, so a paused hook
|
|
31
|
+
* keeps a referentially stable snapshot for `useSyncExternalStore`.
|
|
32
|
+
* @type {LiveQueryState<?>} */
|
|
33
|
+
const EMPTY_STATE = {error: null, loading: false, results: []}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Assigns and stores a stable identity key for query sources without a `toSql`.
|
|
37
|
+
* @type {WeakMap<object, string>} */
|
|
38
|
+
const queryIdentityKeys = new WeakMap()
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Monotonic counter backing the queryIdentityKeys registry.
|
|
42
|
+
* @type {number} */
|
|
43
|
+
let nextQueryIdentity = 0
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Builds a dependency key identifying a query's semantics so the underlying
|
|
47
|
+
* controller is rebuilt when they change. Model-class queries expose `toSql`, so
|
|
48
|
+
* distinct conditions yield distinct keys; other sources fall back to a stable
|
|
49
|
+
* per-object identity (such sources must be memoized by the caller).
|
|
50
|
+
* @param {LiveQuerySource<?> & {toSql?: () => ?}} query - Query source.
|
|
51
|
+
* @param {RecordModelClass[] | undefined} models - Explicit model classes to observe.
|
|
52
|
+
* @returns {string} Dependency key.
|
|
53
|
+
*/
|
|
54
|
+
function liveQueryDependencyKey(query, models) {
|
|
55
|
+
const modelNames = (models ?? [query.getModelClass()]).map((modelClass) => modelClass.getModelName()).join(",")
|
|
56
|
+
|
|
57
|
+
if (typeof query.toSql === "function") return `${modelNames}::${String(query.toSql())}`
|
|
58
|
+
|
|
59
|
+
let identityKey = queryIdentityKeys.get(query)
|
|
60
|
+
|
|
61
|
+
if (identityKey === undefined) {
|
|
62
|
+
identityKey = `#${++nextQueryIdentity}`
|
|
63
|
+
queryIdentityKeys.set(query, identityKey)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return `${modelNames}::${identityKey}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* React hook declaring what a screen shows and keeping it current from committed
|
|
71
|
+
* local model changes. Runs `query.toArray()` once, subscribes to the query's
|
|
72
|
+
* model class(es) on the record-change bus, and re-runs (coalesced, stale-safe)
|
|
73
|
+
* whenever a watched model commits — so local writes, pull applies, and realtime
|
|
74
|
+
* applies all refresh the results without any manual refresh plumbing.
|
|
75
|
+
* @template T
|
|
76
|
+
* @param {(LiveQuerySource<T> & {toSql?: () => ?}) | null | undefined} query - Query source, e.g. `Model.where({...})`.
|
|
77
|
+
* @param {UseLiveQueryOptions} [options] - Hook options.
|
|
78
|
+
* @returns {LiveQueryState<T>} Current results, loading, and last error.
|
|
79
|
+
*/
|
|
80
|
+
export default function useLiveQuery(query, options = {}) {
|
|
81
|
+
const {active = true, debounce, models} = options
|
|
82
|
+
const enabled = active && Boolean(query)
|
|
83
|
+
const dependencyKey = enabled && query ? liveQueryDependencyKey(query, models) : "disabled"
|
|
84
|
+
|
|
85
|
+
const queryRef = useRef(query)
|
|
86
|
+
const modelsRef = useRef(models)
|
|
87
|
+
|
|
88
|
+
queryRef.current = query
|
|
89
|
+
modelsRef.current = models
|
|
90
|
+
|
|
91
|
+
const liveQuery = useMemo(() => {
|
|
92
|
+
if (!enabled || !queryRef.current) return null
|
|
93
|
+
|
|
94
|
+
return new LiveQuery({debounce, models: modelsRef.current, query: queryRef.current})
|
|
95
|
+
}, [dependencyKey, debounce, enabled])
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!liveQuery) return undefined
|
|
99
|
+
|
|
100
|
+
liveQuery.start()
|
|
101
|
+
|
|
102
|
+
return () => liveQuery.close()
|
|
103
|
+
}, [liveQuery])
|
|
104
|
+
|
|
105
|
+
const subscribe = useCallback((/** @type {() => void} */ listener) => {
|
|
106
|
+
if (!liveQuery) return () => {}
|
|
107
|
+
|
|
108
|
+
return liveQuery.subscribe(listener)
|
|
109
|
+
}, [liveQuery])
|
|
110
|
+
|
|
111
|
+
const getSnapshot = useCallback(() => {
|
|
112
|
+
if (!liveQuery) return /** @type {LiveQueryState<T>} */ (EMPTY_STATE)
|
|
113
|
+
|
|
114
|
+
return liveQuery.getState()
|
|
115
|
+
}, [liveQuery])
|
|
116
|
+
|
|
117
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
118
|
+
}
|