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.
Files changed (31) hide show
  1. package/README.md +1 -0
  2. package/build/database/live-query.js +244 -0
  3. package/build/database/record/index.js +25 -0
  4. package/build/database/record-changes.js +135 -0
  5. package/build/database/use-live-query.js +118 -0
  6. package/build/src/database/live-query.d.ts +168 -0
  7. package/build/src/database/live-query.d.ts.map +1 -0
  8. package/build/src/database/live-query.js +211 -0
  9. package/build/src/database/record/index.d.ts +11 -0
  10. package/build/src/database/record/index.d.ts.map +1 -1
  11. package/build/src/database/record/index.js +23 -1
  12. package/build/src/database/record-changes.d.ts +111 -0
  13. package/build/src/database/record-changes.d.ts.map +1 -0
  14. package/build/src/database/record-changes.js +115 -0
  15. package/build/src/database/use-live-query.d.ts +44 -0
  16. package/build/src/database/use-live-query.d.ts.map +1 -0
  17. package/build/src/database/use-live-query.js +97 -0
  18. package/build/src/sync/sync-api-client.d.ts.map +1 -1
  19. package/build/src/sync/sync-api-client.js +17 -10
  20. package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
  21. package/build/src/sync/sync-realtime-bridge.js +9 -5
  22. package/build/sync/sync-api-client.js +19 -11
  23. package/build/sync/sync-realtime-bridge.js +9 -4
  24. package/build/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +1 -1
  26. package/src/database/live-query.js +244 -0
  27. package/src/database/record/index.js +25 -0
  28. package/src/database/record-changes.js +135 -0
  29. package/src/database/use-live-query.js +118 -0
  30. package/src/sync/sync-api-client.js +19 -11
  31. 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
+ }