teamplay 0.4.0-alpha.3 → 0.4.0-alpha.31

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.
@@ -94,9 +94,11 @@ $.users.user1.name.parent(2) // $.users
94
94
  Legacy path navigation. Accepts:
95
95
  - string with dot path (`'a.b.c'`)
96
96
  - integer index for arrays (`0`)
97
+ - multiple path segments (`'a', 'b', 0`)
97
98
 
98
99
  ```js
99
100
  $.users.user1.at('profile.name')
101
+ $.users.user1.at('profile', 'name')
100
102
  $.items.at(0)
101
103
  ```
102
104
 
@@ -106,6 +108,7 @@ Resolve a path from root, ignoring the current signal path.
106
108
 
107
109
  ```js
108
110
  $.users.user1.scope('users.user2')
111
+ $.users.user1.scope('users', 'user2')
109
112
  ```
110
113
 
111
114
  ### ref(target) / ref(subpath, target)
@@ -171,6 +174,49 @@ $alias.get() === $user.get() // false
171
174
  - No event emissions specific to refs.
172
175
  - No support for racer-style ref meta/options beyond the basic signature.
173
176
 
177
+ ### start(targetPath, ...deps, getter)
178
+
179
+ Legacy computed binding API from Racer/StartupJS.
180
+ Creates a reactive computation and writes its result into `targetPath`.
181
+ Source of truth is root API (`$root.start(...)`), but non-root calls are supported as sugar:
182
+ - `$scope.start('a.b', ...deps, getter)` → `$root.start('<scopePath>.a.b', ...deps, getter)`
183
+
184
+ - `targetPath`: string path where computed value is written.
185
+ - `deps`: dependencies used by `getter`.
186
+ - `getter`: function called as `getter(...resolvedDeps)`.
187
+
188
+ Dependency resolution:
189
+ - Signal-like dep (`$doc`, `$session.user`) → `dep.get()`.
190
+ - String dep (`'settings.theme'`) → `$root.get(dep)`.
191
+ - Any other dep → passed as-is.
192
+
193
+ ```js
194
+ $root.start('_virtual.lesson', $.lessons[lessonId], '_session.userId', (lesson, userId) => {
195
+ if (!lesson) return undefined
196
+ return { stageIds: lesson.stageIds, userId }
197
+ })
198
+ ```
199
+
200
+ Behavior:
201
+ - Calling `start()` again for the same `targetPath` replaces previous reaction.
202
+ - `undefined` result applies compat delete semantics at target path.
203
+ - `null` result is stored as `null`.
204
+ - Returns target signal.
205
+ - If any dependency temporarily suspends (throws a Promise/thenable), compat skips the whole tick (getter is not called and target is not written).
206
+ - If `getter` throws a Promise, compat skips that tick and retries on next reactive update.
207
+
208
+ ### stop(targetPath)
209
+
210
+ Stops a computation created with `start(targetPath, ...)`.
211
+ No-op if there is no active computation for the path.
212
+ Source of truth is root API (`$root.stop(...)`), but non-root calls are supported as sugar:
213
+ - `$scope.stop('a.b')` → `$root.stop('<scopePath>.a.b')`
214
+ - `$scope.stop()` → `$root.stop('<scopePath>')`
215
+
216
+ ```js
217
+ $root.stop('_virtual.lesson')
218
+ ```
219
+
174
220
  ### query(collection, query, options?)
175
221
 
176
222
  Creates a query signal **without** subscribing. Supports shorthand params:
@@ -236,6 +282,7 @@ Returns the current value and tracks reactivity.
236
282
  const name = $.users.user1.name.get()
237
283
  $root.get('$render.url')
238
284
  $user.get('profile.name')
285
+ $user.get('profile', 'name')
239
286
  ```
240
287
 
241
288
  ### peek(subpath?)
@@ -245,6 +292,7 @@ Returns the current value **without** tracking reactivity.
245
292
  ```js
246
293
  const name = $.users.user1.name.peek()
247
294
  $user.peek('profile.name')
