teamplay 0.4.0-alpha.2 → 0.4.0-alpha.21

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)
@@ -228,20 +231,25 @@ await $$agg.subscribe()
228
231
  const rows = $$agg.getExtra()
229
232
  ```
230
233
 
231
- ### get()
234
+ ### get(subpath?)
232
235
 
233
236
  Returns the current value and tracks reactivity.
234
237
 
235
238
  ```js
236
239
  const name = $.users.user1.name.get()
240
+ $root.get('$render.url')
241
+ $user.get('profile.name')
242
+ $user.get('profile', 'name')
237
243
  ```
238
244
 
239
- ### peek()
245
+ ### peek(subpath?)
240
246
 
241
247
  Returns the current value **without** tracking reactivity.
242
248
 
243
249
  ```js
244
250
  const name = $.users.user1.name.peek()
251
+ $user.peek('profile.name')
252
+ $user.peek('profile', 'name')
245
253
  ```
246
254
 
247
255
  ### getCopy(subpath)
@@ -669,10 +677,12 @@ This matches StartupJS and makes updates easy:
669
677
  $users[userId].name.set('New Name')
670
678
  ```
671
679
 
672
- `useQuery$` returns the collection signal as well:
680
+ `useQuery$` returns the **query signal**:
673
681
 
674
682
  ```js
675
- const $users = useQuery$('users', { active: true })
683
+ const $query = useQuery$('users', { active: true })
684
+ const ids = $query.getIds()
685
+ const docs = $query.get()
676
686
  ```
677
687
 
678
688
  If `query == null`, a warning is logged and `{ _id: '__NON_EXISTENT__' }` is used.
