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
@@ -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
+ }
@@ -2,6 +2,8 @@
2
2
 
3
3
  import {optionalBoolean, optionalInteger} from "typanic"
4
4
 
5
+ import recordChanges from "../database/record-changes.js"
6
+
5
7
  const syncTaskPromises = new Map()
6
8
 
7
9
  /** @typedef {import("./sync-api-client-types.js").SyncChangeApplyResult} SyncChangeApplyResult */
@@ -128,18 +130,24 @@ export default class SyncApiClient {
128
130
 
129
131
  pages += 1
130
132
 
131
- for (const sync of syncs) {
132
- const applyResult = await args.applySync(sync)
133
- const resourceType = applyResult.resourceType ?? sync.resourceType()
134
-
135
- changed ||= applyResult.changed === true
136
- syncedCount += 1
137
-
138
- if (resourceType) {
139
- resourceCounts[resourceType] = (resourceCounts[resourceType] || 0) + 1
140
- resourceChanged[resourceType] ||= applyResult.changed === true
133
+ // Coalesce record-change events across this page's applies so N applied rows trigger one
134
+ // live-query re-run. Only the apply loop is batched: the network page fetch above and the
135
+ // cursor save below stay outside, so live queries flush right after the applies instead of
136
+ // waiting for the rest of the pull.
137
+ await recordChanges.batch(async () => {
138
+ for (const sync of syncs) {
139
+ const applyResult = await args.applySync(sync)
140
+ const resourceType = applyResult.resourceType ?? sync.resourceType()
141
+
142
+ changed ||= applyResult.changed === true
143
+ syncedCount += 1
144
+
145
+ if (resourceType) {
146
+ resourceCounts[resourceType] = (resourceCounts[resourceType] || 0) + 1
147
+ resourceChanged[resourceType] ||= applyResult.changed === true
148
+ }
141
149
  }
142
- }
150
+ })
143
151
 
144
152
  afterCursor = changesResponse.nextCursor
145
153
 
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
 
3
+ import recordChanges from "../database/record-changes.js"
4
+
3
5
  import SyncApiClient from "./sync-api-client.js"
4
6
  import {VELOCIOUS_SYNC_CHANNEL} from "./sync-channel-name.js"
5
7
 
@@ -350,11 +352,14 @@ export default class SyncRealtimeBridge {
350
352
  const syncPayloads = Array.isArray(body.syncs) ? body.syncs : [body]
351
353
  const applySync = this.syncClient.remoteApplySync({source: "remote change"})
352
354
 
353
- for (const syncPayload of syncPayloads) {
354
- const sync = SyncApiClient.syncEnvelopeFromPayload({resourceType, ...syncPayload})
355
+ // Coalesce record-change events across the pushed batch so it triggers one live-query re-run.
356
+ await recordChanges.batch(async () => {
357
+ for (const syncPayload of syncPayloads) {
358
+ const sync = SyncApiClient.syncEnvelopeFromPayload({resourceType, ...syncPayload})
355
359
 
356
- await applySync(sync)
357
- }
360
+ await applySync(sync)
361
+ }
362
+ })
358
363
  }
359
364
 
360
365
  /**