295
+ $user.peek('profile', 'name')
248
296
  ```
249
297
 
250
298
  ### getCopy(subpath)
@@ -306,6 +354,20 @@ for (const $doc of $query) {
306
354
  }
307
355
  ```
308
356
 
357
+ ### Mutator Semantics (Core vs Compat)
358
+
359
+ Compatibility mode intentionally aligns mutators with Racer. This differs from core `Signal` behavior.
360
+
361
+ | API | Core (`Signal`) | Compat (`SignalCompat`) |
362
+ | --- | --- | --- |
363
+ | `set` | Uses deep-diff path (`dataTree.set` + internal `setDiffDeep`). | Path-targeted replace semantics, Racer-like. `undefined` keeps delete semantics. |
364
+ | `setEach` | Not a special API in core mutators. | Per-key compat `set` (not `assign` merge/delete behavior). |
365
+ | `setDiffDeep` | Deep-diff engine (`utils/setDiffDeep.js`). | Recursive Racer-like diff implemented via compat mutators (`set` / `del`) on nested paths. |
366
+ | `setDiff` | N/A as compat shim. | Alias to compat `set` for both signatures: `setDiff(value)` and `setDiff(path, value)`. |
367
+
368
+ Migration note: compat behavior is intentionally Racer-aligned and may differ from core mutators.
369
+ Composite compat mutators (`setEach`, `setDiffDeep`) apply updates atomically for Teamplay-scheduled observers via the runtime batch scheduler.
370
+
309
371
  ### set(value) and set(path, value)
310
372
 
311
373
  `SignalCompat` accepts both:
@@ -315,7 +377,14 @@ $.users.user1.name.set('Alice')
315
377
  $.users.user1.set('profile.name', 'Alice')
316
378
  ```
317
379
 
318
- In compat mode, `set` replaces values at the target path.
380
+ In compat mode, `set` replaces the value at the target path.
381
+ - `set(path, null)` stores `null`.
382
+ - `set(path, undefined)` applies current delete semantics.
383
+
384
+ ```js
385
+ await $.users.user1.set('profile', { name: 'Ann', role: 'student' })
386
+ await $.users.user1.set('profile', { name: 'Kate' }) // role is removed
387
+ ```
319
388
 
320
389
  ### setNull(path?, value)
321
390
 
@@ -327,26 +396,47 @@ $.config.setNull('theme', 'light')
327
396
 
328
397
  ### setDiffDeep(path?, value)
329
398
 
330
- Applies a diff-deep update (uses base `Signal.set` internally).
399
+ Applies a recursive Racer-like diff using compat mutators (`set` / `del`) on subpaths.
400
+ This is intentionally a compat implementation detail and differs from core deep-diff internals.
331
401
 
332
402
  ```js
333
- $.users.user1.setDiffDeep({ profile: { name: 'Alice' } })
403
+ await $.users.user1.set({ profile: { name: 'Ann', role: 'student' } })
404
+ await $.users.user1.setDiffDeep({ profile: { name: 'Kate' } }) // deep-diff path
334
405
  ```
335
406
 
336
407
  ### setDiff(path?, value)
337
408
 
338
- Alias for `set()` in compat. Accepts the same arguments and semantics.
409
+ Alias for compat `set` in both forms:
410
+ - `setDiff(value)` -> same as `set(value)`
411
+ - `setDiff(path, value)` -> same as `set(path, value)`
339
412
 
340
413
  ```js
341
- $.users.user1.setDiff({ profile: { name: 'Alice' } })
414
+ await $.users.user1.setDiff({ profile: { name: 'Kate' } })
415
+ await $.users.user1.setDiff('profile', { name: 'Bob' })
342
416
  ```
343
417
 
344
418
  ### setEach(path?, object)
345
419
 
346
- Shorthand for assign. Sets or deletes fields from an object.
420
+ Racer-like per-key set. `setEach` iterates keys and applies compat `set` for each key.
421
+ - `setEach({ k: null })` stores `null`.
422
+ - `setEach({ k: undefined })` applies current delete semantics.
347
423
 
348
424
  ```js