@@ -755,6 +765,8 @@ const Component = observer(() => {
755
765
  ```js
756
766
  const Component = observer(() => {
757
767
  const [users, $users] = useQuery('users', { active: true })
768
+ const $query = useQuery$('users', { active: true })
769
+ const ids = $query.getIds()
758
770
  return (
759
771
  <>
760
772
  {users.map(u => <div key={u._id}>{u.name}</div>)}
@@ -40,11 +40,16 @@ import {
40
40
  import { on as onCustomEvent, removeListener as removeCustomEventListener } from './eventsCompat.js'
41
41
  import { normalizePattern, onModelEvent, removeModelListener } from './modelEvents.js'
42
42
  import { setRefLink, removeRefLink } from './refRegistry.js'
43
+ import { REF_TARGET, resolveRefSignalSafe } from './refFallback.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,12 +124,46 @@ class SignalCompat extends Signal {
118
124
  }
119
125
 
120
126
  get () {
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
+ }
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
+ }
139
+ const segments = parseAtSubpath(arguments[0], 1, 'Signal.get()')
140
+ const $base = resolveRefSignal(this)
141
+ const $target = resolveSignal($base, segments)
142
+ return Signal.prototype.get.call($target)
143
+ }
121
144
  const $target = resolveRefSignal(this)
122
145
  if ($target !== this) return Signal.prototype.get.apply($target, arguments)
123
146
  return Signal.prototype.get.apply(this, arguments)
124
147
  }
125
148
 
126
149
  peek () {
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
+ }
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
+ }
162
+ const segments = parseAtSubpath(arguments[0], 1, 'Signal.peek()')
163
+ const $base = resolveRefSignal(this)
164
+ const $target = resolveSignal($base, segments)
165
+ return Signal.prototype.peek.call($target)
166
+ }
127
167
  const $target = resolveRefSignal(this)
128
168
  if ($target !== this) return Signal.prototype.peek.apply($target, arguments)
129
169
  return Signal.prototype.peek.apply(this, arguments)
@@ -143,6 +183,22 @@ class SignalCompat extends Signal {
143
183
  return Signal.prototype.set.call($target, value)
144
184
  }
145
185
 
186
+ async add (collectionOrValue, value) {
187
+ const isRoot = this[SEGMENTS].length === 0
188
+ const isRootCollectionCall = isRoot && typeof collectionOrValue === 'string'
189
+
190
+ if (isRootCollectionCall) {
191
+ if (arguments.length !== 2) throw Error('Signal.add() expects (collection, object)')
192
+ if (!value || typeof value !== 'object') throw Error('Signal.add() expects an object argument')
193
+ const $root = getRoot(this) || this
194
+ const $collection = resolveSignal($root, [collectionOrValue])
195
+ return $collection.add(value)
196
+ }
197
+
198
+ if (arguments.length > 1) throw Error('Signal.add() expects a single argument')
199
+ return Signal.prototype.add.call(this, collectionOrValue)
200
+ }
201
+
146
202
  async setNull (path, value) {
147
203
  const forwarded = forwardRef(this, 'setNull', arguments)
148
204
  if (forwarded) return forwarded
@@ -495,11 +551,11 @@ class SignalCompat extends Signal {
495
551
  }
496
552
 
497
553
  scope (path) {
498
- if (arguments.length > 1) throw Error('Signal.scope() expects a single argument')
499
554
  const $root = getRoot(this) || this
500
555
  if (arguments.length === 0) return $root
501
- if (typeof path !== 'string') throw Error('Signal.scope() expects a string argument')
502
- const segments = path.split('.').filter(Boolean)
556
+ const segments = arguments.length > 1
557
+ ? parseAtSegments(arguments, 'Signal.scope()')
558
+ : parseAtSubpath(path, arguments.length, 'Signal.scope()')
503
559
  if (segments.length === 0) return $root
504
560
  let $cursor = $root
505
561
  for (const segment of segments) {
@@ -510,7 +566,6 @@ class SignalCompat extends Signal {
510
566
  }
511
567
 
512
568
  const REFS = Symbol('compat refs')
513
- const REF_TARGET = Symbol('compat ref target')
514
569
 
515
570
  function getRefStore ($signal) {
516
571
  const $root = getRoot($signal) || $signal
@@ -545,14 +600,7 @@ function trackDeep (value, seen = new Set()) {
545
600
  }
546
601
 
547
602
  function resolveRefSignal ($signal) {
548
- let current = $signal
549
- const seen = new Set()
550
- while (current && current[REF_TARGET]) {
551
- if (seen.has(current)) break
552
- seen.add(current)
553
- current = current[REF_TARGET]
554
- }
555
- return current
603
+ return resolveRefSignalSafe($signal) || $signal
556
604
  }
557
605
 
558
606
  function forwardRef ($signal, methodName, args) {
@@ -586,6 +634,23 @@ function parseAtSubpath (subpath, argsLength, methodName) {
586
634
  throw Error(`${methodName} expects a string or integer argument`)
587
635
  }
588
636
 
637
+ function parseAtSegments (args, methodName) {
638
+ const segments = []
639
+ for (const arg of Array.from(args)) {
640
+ if (typeof arg === 'string') {
641
+ const parts = arg.split('.').filter(Boolean)
642
+ segments.push(...parts)
643
+ continue
644
+ }
645
+ if (typeof arg === 'number' && Number.isFinite(arg) && Number.isInteger(arg)) {
646
+ segments.push(arg)
647
+ continue
648
+ }
649
+ throw Error(`${methodName} expects string or integer path segments`)
650
+ }
651
+ return segments
652
+ }
653
+
589
654
  function resolveSignal ($signal, segments) {
590
655
  let $cursor = $signal
591
656
  for (const segment of segments) {
@@ -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,15 @@ 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
+ return useSub($doc, undefined, { async: false, batch: true })
96
101
  }
97
102
 
98
103
  export function useAsyncDoc$ (collection, id, options) {
@@ -109,8 +114,8 @@ export function useAsyncDoc (collection, id, options) {
109
114
  export function useQuery$ (collection, query, options) {
110
115
  const $collection = getCollectionSignal(collection, query, 'useQuery')
111
116
  const normalizedOptions = options ? { ...options, async: false } : options
112
- useSub($collection, normalizeQuery(query, 'useQuery'), normalizedOptions)
113
- return $collection
117
+ const $query = useSub($collection, normalizeQuery(query, 'useQuery'), normalizedOptions)
118
+ return $query
114
119
  }
115
120
 
116
121
  export function useQuery (collection, query, options) {
@@ -122,8 +127,8 @@ export function useQuery (collection, query, options) {
122
127
 
123
128
  export function useAsyncQuery$ (collection, query, options) {
124
129
  const $collection = getCollectionSignal(collection, query, 'useAsyncQuery')
125
- useAsyncSub($collection, normalizeQuery(query, 'useAsyncQuery'), options)
126
- return $collection
130
+ const $query = useAsyncSub($collection, normalizeQuery(query, 'useAsyncQuery'), options)
131
+ return $query
127
132
  }
128
133
 
129
134
  export function useAsyncQuery (collection, query, options) {
@@ -133,13 +138,16 @@ export function useAsyncQuery (collection, query, options) {
133
138
  return [$query.get(), $collection]
134
139
  }
135
140
 
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)
141
+ export function useBatchQuery$ (collection, query, _options) {
142
+ const $collection = getCollectionSignal(collection, query, 'useBatchQuery')
143
+ return useSub($collection, normalizeQuery(query, 'useBatchQuery'), { async: false, batch: true })
139
144
  }
140
145
 
141
146
  export function useBatchQuery (collection, query, options) {
142
- return useQuery(collection, query, options)
147
+ const $collection = getCollectionSignal(collection, query, 'useBatchQuery')
148
+ const $query = useBatchQuery$(collection, query, options)
149
+ if (!$query) return [undefined, $collection]
150
+ return [$query.get(), $collection]
143
151
  }
144
152
 
145
153
  export function useQueryIds (collection, ids = [], options = {}) {
@@ -157,7 +165,17 @@ export function useQueryIds (collection, ids = [], options = {}) {
157
165
  }
158
166
 
159
167
  export function useBatchQueryIds (collection, ids = [], options = {}) {
160
- return useQueryIds(collection, ids, options)
168
+ const list = Array.isArray(ids) ? ids.slice() : []
169
+ if (options?.reverse) list.reverse()
170
+ const [docs, $collection] = useBatchQuery(collection, { _id: { $in: list } }, options)
171
+ if (!docs) return [docs, $collection]
172
+ const docsById = new Map()
173
+ for (const doc of docs) {
174
+ const id = doc?._id ?? doc?.id
175
+ if (id != null) docsById.set(id, doc)
176
+ }
177
+ const items = list.map(id => docsById.get(id)).filter(Boolean)
178
+ return [items, $collection]
161
179
  }
162
180
 
163
181
  export function useAsyncQueryIds (collection, ids = [], options = {}) {
@@ -194,11 +212,23 @@ export function useQueryDoc$ (collection, query, options) {
194
212
  }
195
213
 
196
214
  export function useBatchQueryDoc (collection, query, options) {
197
- return useQueryDoc(collection, query, options)
215
+ const normalized = normalizeQuery(query, 'useBatchQueryDoc')
216
+ const queryDoc = {
217
+ ...normalized,
218
+ $limit: 1,
219
+ $sort: normalized.$sort || { createdAt: -1 }
220
+ }
221
+ const [docs, $collection] = useBatchQuery(collection, queryDoc, options)
222
+ if (!docs) return [undefined, undefined]
223
+ const doc = docs && docs[0]
224
+ const docId = doc?._id ?? doc?.id
225
+ const $doc = docId != null ? $collection[docId] : undefined
226
+ return [doc, $doc]
198
227
  }
199
228
 
200
229
  export function useBatchQueryDoc$ (collection, query, options) {
201
- return useQueryDoc$(collection, query, options)
230
+ const [, $doc] = useBatchQueryDoc(collection, query, options)
231
+ return $doc
202
232
  }
203
233
 
204
234
  export function useAsyncQueryDoc (collection, query, options) {
@@ -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) {
@@ -0,0 +1,60 @@
1
+ import { getRefLinks } from './refRegistry.js'
2
+
3
+ export const REF_TARGET = Symbol.for('teamplay.compat.refTarget')
4
+
5
+ export function resolveRefSignalSafe ($signal, maxDepth = 32) {
6
+ let current = $signal
7
+ const seen = new Set()
8
+ for (let i = 0; i < maxDepth; i++) {
9
+ if (!current) return undefined
10
+ const next = current[REF_TARGET]
11
+ if (!next) return current
12
+ if (seen.has(current)) return undefined
13
+ seen.add(current)
14
+ current = next
15
+ }
16
+ return undefined
17
+ }
18
+
19
+ export function resolveRefSegmentsSafe (segments, maxDepth = 32) {
20
+ if (!Array.isArray(segments) || segments.length === 0) return undefined
21
+ let current = [...segments]
22
+ const visited = new Set([toPathKey(current)])
23
+ let changed = false
24
+
25
+ for (let i = 0; i < maxDepth; i++) {
26
+ const link = findBestMatchingLink(current)
27
+ if (!link) return changed ? current : undefined
28
+ const suffix = current.slice(link.fromSegments.length)
29
+ const next = link.toSegments.concat(suffix)
30
+ const key = toPathKey(next)
31
+ if (visited.has(key)) return undefined
32
+ visited.add(key)
33
+ current = next
34
+ changed = true
35
+ }
36
+ return undefined
37
+ }
38
+
39
+ function findBestMatchingLink (segments) {
40
+ let best
41
+ for (const link of getRefLinks().values()) {
42
+ if (!isPathPrefix(link.fromSegments, segments)) continue
43
+ if (!best || link.fromSegments.length > best.fromSegments.length) {
44
+ best = link
45
+ }
46
+ }
47
+ return best
48
+ }
49
+
50
+ function isPathPrefix (prefixSegments, fullSegments) {
51
+ if (prefixSegments.length > fullSegments.length) return false
52
+ for (let i = 0; i < prefixSegments.length; i++) {
53
+ if (prefixSegments[i] !== String(fullSegments[i])) return false
54
+ }
55
+ return true
56
+ }
57
+
58
+ function toPathKey (segments) {
59
+ return segments.map(segment => String(segment)).join('.')
60
+ }
package/orm/Doc.js CHANGED
@@ -1,4 +1,4 @@
1
- import { isObservable, observable } from '@nx-js/observer-util'
1
+ import { isObservable, observable, raw } from '@nx-js/observer-util'
2
2
  import { set as _set, del as _del, getRaw as _getRaw } from './dataTree.js'
3
3
  import { SEGMENTS } from './Signal.js'
4
4
  import { getConnection, fetchOnly } from './connection.js'
@@ -90,9 +90,12 @@ class Doc {
90
90
  if (doc.data == null) return
91
91
  const idFields = getIdFieldsForSegments([this.collection, this.docId])
92
92
  if (isPlainObject(doc.data)) injectIdFields(doc.data, idFields, this.docId)
93
- if (isObservable(doc.data)) return
94
- _set([this.collection, this.docId], doc.data)
95
- doc.data = observable(doc.data)
93
+ const hasRaw = _getRaw([this.collection, this.docId]) != null
94
+ if (!hasRaw) {
95
+ const data = isObservable(doc.data) ? raw(doc.data) : doc.data
96
+ _set([this.collection, this.docId], data)
97
+ }
98
+ if (!isObservable(doc.data)) doc.data = observable(doc.data)
96
99
  }
97
100
 
98
101
  _removeData () {
package/orm/Query.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { raw } from '@nx-js/observer-util'
2
- import { get as _get, set as _set, del as _del } from './dataTree.js'
2
+ import { get as _get, set as _set, del as _del, getRaw } from './dataTree.js'
3
3
  import getSignal from './getSignal.js'
4
4
  import { getConnection, fetchOnly } from './connection.js'
5
5
  import { emitModelChange, isModelEventsEnabled } from './Compat/modelEvents.js'
6
+ import { isCompatEnv } from './compatEnv.js'
6
7
  import { docSubscriptions } from './Doc.js'
7
8
  import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
8
9
  import SubscriptionState from './SubscriptionState.js'
@@ -76,6 +77,7 @@ export class Query {
76
77
 
77
78
  _initData () {
78
79
  { // reference the fetched docs
80
+ maybeMaterializeQueryDocsToCollection(this.collectionName, this.shareQuery.results)
79
81
  const docs = this.shareQuery.results.map(doc => {
80
82
  const idFields = getIdFieldsForSegments([this.collectionName, doc.id])
81
83
  if (isPlainObject(doc.data)) injectIdFields(doc.data, idFields, doc.id)
@@ -98,6 +100,7 @@ export class Query {
98
100
  }
99
101
 
100
102
  this.shareQuery.on('insert', (shareDocs, index) => {
103
+ maybeMaterializeQueryDocsToCollection(this.collectionName, shareDocs)
101
104
  const newDocs = shareDocs.map(doc => {
102
105
  const idFields = getIdFieldsForSegments([this.collectionName, doc.id])
103
106
  if (isPlainObject(doc.data)) injectIdFields(doc.data, idFields, doc.id)
@@ -218,7 +221,13 @@ export class QuerySubscriptions {
218
221
  let count = this.subCount.get(hash) || 0
219
222
  count += 1
220
223
  this.subCount.set(hash, count)
221
- if (count > 1) return this.queries.get(hash)._subscribing
224
+ if (count > 1) {
225
+ const existingQuery = this.queries.get(hash)
226
+ if (existingQuery) return existingQuery._subscribing
227
+ // Recover from stale ref-count state when query was already cleaned up.
228
+ count = 1
229
+ this.subCount.set(hash, count)
230
+ }
222
231
 
223
232
  this.fr.register($query, { collectionName, params }, $query)
224
233
 
@@ -246,6 +255,7 @@ export class QuerySubscriptions {
246
255
  this.subCount.delete(hash)
247
256
  this.fr.unregister($query)
248
257
  const query = this.queries.get(hash)
258
+ if (!query) return
249
259
  await query.unsubscribe()
250
260
  if (query.subscribed) return // if we subscribed again while waiting for unsubscribe, we don't delete the doc
251
261
  this.queries.delete(hash)
@@ -264,6 +274,18 @@ export class QuerySubscriptions {
264
274
 
265
275
  export const querySubscriptions = new QuerySubscriptions()
266
276
 
277
+ function maybeMaterializeQueryDocsToCollection (collectionName, shareDocs) {
278
+ if (!isCompatEnv()) return
279
+ for (const doc of shareDocs) {
280
+ if (!doc?.id || doc.data == null) continue
281
+ const existing = getRaw([collectionName, doc.id])
282
+ if (existing != null) continue
283
+ const idFields = getIdFieldsForSegments([collectionName, doc.id])
284
+ if (isPlainObject(doc.data)) injectIdFields(doc.data, idFields, doc.id)
285
+ _set([collectionName, doc.id], raw(doc.data))
286
+ }
287
+ }
288
+
267
289
  export function hashQuery (collectionName, params) {
268
290
  // TODO: probably makes sense to use fast-stable-json-stringify for this because of the params
269
291
  return JSON.stringify({ query: [collectionName, params] })
package/orm/Signal.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Signal } from './SignalBase.js'
2
2
  import SignalCompat from './Compat/SignalCompat.js'
3
+ import { isCompatEnv } from './compatEnv.js'
3
4
 
4
5
  export {
5
6
  Signal,
@@ -18,8 +19,4 @@ export {
18
19
 
19
20
  export { SignalCompat }
20
21
 
21
- const compatEnv =
22
- globalThis?.teamplayCompatibilityMode ??
23
- (typeof process !== 'undefined' && process?.env?.TEAMPLAY_COMPAT === '1')
24
-
25
- export default compatEnv ? SignalCompat : Signal
22
+ export default isCompatEnv() ? SignalCompat : Signal
package/orm/SignalBase.js CHANGED
@@ -46,6 +46,8 @@ import { AGGREGATIONS, IS_AGGREGATION, getAggregationCollectionName, getAggregat
46
46
  import { ROOT_FUNCTION, getRoot } from './Root.js'
47
47
  import { publicOnly } from './connection.js'
48
48
  import { DEFAULT_ID_FIELDS, getIdFieldsForSegments, isIdFieldPath, normalizeIdFields } from './idFields.js'
49
+ import { isCompatEnv } from './compatEnv.js'
50
+ import { resolveRefSegmentsSafe, resolveRefSignalSafe } from './Compat/refFallback.js'
49
51
 
50
52
  export const SEGMENTS = Symbol('path segments targeting the particular node in the data tree')
51
53
  export const ARRAY_METHOD = Symbol('run array method on the signal')
@@ -501,8 +503,28 @@ export const extremelyLateBindings = {
501
503
  }
502
504
  const $parent = getSignal(getRoot(signal), segments)
503
505
  const rawParent = rawSignal($parent)
504
- if (!(key in rawParent)) throw Error(ERRORS.noSignalKey($parent, key))
505
- return Reflect.apply(rawParent[key], $parent, argumentsList)
506
+ if (key in rawParent) return Reflect.apply(rawParent[key], $parent, argumentsList)
507
+
508
+ if (isCompatEnv()) {
509
+ const $resolvedParent = resolveRefSignalSafe($parent)
510
+ if ($resolvedParent && $resolvedParent !== $parent) {
511
+ const rawResolvedParent = rawSignal($resolvedParent)
512
+ if (rawResolvedParent && key in rawResolvedParent) {
513
+ return Reflect.apply(rawResolvedParent[key], $resolvedParent, argumentsList)
514
+ }
515
+ } else {
516
+ const resolvedSegments = resolveRefSegmentsSafe(segments)
517
+ if (resolvedSegments) {
518
+ const $resolvedByPath = getSignal(getRoot(signal), resolvedSegments)
519
+ const rawResolvedByPath = rawSignal($resolvedByPath)
520
+ if (rawResolvedByPath && key in rawResolvedByPath) {
521
+ return Reflect.apply(rawResolvedByPath[key], $resolvedByPath, argumentsList)
522
+ }
523
+ }
524
+ }
525
+ }
526
+
527
+ throw Error(ERRORS.noSignalKey($parent, key))
506
528
  },
507
529
  get (signal, key, receiver) {
508
530
  if (typeof key === 'symbol') return Reflect.get(signal, key, receiver)
@@ -0,0 +1,4 @@
1
+ export function isCompatEnv () {
2
+ return globalThis?.teamplayCompatibilityMode ??
3
+ (typeof process !== 'undefined' && process?.env?.TEAMPLAY_COMPAT === '1')
4
+ }
package/orm/dataTree.js CHANGED
@@ -186,7 +186,12 @@ export async function setPublicDoc (segments, value, deleteValue = false) {
186
186
  })
187
187
  } else {
188
188
  // > modify existing doc. Partial doc modification
189
- const oldDoc = getRaw([collection, docId])
189
+ let oldDoc = getRaw([collection, docId])
190
+ if (oldDoc == null) {
191
+ const docData = getConnection().get(collection, docId).data
192
+ oldDoc = docData == null ? {} : raw(docData)
193
+ if (docData != null) set([collection, docId], oldDoc)
194
+ }
190
195
  const newDoc = JSON.parse(JSON.stringify(oldDoc))
191
196
  if (deleteValue) {
192
197
  del(segments.slice(2), newDoc)
package/orm/getSignal.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import Cache from './Cache.js'
2
- import Signal, { regularBindings, extremelyLateBindings, isPublicCollection, isPrivateCollection } from './Signal.js'
2
+ import Signal, { SEGMENTS, regularBindings, extremelyLateBindings, isPublicCollection, isPrivateCollection } from './Signal.js'
3
3
  import { findModel } from './addModel.js'
4
4
  import { LOCAL } from './$.js'
5
5
  import { ROOT, ROOT_ID, GLOBAL_ROOT_ID } from './Root.js'
6
6
  import { QUERIES } from './Query.js'
7
7
  import { AGGREGATIONS } from './Aggregation.js'
8
+ import { isCompatEnv } from './compatEnv.js'
9
+ import { getConnection } from './connection.js'
10
+ import { resolveRefSegmentsSafe } from './Compat/refFallback.js'
8
11
 
9
12
  const PROXIES_CACHE = new Cache()
10
13
  const PROXY_TO_SIGNAL = new WeakMap()
@@ -48,6 +51,9 @@ export default function getSignal ($root, segments = [], {
48
51
  // but without it calling the methods of root signal like $.get() doesn't work
49
52
  proxy[ROOT] = $root || getSignal(undefined, [], { rootId: GLOBAL_ROOT_ID })
50
53
  }
54
+ signal[ROOT] = proxy[ROOT]
55
+ } else {
56
+ signal[ROOT] = proxy
51
57
  }
52
58
  PROXY_TO_SIGNAL.set(proxy, signal)
53
59
  const dependencies = []
@@ -69,7 +75,22 @@ export default function getSignal ($root, segments = [], {
69
75
  }
70
76
 
71
77
  function getDefaultProxyHandlers ({ useExtremelyLateBindings } = {}) {
72
- return useExtremelyLateBindings ? extremelyLateBindings : regularBindings
78
+ const baseHandlers = useExtremelyLateBindings ? extremelyLateBindings : regularBindings
79
+ if (!isCompatEnv() || baseHandlers !== extremelyLateBindings) return baseHandlers
80
+ return {
81
+ ...baseHandlers,
82
+ get (signal, key, receiver) {
83
+ if (key === 'connection' && signal[SEGMENTS].length === 0) {
84
+ try {
85
+ return getConnection()
86
+ } catch {
87
+ return undefined
88
+ }
89
+ }
90
+ if (key === 'root') return Reflect.get(signal, key, receiver)
91
+ return baseHandlers.get(signal, key, receiver)
92
+ }
93
+ }
73
94
  }
74
95
 
75
96
  function hashSegments (segments, rootId) {
@@ -85,7 +106,15 @@ function hashSegments (segments, rootId) {
85
106
  }
86
107
 
87
108
  export function getSignalClass (segments) {
88
- return findModel(segments) ?? Signal
109
+ let Model = findModel(segments)
110
+ if (Model) return Model
111
+ if (!isCompatEnv()) return Signal
112
+ const dereferencedSegments = resolveRefSegmentsSafe(segments)
113
+ if (dereferencedSegments) {
114
+ Model = findModel(dereferencedSegments)
115
+ if (Model) return Model
116
+ }
117
+ return Signal
89
118
  }
90
119
 
91
120
  export function rawSignal (proxy) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamplay",
3
- "version": "0.4.0-alpha.2",
3
+ "version": "0.4.0-alpha.21",
4
4
  "description": "Full-stack signals ORM with multiplayer",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -34,12 +34,12 @@
34
34
  "dependencies": {
35
35
  "@nx-js/observer-util": "^4.1.3",
36
36
  "@startupjs/sharedb-mingo-memory": "^4.0.0-2",
37
- "@teamplay/backend": "^0.4.0-alpha.1",
38
- "@teamplay/cache": "^0.4.0-alpha.1",
39
- "@teamplay/channel": "^0.4.0-alpha.1",
40
- "@teamplay/debug": "^0.4.0-alpha.1",
41
- "@teamplay/schema": "^0.4.0-alpha.1",
42
- "@teamplay/utils": "^0.4.0-alpha.1",
37
+ "@teamplay/backend": "^0.4.0-alpha.9",
38
+ "@teamplay/cache": "^0.4.0-alpha.9",
39
+ "@teamplay/channel": "^0.4.0-alpha.9",
40
+ "@teamplay/debug": "^0.4.0-alpha.9",
41
+ "@teamplay/schema": "^0.4.0-alpha.9",
42
+ "@teamplay/utils": "^0.4.0-alpha.9",
43
43
  "diff-match-patch": "^1.0.5",
44
44
  "events": "^3.3.0",
45
45
  "json0-ot-diff": "^1.1.2",
@@ -81,5 +81,5 @@
81
81
  ]
82
82
  },
83
83
  "license": "MIT",
84
- "gitHead": "ad790a31ac628c9ea5ac851572533620ac0b7602"
84
+ "gitHead": "5b440dc6e2e114f2cfaac81d4757b710f153ff5d"
85
85
  }
@@ -0,0 +1,27 @@
1
+ let active = false
2
+ let promises = []
3
+
4
+ export function activate () {
5
+ active = true
6
+ }
7
+
8
+ export function add (promise) {
9
+ if (!promise || typeof promise.then !== 'function') return
10
+ promises.push(promise)
11
+ }
12
+
13
+ export function getPromiseAll () {
14
+ const hasPromises = promises.length > 0
15
+ const result = hasPromises ? Promise.all(promises) : null
16
+ reset()
17
+ return result
18
+ }
19
+
20
+ export function isActive () {
21
+ return active
22
+ }
23
+
24
+ export function reset () {
25
+ active = false
26
+ promises = []
27
+ }
@@ -1,6 +1,7 @@
1
1
  // trap render function (functional component) to block observer updates and activate cache
2
2
  // during synchronous rendering
3
3
  import executionContextTracker from './executionContextTracker.js'
4
+ import * as promiseBatcher from './promiseBatcher.js'
4
5
 
5
6
  export default function trapRender ({ render, cache, destroy, componentId }) {
6
7
  return (...args) => {
@@ -9,13 +10,14 @@ export default function trapRender ({ render, cache, destroy, componentId }) {
9
10
  let destroyed
10
11
  try {
11
12
  // destroyer.reset() // TODO: this one is for any destructuring logic which might be needed
12
- // promiseBatcher.reset() // TODO: this is to support useBatch* hooks
13
+ promiseBatcher.reset()
13
14
  const res = render(...args)
14
- // if (promiseBatcher.isActive()) {
15
- // throw Error('[react-sharedb] useBatch* hooks were used without a closing useBatch() call.')
16
- // }
15
+ if (isDevMode() && promiseBatcher.isActive()) {
16
+ throw Error('[teamplay] useBatch* hooks were used without a closing useBatch() call.')
17
+ }
17
18
  return res
18
19
  } catch (err) {
20
+ promiseBatcher.reset()
19
21
  // TODO: this might only be needed only if promise is thrown
20
22
  // (check if useUnmount in convertToObserver is called if a regular error is thrown)
21
23
  destroy('trapRender.js')
@@ -37,3 +39,8 @@ export default function trapRender ({ render, cache, destroy, componentId }) {
37
39
  }
38
40
  }
39
41
  }
42
+
43
+ function isDevMode () {
44
+ if (typeof process === 'undefined' || !process?.env) return true
45
+ return process.env.NODE_ENV !== 'production'
46
+ }
package/react/useSub.js CHANGED
@@ -2,6 +2,7 @@ import { useRef, useDeferredValue } from 'react'
2
2
  import sub from '../orm/sub.js'
3
3
  import { useScheduleUpdate, useCache, useDefer } from './helpers.js'
4
4
  import executionContextTracker from './executionContextTracker.js'
5
+ import * as promiseBatcher from './promiseBatcher.js'
5
6
 
6
7
  let TEST_THROTTLING = false
7
8
 
@@ -24,10 +25,11 @@ export default function useSub (signal, params, options) {
24
25
  }
25
26
 
26
27
  // version of sub() which works as a react hook and throws promise for Suspense
27
- export function useSubDeferred (signal, params, { async = false, defer } = {}) {
28
+ export function useSubDeferred (signal, params, { async = false, defer, batch = false } = {}) {
28
29
  const $signalRef = useRef() // eslint-disable-line react-hooks/rules-of-hooks
29
30
  const scheduleUpdate = useScheduleUpdate()
30
31
  const observerDefer = useDefer()
32
+ if (batch) promiseBatcher.activate()
31
33
  defer ??= observerDefer ?? DEFAULT_DEFER
32
34
  if (defer) {
33
35
  signal = useDeferredValue(signal) // eslint-disable-line react-hooks/rules-of-hooks
@@ -38,6 +40,11 @@ export function useSubDeferred (signal, params, { async = false, defer } = {}) {
38
40
  // 1. if it's a promise, throw it so that Suspense can catch it and wait for subscription to finish
39
41
  if (promiseOrSignal.then) {
40
42
  const promise = maybeThrottle(promiseOrSignal)
43
+ if (batch) {
44
+ promiseBatcher.add(promise)
45
+ if (async) scheduleUpdate(promise)
46
+ return $signalRef.current
47
+ }
41
48
  if (async) {
42
49
  scheduleUpdate(promise)
43
50
  return
@@ -53,15 +60,22 @@ export function useSubDeferred (signal, params, { async = false, defer } = {}) {
53
60
 
54
61
  // classic version which initially throws promise for Suspense
55
62
  // but if we get a promise second time, we return the last signal and wait for promise to resolve
56
- export function useSubClassic (signal, params, { async = false } = {}) {
63
+ export function useSubClassic (signal, params, { async = false, batch = false } = {}) {
57
64
  const id = executionContextTracker.newHookId()
58
65
  const cache = useCache()
59
66
  const activePromiseRef = useRef()
60
67
  const scheduleUpdate = useScheduleUpdate()
68
+ if (batch) promiseBatcher.activate()
61
69
  const promiseOrSignal = params != null ? sub(signal, params) : sub(signal)
62
70
  // 1. if it's a promise, throw it so that Suspense can catch it and wait for subscription to finish
63
71
  if (promiseOrSignal.then) {
64
72
  const promise = maybeThrottle(promiseOrSignal)
73
+ if (batch) {
74
+ promiseBatcher.add(promise)
75
+ if (async) scheduleUpdate(promise)
76
+ if (cache.has(id)) return cache.get(id)
77
+ return
78
+ }
65
79
  // first time we just throw the promise to be caught by Suspense
66
80
  if (!cache.has(id)) {
67
81
  // if we are in async mode, we just return nothing and let the user
@@ -52,7 +52,7 @@ export default function wrapIntoSuspense ({
52
52
  // to avoid updating during the subscribe/render phase
53
53
  if (adm.hasPendingUpdate) {
54
54
  adm.hasPendingUpdate = false
55
- queueMicrotask(() => adm.onStoreChange())
55
+ queueMicrotask(() => adm.onStoreChange?.())
56
56
  }
57
57
  return () => destroyAdm(adm)
58
58
  },
@@ -1,5 +1,9 @@
1
1
  import isPlainObject from 'lodash/isPlainObject.js'
2
2
 
3
+ function isReactLike (value) {
4
+ return !!(value && typeof value === 'object' && typeof value.$$typeof === 'symbol')
5
+ }
6
+
3
7
  export default function setDiffDeep (existing, updated) {
4
8
  // Handle primitive types, null, and type mismatches
5
9
  if (existing === null || updated === null ||
@@ -21,6 +25,9 @@ export default function setDiffDeep (existing, updated) {
21
25
  return existing
22
26
  }
23
27
 
28
+ // React elements are plain objects but must be treated as non-plain
29
+ if (isReactLike(updated)) return updated
30
+
24
31
  // Handle non-plain objects - just return them as-is to fully overwrite
25
32
  // and don't try to update an old object in-place
26
33
  if (!isPlainObject(updated)) return updated
package/orm/Compat/REF.md DELETED
@@ -1,315 +0,0 @@
1
- # SignalCompat `ref` / `removeRef` — Compatibility Draft
2
-
3
- This document captures a **draft** implementation of StartupJS/Racer-style `ref` behavior for Teamplay’s `SignalCompat`.
4
-
5
- It is **not active in code** right now. The goal is to discuss and decide whether we want to bring it back, and in what form.
6
-
7
- ---
8
-
9
- ## 1) Why we need this
10
-
11
- In LMS there are a few **real usages** of model refs (not React DOM refs):
12
-
13
- - `components/Media/index.js`
14
- ```js
15
- if ($fullscreen) $localFullscreen.ref($fullscreen)
16
- ```
17
- - `main/components/FilterV2/index.js`
18
- ```js
19
- if (!isMultiSelect) $localValue.ref($value)
20
- ```
21
- - `main/Layout/Tutoring/index.js` and `v5/apps/main/Layout/Tutoring/index.js`
22
- ```js
23
- $session.ref('tutoringSession', $tutoringSession)
24
- $session.removeRef('tutoringSession')
25
- ```
26
-
27
- These use the **Racer model ref**, which effectively makes one path behave like another path (alias). Teamplay doesn’t have this concept, so we explored a minimal compat layer.
28
-
29
- ---
30
-
31
- ## 2) Target API (minimal subset)
32
-
33
- We only target what LMS actually uses:
34
-
35
- ### `ref(target)`
36
-
37
- ```js
38
- $local.ref($.users.user1)
39
- ```
40
-
41
- This means `$local` mirrors `$users.user1` and mutating `$local` mutates `$users.user1`.
42
-
43
- ### `ref(subpath, target)`
44
-
45
- ```js
46
- $session.ref('tutoringSession', $tutoringSession)
47
- ```
48
-
49
- This means `$session.tutoringSession` acts as an alias to `$tutoringSession`.
50
-
51
- ### `removeRef(path?)`
52
-
53
- ```js
54
- $local.removeRef()
55
- $session.removeRef('tutoringSession')
56
- ```
57
-
58
- Stops syncing.
59
-
60
- ---
61
-
62
- ## 3) Semantics vs Racer
63
-
64
- Racer refs are deep and complicated (they respond to all model events, including array insert/remove/move, etc).
65
-
66
- This draft **only covers**:
67
- - Signal-level aliasing (one signal proxies another).
68
- - No `refList`, `refExtra`, `refMap`.
69
- - No automatic path-patching for list inserts/moves.
70
-
71
- It should be enough for current LMS usages.
72
-
73
- ---
74
-
75
- ## 4) Draft Implementation Strategy
76
-
77
- ### 4.1 Keep a ref store on root
78
-
79
- We store refs on root signal:
80
-
81
- ```js
82
- const REFS = Symbol('compat refs')
83
- $root[REFS] = new Map()
84
- ```
85
-
86
- Each entry is keyed by `fromPath` and stores `{ stop }` cleanup.
87
-
88
- ### 4.2 One-way reactive sync (target → alias)
89
-
90
- We use `@nx-js/observer-util` `observe()` to track target changes and push them into alias:
91
-
92
- ```js
93
- const toReaction = observe(() => {
94
- const value = $to.get()
95
- trackDeep(value)
96
- setDiffDeepBypassRef($from, deepCopy(value))
97
- }, { lazy: true })
98
-
99
- toReaction()
100
- ```
101
-
102
- Why deep copy?
103
- - Without it, `setDiffDeep` can re-use same object references and skip updates.
104
- - Deep copy ensures the diffing path detects change.
105
-
106
- ### 4.3 Forward all mutations from alias → target
107
-
108
- To avoid two reactions and feedback loops, we forward all mutator calls:
109
-
110
- - `set`, `setNull`, `setDiffDeep`, `setEach`
111
- - `del`
112
- - `increment`
113
- - `push`, `unshift`, `insert`, `pop`, `shift`, `remove`, `move`
114
- - `stringInsert`, `stringRemove`
115
- - `assign`
116
-
117
- Forwarding uses a hidden `REF_TARGET` symbol on the alias signal.
118
-
119
- ### 4.4 Mutator forward mechanism
120
-
121
- On each mutator:
122
-
123
- ```js
124
- const forwarded = forwardRef(this, 'set', arguments)
125
- if (forwarded) return forwarded
126
- ```
127
-
128
- `forwardRef()` resolves to a target signal if present and applies the same method there.
129
-
130
- ---
131
-
132
- ## 5) Draft Code (for later restoration)
133
-
134
- Below is the exact code we removed from `SignalCompat.js`. It can be re-applied as-is.
135
-
136
- ### 5.1 Imports (add back)
137
-
138
- ```js
139
- import { raw, observe, unobserve } from '@nx-js/observer-util'
140
- ```
141
-
142
- ### 5.2 Symbols and helpers (add near other helpers)
143
-
144
- ```js
145
- const REFS = Symbol('compat refs')
146
- const REF_TARGET = Symbol('compat ref target')
147
-
148
- function getRefStore ($signal) {
149
- const $root = getRoot($signal) || $signal
150
- $root[REFS] ??= new Map()
151
- return $root[REFS]
152
- }
153
-
154
- function createRefLink ($from, $to) {
155
- const toReaction = observe(() => {
156
- const value = $to.get()
157
- trackDeep(value)
158
- setDiffDeepBypassRef($from, deepCopy(value))
159
- }, { lazy: true })
160
-
161
- // Prime sync and start tracking.
162
- toReaction()
163
- return () => {
164
- unobserve(toReaction)
165
- }
166
- }
167
-
168
- function trackDeep (value, seen = new Set()) {
169
- if (!value || typeof value !== 'object') return
170
- if (seen.has(value)) return
171
- seen.add(value)
172
- if (Array.isArray(value)) {
173
- for (const item of value) trackDeep(item, seen)
174
- } else {
175
- for (const key in value) {
176
- if (Object.prototype.hasOwnProperty.call(value, key)) {
177
- trackDeep(value[key], seen)
178
- }
179
- }
180
- }
181
- }
182
-
183
- function resolveRefSignal ($signal) {
184
- let current = $signal
185
- const seen = new Set()
186
- while (current && current[REF_TARGET]) {
187
- if (seen.has(current)) break
188
- seen.add(current)
189
- current = current[REF_TARGET]
190
- }
191
- return current
192
- }
193
-
194
- function forwardRef ($signal, methodName, args) {
195
- const $target = resolveRefSignal($signal)
196
- if ($target === $signal) return null
197
- return SignalCompat.prototype[methodName].apply($target, args)
198
- }
199
-
200
- function setDiffDeepBypassRef ($signal, value) {
201
- return Signal.prototype.set.call($signal, value)
202
- }
203
- ```
204
-
205
- ### 5.3 `ref()` / `removeRef()` methods (add to `SignalCompat`)
206
-
207
- ```js
208
- ref (path, target, options) {
209
- if (arguments.length > 3) throw Error('Signal.ref() expects one to three arguments')
210
- let $from = this
211
- let $to
212
- if (arguments.length === 1) {
213
- $to = resolveRefTarget(this, path, 'Signal.ref()')
214
- } else if (arguments.length === 2) {
215
- if (isSignalLike(target) || typeof target === 'string') {
216
- const segments = parseAtSubpath(path, 1, 'Signal.ref()')
217
- $from = resolveSignal(this, segments)
218
- $to = resolveRefTarget(this, target, 'Signal.ref()')
219
- } else {
220
- $to = resolveRefTarget(this, path, 'Signal.ref()')
221
- options = target
222
- }
223
- } else {
224
- const segments = parseAtSubpath(path, 1, 'Signal.ref()')
225
- $from = resolveSignal(this, segments)
226
- $to = resolveRefTarget(this, target, 'Signal.ref()')
227
- }
228
- if (!$to) throw Error('Signal.ref() expects a target path or signal')
229
- if ($from === $to) return $from
230
- const store = getRefStore($from)
231
- const fromPath = $from.path()
232
- const existing = store.get(fromPath)
233
- if (existing) existing.stop()
234
- const stop = createRefLink($from, $to, options)
235
- store.set(fromPath, { stop })
236
- $from[REF_TARGET] = $to
237
- return $from
238
- }
239
-
240
- removeRef (path) {
241
- if (arguments.length > 1) throw Error('Signal.removeRef() expects a single argument')
242
- let $from = this
243
- if (arguments.length === 1) {
244
- const segments = parseAtSubpath(path, 1, 'Signal.removeRef()')
245
- $from = resolveSignal(this, segments)
246
- }
247
- const store = getRefStore($from)
248
- const fromPath = $from.path()
249
- const existing = store.get(fromPath)
250
- if (existing) {
251
- existing.stop()
252
- store.delete(fromPath)
253
- }
254
- if ($from[REF_TARGET]) delete $from[REF_TARGET]
255
- }
256
- ```
257
-
258
- ### 5.4 Forwarding mutations (add to each mutator)
259
-
260
- Example for `set()`:
261
-
262
- ```js
263
- async set (path, value) {
264
- const forwarded = forwardRef(this, 'set', arguments)
265
- if (forwarded) return forwarded
266
- // ...existing body
267
- }
268
- ```
269
-
270
- Same pattern for:
271
- - `setNull`, `setDiffDeep`, `setEach`
272
- - `del`
273
- - `increment`
274
- - `push`, `unshift`, `insert`, `pop`, `shift`, `remove`, `move`
275
- - `stringInsert`, `stringRemove`
276
- - `assign`
277
-
278
- ### 5.5 Supporting helpers (only needed with ref)
279
-
280
- ```js
281
- function isSignalLike (value) {
282
- return value && typeof value.path === 'function' && typeof value.get === 'function'
283
- }
284
-
285
- function resolveRefTarget ($signal, target, methodName) {
286
- if (isSignalLike(target)) return target
287
- if (typeof target === 'string') {
288
- const segments = parseAtSubpath(target, 1, methodName)
289
- const $root = getRoot($signal) || $signal
290
- return resolveSignal($root, segments)
291
- }
292
- return undefined
293
- }
294
- ```
295
-
296
- ---
297
-
298
- ## 6) Draft tests (removed)
299
-
300
- We also had tests in `packages/teamplay/test/signalCompat.js`. They can be restored if needed:
301
-
302
- - `syncs values both ways for direct signals`
303
- - `supports subpath refs from root`
304
- - `removeRef stops syncing`
305
-
306
- ---
307
-
308
- ## 7) Risks and limitations
309
-
310
- - This is **not a full racer ref** implementation.
311
- - No support for `refList`, `refExtra`, `refMap`.
312
- - No array index patching when list changes.
313
- - Might not handle exotic cases with cyclic refs.
314
-
315
- That said, it’s deliberately scoped to known LMS usage patterns and should be “good enough” for those.