349
- $.users.user1.setEach({ name: 'Bob', age: 30 })
425
+ await $.users.user1.setEach({ name: 'Bob', age: null })
426
+ ```
427
+
428
+ ### Null / Undefined Matrix (Compat)
429
+
430
+ | Call | Result |
431
+ | --- | --- |
432
+ | `set(path, null)` | stores `null` at `path` |
433
+ | `set(path, undefined)` | applies delete semantics at `path` |
434
+ | `setEach({ k: null })` | stores `null` for `k` |
435
+ | `setEach({ k: undefined })` | applies delete semantics for `k` |
436
+
437
+ ```js
438
+ await $.users.user1.set('status', null) // status === null
439
+ await $.users.user1.setEach({ status: undefined }) // status deleted
350
440
  ```
351
441
 
352
442
  ### assign(object)
@@ -672,10 +762,12 @@ This matches StartupJS and makes updates easy:
672
762
  $users[userId].name.set('New Name')
673
763
  ```
674
764
 
675
- `useQuery$` returns the collection signal as well:
765
+ `useQuery$` returns the **query signal**:
676
766
 
677
767
  ```js
678
- const $users = useQuery$('users', { active: true })
768
+ const $query = useQuery$('users', { active: true })
769
+ const ids = $query.getIds()
770
+ const docs = $query.get()
679
771
  ```
680
772
 
681
773
  If `query == null`, a warning is logged and `{ _id: '__NON_EXISTENT__' }` is used.
@@ -758,6 +850,8 @@ const Component = observer(() => {
758
850
  ```js
759
851
  const Component = observer(() => {
760
852
  const [users, $users] = useQuery('users', { active: true })
853
+ const $query = useQuery$('users', { active: true })
854
+ const ids = $query.getIds()
761
855
  return (
762
856
  <>
763
857
  {users.map(u => <div key={u._id}>{u.name}</div>)}
@@ -8,15 +8,14 @@ import {
8
8
  isPublicCollectionSignal,
9
9
  isPublicDocumentSignal
10
10
  } from '../SignalBase.js'
11
- import { getRoot } from '../Root.js'
11
+ import { getRoot, ROOT } from '../Root.js'
12
12
  import { publicOnly, fetchOnly, setFetchOnly } from '../connection.js'
13
13
  import { docSubscriptions } from '../Doc.js'
14
14
  import { IS_QUERY, getQuerySignal, querySubscriptions } from '../Query.js'
15
15
  import { IS_AGGREGATION, aggregationSubscriptions, getAggregationSignal } from '../Aggregation.js'
16
- import { getIdFieldsForSegments, isIdFieldPath, normalizeIdFields } from '../idFields.js'
16
+ import { getIdFieldsForSegments, isIdFieldPath, normalizeIdFields, isPlainObject } from '../idFields.js'
17
17
  import {
18
18
  setReplace as _setReplace,
19
- setPublicDocReplace as _setPublicDocReplace,
20
19
  incrementPublic as _incrementPublic,
21
20
  arrayPush as _arrayPush,
22
21
  arrayUnshift as _arrayUnshift,
@@ -40,11 +39,17 @@ import {
40
39
  import { on as onCustomEvent, removeListener as removeCustomEventListener } from './eventsCompat.js'
41
40
  import { normalizePattern, onModelEvent, removeModelListener } from './modelEvents.js'
42
41
  import { setRefLink, removeRefLink } from './refRegistry.js'
42
+ import { REF_TARGET, resolveRefSignalSafe } from './refFallback.js'
43
+ import { runInBatch } from '../batchScheduler.js'
43
44
 
44
45
  class SignalCompat extends Signal {
45
46
  static ID_FIELDS = ['_id', 'id']
46
47
  static [GETTERS] = [...DEFAULT_GETTERS, 'getCopy', 'getDeepCopy']
47
48
 
49
+ get root () {
50
+ return this.scope()
51
+ }
52
+
48
53
  path (subpath) {
49
54
  if (arguments.length > 1) throw Error('Signal.path() expects a single argument')
50
55
  if (arguments.length === 0) return super.path()
@@ -54,8 +59,9 @@ class SignalCompat extends Signal {
54
59
  }
55
60
 
56
61
  at (subpath) {
57
- if (arguments.length > 1) throw Error('Signal.at() expects a single argument')
58
- const segments = parseAtSubpath(subpath, arguments.length, 'Signal.at()')
62
+ const segments = arguments.length > 1
63
+ ? parseAtSegments(arguments, 'Signal.at()')
64
+ : parseAtSubpath(subpath, arguments.length, 'Signal.at()')
59
65
  if (segments.length === 0) return this
60
66
  let $cursor = this
61
67
  for (const segment of segments) {
@@ -118,8 +124,18 @@ class SignalCompat extends Signal {
118
124
  }
119
125
 
120
126
  get () {
121
- if (arguments.length > 1) throw Error('Signal.get() expects zero or one argument')
127
+ if (arguments.length > 1) {
128
+ const segments = parseAtSegments(arguments, 'Signal.get()')
129
+ const $base = resolveRefSignal(this)
130
+ const $target = resolveSignal($base, segments)
131
+ return Signal.prototype.get.call($target)
132
+ }
122
133
  if (arguments.length === 1) {
134
+ if (arguments[0] == null) {
135
+ const $target = resolveRefSignal(this)
136
+ if ($target !== this) return Signal.prototype.get.apply($target, [])
137
+ return Signal.prototype.get.apply(this, [])
138
+ }
123
139
  const segments = parseAtSubpath(arguments[0], 1, 'Signal.get()')
124
140
  const $base = resolveRefSignal(this)
125
141
  const $target = resolveSignal($base, segments)
@@ -131,8 +147,18 @@ class SignalCompat extends Signal {
131
147
  }
132
148
 
133
149
  peek () {
134
- if (arguments.length > 1) throw Error('Signal.peek() expects zero or one argument')
150
+ if (arguments.length > 1) {
151
+ const segments = parseAtSegments(arguments, 'Signal.peek()')
152
+ const $base = resolveRefSignal(this)
153
+ const $target = resolveSignal($base, segments)
154
+ return Signal.prototype.peek.call($target)
155
+ }
135
156
  if (arguments.length === 1) {
157
+ if (arguments[0] == null) {
158
+ const $target = resolveRefSignal(this)
159
+ if ($target !== this) return Signal.prototype.peek.apply($target, [])
160
+ return Signal.prototype.peek.apply(this, [])
161
+ }
136
162
  const segments = parseAtSubpath(arguments[0], 1, 'Signal.peek()')
137
163
  const $base = resolveRefSignal(this)
138
164
  const $target = resolveSignal($base, segments)
@@ -154,7 +180,24 @@ class SignalCompat extends Signal {
154
180
  value = path
155
181
  }
156
182
  const $target = resolveSignal(this, segments)
157
- return Signal.prototype.set.call($target, value)
183
+ if (value === undefined) return Signal.prototype.set.call($target, value)
184
+ return setReplaceOnSignal($target, value)
185
+ }
186
+
187
+ async add (collectionOrValue, value) {
188
+ const isRoot = this[SEGMENTS].length === 0
189
+ const isRootCollectionCall = isRoot && typeof collectionOrValue === 'string'
190
+
191
+ if (isRootCollectionCall) {
192
+ if (arguments.length !== 2) throw Error('Signal.add() expects (collection, object)')
193
+ if (!value || typeof value !== 'object') throw Error('Signal.add() expects an object argument')
194
+ const $root = getRoot(this) || this
195
+ const $collection = resolveSignal($root, [collectionOrValue])
196
+ return $collection.add(value)
197
+ }
198
+
199
+ if (arguments.length > 1) throw Error('Signal.add() expects a single argument')
200
+ return Signal.prototype.add.call(this, collectionOrValue)
158
201
  }
159
202
 
160
203
  async setNull (path, value) {
@@ -183,7 +226,7 @@ class SignalCompat extends Signal {
183
226
  value = path
184
227
  }
185
228
  const $target = resolveSignal(this, segments)
186
- return Signal.prototype.set.call($target, value)
229
+ return runInBatch(() => setDiffDeepOnSignal($target, value))
187
230
  }
188
231
 
189
232
  async setDiff (path, value) {
@@ -191,7 +234,7 @@ class SignalCompat extends Signal {
191
234
  if (forwarded) return forwarded
192
235
  if (arguments.length > 2) throw Error('Signal.setDiff() expects one or two arguments')
193
236
  if (arguments.length === 1) {
194
- return Signal.prototype.set.call(this, path)
237
+ return this.set(path)
195
238
  }
196
239
  return this.set(path, value)
197
240
  }
@@ -207,7 +250,17 @@ class SignalCompat extends Signal {
207
250
  object = path
208
251
  }
209
252
  const $target = resolveSignal(this, segments)
210
- return Signal.prototype.assign.call($target, object)
253
+ if (!object) return
254
+ if (typeof object !== 'object') {
255
+ throw Error('Signal.setEach() expects an object argument, got: ' + typeof object)
256
+ }
257
+ return runInBatch(async () => {
258
+ const promises = []
259
+ for (const key of Object.keys(object)) {
260
+ promises.push(SignalCompat.prototype.set.call($target[key], object[key]))
261
+ }
262
+ await Promise.all(promises)
263
+ })
211
264
  }
212
265
 
213
266
  async del (path) {
@@ -509,11 +562,11 @@ class SignalCompat extends Signal {
509
562
  }
510
563
 
511
564
  scope (path) {
512
- if (arguments.length > 1) throw Error('Signal.scope() expects a single argument')
513
565
  const $root = getRoot(this) || this
514
566
  if (arguments.length === 0) return $root
515
- if (typeof path !== 'string') throw Error('Signal.scope() expects a string argument')
516
- const segments = path.split('.').filter(Boolean)
567
+ const segments = arguments.length > 1
568
+ ? parseAtSegments(arguments, 'Signal.scope()')
569
+ : parseAtSubpath(path, arguments.length, 'Signal.scope()')
517
570
  if (segments.length === 0) return $root
518
571
  let $cursor = $root
519
572
  for (const segment of segments) {
@@ -524,7 +577,6 @@ class SignalCompat extends Signal {
524
577
  }
525
578
 
526
579
  const REFS = Symbol('compat refs')
527
- const REF_TARGET = Symbol('compat ref target')
528
580
 
529
581
  function getRefStore ($signal) {
530
582
  const $root = getRoot($signal) || $signal
@@ -559,14 +611,7 @@ function trackDeep (value, seen = new Set()) {
559
611
  }
560
612
 
561
613
  function resolveRefSignal ($signal) {
562
- let current = $signal
563
- const seen = new Set()
564
- while (current && current[REF_TARGET]) {
565
- if (seen.has(current)) break
566
- seen.add(current)
567
- current = current[REF_TARGET]
568
- }
569
- return current
614
+ return resolveRefSignalSafe($signal) || $signal
570
615
  }
571
616
 
572
617
  function forwardRef ($signal, methodName, args) {
@@ -583,6 +628,10 @@ function isSignalLike (value) {
583
628
  return value && typeof value.path === 'function' && typeof value.get === 'function'
584
629
  }
585
630
 
631
+ function isReactLike (value) {
632
+ return !!(value && typeof value === 'object' && typeof value.$$typeof === 'symbol')
633
+ }
634
+
586
635
  function resolveRefTarget ($signal, target, methodName) {
587
636
  if (isSignalLike(target)) return target
588
637
  if (typeof target === 'string') {
@@ -600,6 +649,23 @@ function parseAtSubpath (subpath, argsLength, methodName) {
600
649
  throw Error(`${methodName} expects a string or integer argument`)
601
650
  }
602
651
 
652
+ function parseAtSegments (args, methodName) {
653
+ const segments = []
654
+ for (const arg of Array.from(args)) {
655
+ if (typeof arg === 'string') {
656
+ const parts = arg.split('.').filter(Boolean)
657
+ segments.push(...parts)
658
+ continue
659
+ }
660
+ if (typeof arg === 'number' && Number.isFinite(arg) && Number.isInteger(arg)) {
661
+ segments.push(arg)
662
+ continue
663
+ }
664
+ throw Error(`${methodName} expects string or integer path segments`)
665
+ }
666
+ return segments
667
+ }
668
+
603
669
  function resolveSignal ($signal, segments) {
604
670
  let $cursor = $signal
605
671
  for (const segment of segments) {
@@ -608,6 +674,89 @@ function resolveSignal ($signal, segments) {
608
674
  return $cursor
609
675
  }
610
676
 
677
+ async function setDiffDeepOnSignal ($target, value) {
678
+ if ($target[SEGMENTS].length === 0) throw Error('Can\'t set the root signal data')
679
+ const before = $target.get()
680
+ await diffDeepCompat($target, before, value)
681
+ }
682
+
683
+ async function diffDeepCompat ($signal, before, after) {
684
+ if (before === after) return
685
+
686
+ if (Array.isArray(before) && Array.isArray(after)) {
687
+ if (deepEqualCompat(before, after)) return
688
+ const changedIndexes = getChangedArrayIndexes(before, after)
689
+ if (before.length === after.length && changedIndexes.length === 1) {
690
+ const index = changedIndexes[0]
691
+ await diffDeepCompat(getChildSignal($signal, index), before[index], after[index])
692
+ return
693
+ }
694
+ await SignalCompat.prototype.set.call($signal, after)
695
+ return
696
+ }
697
+
698
+ if (isDiffableObject(before, after)) {
699
+ for (const key of Object.keys(before)) {
700
+ if (Object.prototype.hasOwnProperty.call(after, key)) continue
701
+ await SignalCompat.prototype.del.call(getChildSignal($signal, key))
702
+ }
703
+ for (const key of Object.keys(after)) {
704
+ await diffDeepCompat(getChildSignal($signal, key), before[key], after[key])
705
+ }
706
+ return
707
+ }
708
+
709
+ await SignalCompat.prototype.set.call($signal, after)
710
+ }
711
+
712
+ function isDiffableObject (before, after) {
713
+ if (!isPlainObject(before) || !isPlainObject(after)) return false
714
+ if (isReactLike(before) || isReactLike(after)) return false
715
+ return true
716
+ }
717
+
718
+ function getChangedArrayIndexes (before, after) {
719
+ if (!Array.isArray(before) || !Array.isArray(after)) return []
720
+ const maxLength = Math.max(before.length, after.length)
721
+ const changed = []
722
+ for (let i = 0; i < maxLength; i++) {
723
+ if (!deepEqualCompat(before[i], after[i])) changed.push(i)
724
+ }
725
+ return changed
726
+ }
727
+
728
+ function getChildSignal ($parent, key) {
729
+ const $child = new SignalCompat([...$parent[SEGMENTS], key])
730
+ const $root = getRoot($parent)
731
+ if ($root) $child[ROOT] = $root
732
+ return $child
733
+ }
734
+
735
+ function deepEqualCompat (left, right) {
736
+ if (left === right) return true
737
+ if (left == null || right == null) return false
738
+ if (typeof left !== 'object' || typeof right !== 'object') return false
739
+ if (Array.isArray(left) !== Array.isArray(right)) return false
740
+
741
+ if (Array.isArray(left)) {
742
+ if (left.length !== right.length) return false
743
+ for (let i = 0; i < left.length; i++) {
744
+ if (!deepEqualCompat(left[i], right[i])) return false
745
+ }
746
+ return true
747
+ }
748
+
749
+ if (!isPlainObject(left) || !isPlainObject(right)) return false
750
+ const leftKeys = Object.keys(left)
751
+ const rightKeys = Object.keys(right)
752
+ if (leftKeys.length !== rightKeys.length) return false
753
+ for (const key of leftKeys) {
754
+ if (!Object.prototype.hasOwnProperty.call(right, key)) return false
755
+ if (!deepEqualCompat(left[key], right[key])) return false
756
+ }
757
+ return true
758
+ }
759
+
611
760
  function getSignalValueAt ($signal, segments) {
612
761
  const $target = resolveSignal($signal, segments)
613
762
  return $target.get()
@@ -622,7 +771,7 @@ async function setReplaceOnSignal ($signal, value) {
622
771
  value = normalizeIdFields(value, idFields, segments[1])
623
772
  }
624
773
  if (isPublicCollection(segments[0])) {
625
- return _setPublicDocReplace(segments, value)
774
+ return Signal.prototype.set.call($signal, value)
626
775
  }
627
776
  if (publicOnly) throw Error(ERRORS.publicOnly)
628
777
  return _setReplace(segments, value)
@@ -1,6 +1,7 @@
1
1
  import { getRootSignal, GLOBAL_ROOT_ID } from '../Root.js'
2
2
  import useSub, { useAsyncSub } from '../../react/useSub.js'
3
3
  import universal$ from '../../react/universal$.js'
4
+ import * as promiseBatcher from '../../react/promiseBatcher.js'
4
5
 
5
6
  const $root = getRootSignal({ rootId: GLOBAL_ROOT_ID, rootFunction: universal$ })
6
7
 
@@ -72,8 +73,10 @@ export function usePage (path) {
72
73
  return useLocal(prefixLocalPath('_page', path))
73
74
  }
74
75
 
75
- // Placeholder for startupjs batching API. No-op in teamplay.
76
- export function useBatch () {}
76
+ export function useBatch () {
77
+ const promise = promiseBatcher.getPromiseAll()
78
+ if (promise) throw promise
79
+ }
77
80
 
78
81
  export function useDoc$ (collection, id, options) {
79
82
  const $doc = getDocSignal(collection, id, 'useDoc')
@@ -86,13 +89,16 @@ export function useDoc (collection, id, options) {
86
89
  return [$doc.get(), $doc]
87
90
  }
88
91
 
89
- // Batch variants are aliases to non-batch versions (no batching in teamplay).
90
92
  export function useBatchDoc (collection, id, options) {
91
- return useDoc(collection, id, options)
93
+ const $doc = useBatchDoc$(collection, id, options)
94
+ if (!$doc) return [undefined, undefined]
95
+ return [$doc.get(), $doc]
92
96
  }
93
97
 
94
- export function useBatchDoc$ (collection, id, options) {
95
- return useDoc$(collection, id, options)
98
+ export function useBatchDoc$ (collection, id, _options) {
99
+ const $doc = getDocSignal(collection, id, 'useBatchDoc')
100
+ const options = _options ? { ..._options, ...BATCH_SUB_OPTIONS } : BATCH_SUB_OPTIONS
101
+ return useSub($doc, undefined, options)
96
102
  }
97
103
 
98
104
  export function useAsyncDoc$ (collection, id, options) {
@@ -109,8 +115,8 @@ export function useAsyncDoc (collection, id, options) {
109
115
  export function useQuery$ (collection, query, options) {
110
116
  const $collection = getCollectionSignal(collection, query, 'useQuery')
111
117
  const normalizedOptions = options ? { ...options, async: false } : options
112
- useSub($collection, normalizeQuery(query, 'useQuery'), normalizedOptions)
113
- return $collection
118
+ const $query = useSub($collection, normalizeQuery(query, 'useQuery'), normalizedOptions)
119
+ return $query
114
120
  }
115
121
 
116
122
  export function useQuery (collection, query, options) {
@@ -122,8 +128,8 @@ export function useQuery (collection, query, options) {
122
128
 
123
129
  export function useAsyncQuery$ (collection, query, options) {
124
130
  const $collection = getCollectionSignal(collection, query, 'useAsyncQuery')
125
- useAsyncSub($collection, normalizeQuery(query, 'useAsyncQuery'), options)
126
- return $collection
131
+ const $query = useAsyncSub($collection, normalizeQuery(query, 'useAsyncQuery'), options)
132
+ return $query
127
133
  }
128
134
 
129
135
  export function useAsyncQuery (collection, query, options) {
@@ -133,13 +139,17 @@ export function useAsyncQuery (collection, query, options) {
133
139
  return [$query.get(), $collection]
134
140
  }
135
141
 
136
- // Batch variants are aliases to non-batch versions (no batching in teamplay).
137
- export function useBatchQuery$ (collection, query, options) {
138
- return useQuery$(collection, query, options)
142
+ export function useBatchQuery$ (collection, query, _options) {
143
+ const $collection = getCollectionSignal(collection, query, 'useBatchQuery')
144
+ const options = _options ? { ..._options, ...BATCH_SUB_OPTIONS } : BATCH_SUB_OPTIONS
145
+ return useSub($collection, normalizeQuery(query, 'useBatchQuery'), options)
139
146
  }
140
147
 
141
148
  export function useBatchQuery (collection, query, options) {
142
- return useQuery(collection, query, options)
149
+ const $collection = getCollectionSignal(collection, query, 'useBatchQuery')
150
+ const $query = useBatchQuery$(collection, query, options)
151
+ if (!$query) return [undefined, $collection]
152
+ return [$query.get(), $collection]
143
153
  }
144
154
 
145
155
  export function useQueryIds (collection, ids = [], options = {}) {
@@ -157,7 +167,17 @@ export function useQueryIds (collection, ids = [], options = {}) {
157
167
  }
158
168
 
159
169
  export function useBatchQueryIds (collection, ids = [], options = {}) {
160
- return useQueryIds(collection, ids, options)
170
+ const list = Array.isArray(ids) ? ids.slice() : []
171
+ if (options?.reverse) list.reverse()
172
+ const [docs, $collection] = useBatchQuery(collection, { _id: { $in: list } }, options)
173
+ if (!docs) return [docs, $collection]
174
+ const docsById = new Map()
175
+ for (const doc of docs) {
176
+ const id = doc?._id ?? doc?.id
177
+ if (id != null) docsById.set(id, doc)
178
+ }
179
+ const items = list.map(id => docsById.get(id)).filter(Boolean)
180
+ return [items, $collection]
161
181
  }
162
182
 
163
183
  export function useAsyncQueryIds (collection, ids = [], options = {}) {
@@ -194,11 +214,23 @@ export function useQueryDoc$ (collection, query, options) {
194
214
  }
195
215
 
196
216
  export function useBatchQueryDoc (collection, query, options) {
197
- return useQueryDoc(collection, query, options)
217
+ const normalized = normalizeQuery(query, 'useBatchQueryDoc')
218
+ const queryDoc = {
219
+ ...normalized,
220
+ $limit: 1,
221
+ $sort: normalized.$sort || { createdAt: -1 }
222
+ }
223
+ const [docs, $collection] = useBatchQuery(collection, queryDoc, options)
224
+ if (!docs) return [undefined, undefined]
225
+ const doc = docs && docs[0]
226
+ const docId = doc?._id ?? doc?.id
227
+ const $doc = docId != null ? $collection[docId] : undefined
228
+ return [doc, $doc]
198
229
  }
199
230
 
200
231
  export function useBatchQueryDoc$ (collection, query, options) {
201
- return useQueryDoc$(collection, query, options)
232
+ const [, $doc] = useBatchQueryDoc(collection, query, options)
233
+ return $doc
202
234
  }
203
235
 
204
236
  export function useAsyncQueryDoc (collection, query, options) {
@@ -276,3 +308,11 @@ function normalizeQuery (query, hookName) {
276
308
  }
277
309
  return query
278
310
  }
311
+
312
+ const BATCH_SUB_OPTIONS = Object.freeze({
313
+ async: false,
314
+ batch: true,
315
+ // Batch hooks are a hard suspense barrier. Deferred params can skip the barrier
316
+ // on route transitions and cause immediate reads from stale/empty local nodes.
317
+ defer: false
318
+ })
@@ -1,4 +1,5 @@
1
1
  import { getRefLinks } from './refRegistry.js'
2
+ import { isCompatEnv } from '../compatEnv.js'
2
3
 
3
4
  const modelListeners = {
4
5
  change: new Map(),
@@ -6,10 +7,7 @@ const modelListeners = {
6
7
  }
7
8
 
8
9
  export function isModelEventsEnabled () {
9
- return (
10
- globalThis?.teamplayCompatibilityMode ??
11
- (typeof process !== 'undefined' && process?.env?.TEAMPLAY_COMPAT === '1')
12
- )
10
+ return isCompatEnv()
13
11
  }
14
12
 
15
13
  export function normalizePattern (pattern, methodName) {