teamplay 0.4.0-alpha.98 → 0.4.0

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.
@@ -1,4 +1,5 @@
1
1
  import { raw, observe, unobserve } from '@nx-js/observer-util'
2
+ import arrayDiff from 'arraydiff'
2
3
  import {
3
4
  Signal,
4
5
  GETTERS,
@@ -974,14 +975,14 @@ async function diffDeepCompat ($signal, before, after) {
974
975
  if (before === after) return
975
976
 
976
977
  if (Array.isArray(before) && Array.isArray(after)) {
977
- if (deepEqualCompat(before, after)) return
978
- const changedIndexes = getChangedArrayIndexes(before, after)
979
- if (before.length === after.length && changedIndexes.length === 1) {
980
- const index = changedIndexes[0]
978
+ const diff = arrayDiff(before, after, deepEqualCompat)
979
+ if (!diff.length) return
980
+ const index = getSingleArrayReplacementIndex(diff)
981
+ if (index != null) {
981
982
  await diffDeepCompat(getChildSignal($signal, index), before[index], after[index])
982
983
  return
983
984
  }
984
- await SignalCompat.prototype.set.call($signal, after)
985
+ await applyArrayDiffCompat($signal, diff)
985
986
  return
986
987
  }
987
988
 
@@ -1003,14 +1004,14 @@ function diffDeepCompatSync ($signal, before, after) {
1003
1004
  if (before === after) return
1004
1005
 
1005
1006
  if (Array.isArray(before) && Array.isArray(after)) {
1006
- if (deepEqualCompat(before, after)) return
1007
- const changedIndexes = getChangedArrayIndexes(before, after)
1008
- if (before.length === after.length && changedIndexes.length === 1) {
1009
- const index = changedIndexes[0]
1007
+ const diff = arrayDiff(before, after, deepEqualCompat)
1008
+ if (!diff.length) return
1009
+ const index = getSingleArrayReplacementIndex(diff)
1010
+ if (index != null) {
1010
1011
  diffDeepCompatSync(getChildSignal($signal, index), before[index], after[index])
1011
1012
  return
1012
1013
  }
1013
- setReplacePrivateCompatSync($signal, after)
1014
+ applyArrayDiffCompatSync($signal, diff)
1014
1015
  return
1015
1016
  }
1016
1017
 
@@ -1035,14 +1036,54 @@ function isDiffableObject (before, after) {
1035
1036
  return true
1036
1037
  }
1037
1038
 
1038
- function getChangedArrayIndexes (before, after) {
1039
- if (!Array.isArray(before) || !Array.isArray(after)) return []
1040
- const maxLength = Math.max(before.length, after.length)
1041
- const changed = []
1042
- for (let i = 0; i < maxLength; i++) {
1043
- if (!deepEqualCompat(before[i], after[i])) changed.push(i)
1039
+ function getSingleArrayReplacementIndex (diff) {
1040
+ if (!Array.isArray(diff) || diff.length !== 2) return null
1041
+ const first = diff[0]
1042
+ const second = diff[1]
1043
+ if (
1044
+ first instanceof arrayDiff.RemoveDiff &&
1045
+ second instanceof arrayDiff.InsertDiff &&
1046
+ first.index === second.index &&
1047
+ first.howMany === 1 &&
1048
+ second.values.length === 1
1049
+ ) {
1050
+ return first.index
1051
+ }
1052
+ return null
1053
+ }
1054
+
1055
+ async function applyArrayDiffCompat ($signal, diff) {
1056
+ for (const item of diff) {
1057
+ if (item instanceof arrayDiff.InsertDiff) {
1058
+ await arrayInsertOnSignal($signal, item.index, item.values)
1059
+ continue
1060
+ }
1061
+ if (item instanceof arrayDiff.RemoveDiff) {
1062
+ await arrayRemoveOnSignal($signal, item.index, item.howMany)
1063
+ continue
1064
+ }
1065
+ if (item instanceof arrayDiff.MoveDiff) {
1066
+ await arrayMoveOnSignal($signal, item.from, item.to, item.howMany)
1067
+ }
1068
+ }
1069
+ }
1070
+
1071
+ function applyArrayDiffCompatSync ($signal, diff) {
1072
+ const segments = ensureArrayTarget($signal)
1073
+ const rootId = getOwningRootId($signal)
1074
+ for (const item of diff) {
1075
+ if (item instanceof arrayDiff.InsertDiff) {
1076
+ arrayInsertPrivateData(rootId, segments, item.index, item.values)
1077
+ continue
1078
+ }
1079
+ if (item instanceof arrayDiff.RemoveDiff) {
1080
+ arrayRemovePrivateData(rootId, segments, item.index, item.howMany)
1081
+ continue
1082
+ }
1083
+ if (item instanceof arrayDiff.MoveDiff) {
1084
+ arrayMovePrivateData(rootId, segments, item.from, item.to, item.howMany)
1085
+ }
1044
1086
  }
1045
- return changed
1046
1087
  }
1047
1088
 
1048
1089
  function getChildSignal ($parent, key) {
@@ -115,48 +115,54 @@ export function useAsyncDoc (collection, id, options) {
115
115
  return [$doc.get(), $doc]
116
116
  }
117
117
 
118
+ function useSubscribedQuery (collection, query, options, hookName, subscribe) {
119
+ const normalizedQuery = normalizeQuery(query, hookName)
120
+ const $collection = getCollectionSignal(collection, query, hookName)
121
+ const $query = subscribe($collection, normalizedQuery, options)
122
+ return {
123
+ normalizedQuery,
124
+ $collection,
125
+ $query: getExtraQuerySignal($query, normalizedQuery)
126
+ }
127
+ }
128
+
129
+ function getExtraQuerySignal ($query, normalizedQuery) {
130
+ if (!$query) return $query
131
+ return isExtraQuery(normalizedQuery) ? $query.extra : $query
132
+ }
133
+
118
134
  export function useQuery$ (collection, query, options) {
119
- const normalizedQuery = normalizeQuery(query, 'useQuery')
120
- const $collection = getCollectionSignal(collection, query, 'useQuery')
121
135
  const normalizedOptions = normalizeSyncSubOptions(options)
122
- const $query = useSub($collection, normalizedQuery, normalizedOptions)
123
- return isExtraQuery(normalizedQuery) ? $query.extra : $query
136
+ const { $query } = useSubscribedQuery(collection, query, normalizedOptions, 'useQuery', useSub)
137
+ return $query
124
138
  }
125
139
 
126
140
  export function useQuery (collection, query, options) {
127
- const $collection = getCollectionSignal(collection, query, 'useQuery')
128
141
  const normalizedOptions = normalizeSyncSubOptions(options)
129
- const $query = useSub($collection, normalizeQuery(query, 'useQuery'), normalizedOptions)
142
+ const { $collection, $query } = useSubscribedQuery(collection, query, normalizedOptions, 'useQuery', useSub)
130
143
  return [$query.get(), $collection]
131
144
  }
132
145
 
133
146
  export function useAsyncQuery$ (collection, query, options) {
134
- const normalizedQuery = normalizeQuery(query, 'useAsyncQuery')
135
- const $collection = getCollectionSignal(collection, query, 'useAsyncQuery')
136
- const $query = useAsyncSub($collection, normalizedQuery, options)
137
- if (!$query) return $query
138
- return isExtraQuery(normalizedQuery) ? $query.extra : $query
147
+ const { $query } = useSubscribedQuery(collection, query, options, 'useAsyncQuery', useAsyncSub)
148
+ return $query
139
149
  }
140
150
 
141
151
  export function useAsyncQuery (collection, query, options) {
142
- const $collection = getCollectionSignal(collection, query, 'useAsyncQuery')
143
- const $query = useAsyncSub($collection, normalizeQuery(query, 'useAsyncQuery'), options)
152
+ const { $collection, $query } = useSubscribedQuery(collection, query, options, 'useAsyncQuery', useAsyncSub)
144
153
  if (!$query) return [undefined, $collection]
145
154
  return [$query.get(), $collection]
146
155
  }
147
156
 
148
157
  export function useBatchQuery$ (collection, query, _options) {
149
- const normalizedQuery = normalizeQuery(query, 'useBatchQuery')
150
- const $collection = getCollectionSignal(collection, query, 'useBatchQuery')
151
- const options = _options ? { ..._options, ...BATCH_SUB_OPTIONS } : BATCH_SUB_OPTIONS
152
- const $query = useSub($collection, normalizedQuery, options)
153
- if (!$query) return $query
154
- return isExtraQuery(normalizedQuery) ? $query.extra : $query
158
+ const options = normalizeBatchSubOptions(_options)
159
+ const { $query } = useSubscribedQuery(collection, query, options, 'useBatchQuery', useSub)
160
+ return $query
155
161
  }
156
162
 
157
- export function useBatchQuery (collection, query, options) {
158
- const $collection = getCollectionSignal(collection, query, 'useBatchQuery')
159
- const $query = useBatchQuery$(collection, query, options)
163
+ export function useBatchQuery (collection, query, _options) {
164
+ const options = normalizeBatchSubOptions(_options)
165
+ const { $collection, $query } = useSubscribedQuery(collection, query, options, 'useBatchQuery', useSub)
160
166
  if (!$query) return [undefined, $collection]
161
167
  return [$query.get(), $collection]
162
168
  }
@@ -359,6 +365,10 @@ function normalizeSyncSubOptions (options) {
359
365
  }
360
366
  }
361
367
 
368
+ function normalizeBatchSubOptions (options) {
369
+ return options ? { ...options, ...BATCH_SUB_OPTIONS } : BATCH_SUB_OPTIONS
370
+ }
371
+
362
372
  export const __COMPAT_BATCH_READY__ = {
363
373
  isQueryReady
364
374
  }
@@ -24,6 +24,7 @@ export function compatStartOnRoot ($root, targetPath, ...depsAndGetter) {
24
24
  const existing = store.get(targetKey)
25
25
  if (existing) existing.stop()
26
26
 
27
+ let lastSourceSnapshot = UNSET
27
28
  const reaction = observe(() => {
28
29
  const resolvedDeps = []
29
30
  for (const dep of deps) {
@@ -38,13 +39,22 @@ export function compatStartOnRoot ($root, targetPath, ...depsAndGetter) {
38
39
  if (isThenable(err)) return
39
40
  throw err
40
41
  }
41
- const detachedValue = detachStartValue(nextValue)
42
+ const sourceSnapshot = detachStartValue(nextValue)
43
+ if (lastSourceSnapshot !== UNSET && deepEqualStartValue(lastSourceSnapshot, sourceSnapshot)) {
44
+ return
45
+ }
46
+ lastSourceSnapshot = sourceSnapshot
47
+ const detachedValue = detachStartValue(sourceSnapshot)
42
48
  // Keep the detached snapshot to avoid aliasing source and target.
43
49
  // Old racer start() writes through diffDeep by default. In compat mode we must preserve
44
50
  // that behavior, but also avoid reading the target reactively inside start(), otherwise
45
51
  // start() subscribes to its own output and local child edits get immediately overwritten.
46
52
  const maybePromise = $target.setDiffDeep(detachedValue)
47
- if (maybePromise?.catch) maybePromise.catch(ignorePromiseRejection)
53
+ if (maybePromise?.then) {
54
+ maybePromise
55
+ .then(() => {})
56
+ .catch(ignorePromiseRejection)
57
+ }
48
58
  }, { scheduler: scheduleReaction })
49
59
  store.set(targetKey, { stop: () => unobserve(reaction) })
50
60
  return $target
@@ -134,6 +144,8 @@ function isThenable (value) {
134
144
  return !!value && typeof value.then === 'function'
135
145
  }
136
146
 
147
+ const UNSET = Symbol('compat start unset')
148
+
137
149
  function detachStartValue (value) {
138
150
  const rawValue = raw(value)
139
151
  if (!rawValue || typeof rawValue !== 'object') return rawValue
@@ -166,3 +178,30 @@ function racerDeepCopy (value) {
166
178
  }
167
179
  return value
168
180
  }
181
+
182
+ function deepEqualStartValue (left, right) {
183
+ if (left === right) return true
184
+ if (Number.isNaN(left) && Number.isNaN(right)) return true
185
+ if (left instanceof Date || right instanceof Date) {
186
+ return left instanceof Date && right instanceof Date && left.getTime() === right.getTime()
187
+ }
188
+ if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false
189
+
190
+ if (Array.isArray(left) || Array.isArray(right)) {
191
+ if (!Array.isArray(left) || !Array.isArray(right)) return false
192
+ if (left.length !== right.length) return false
193
+ for (let i = 0; i < left.length; i++) {
194
+ if (!deepEqualStartValue(left[i], right[i])) return false
195
+ }
196
+ return true
197
+ }
198
+
199
+ const leftKeys = Object.keys(left)
200
+ const rightKeys = Object.keys(right)
201
+ if (leftKeys.length !== rightKeys.length) return false
202
+ for (const key of leftKeys) {
203
+ if (!Object.prototype.hasOwnProperty.call(right, key)) return false
204
+ if (!deepEqualStartValue(left[key], right[key])) return false
205
+ }
206
+ return true
207
+ }
package/orm/Doc.js CHANGED
@@ -35,6 +35,34 @@ function getOwningRootId ($doc) {
35
35
  return rootId
36
36
  }
37
37
 
38
+ function deepEqualDocData (left, right) {
39
+ if (left === right) return true
40
+ if (left == null || right == null) return left === right
41
+
42
+ const leftIsArray = Array.isArray(left)
43
+ if (leftIsArray || Array.isArray(right)) {
44
+ if (!leftIsArray || !Array.isArray(right)) return false
45
+ if (left.length !== right.length) return false
46
+ for (let i = 0; i < left.length; i++) {
47
+ if (!deepEqualDocData(left[i], right[i])) return false
48
+ }
49
+ return true
50
+ }
51
+
52
+ if (typeof left !== 'object' || typeof right !== 'object') return false
53
+
54
+ const leftKeys = Object.keys(left)
55
+ const rightKeys = Object.keys(right)
56
+ if (leftKeys.length !== rightKeys.length) return false
57
+
58
+ for (const key of leftKeys) {
59
+ if (!Object.prototype.hasOwnProperty.call(right, key)) return false
60
+ if (!deepEqualDocData(left[key], right[key])) return false
61
+ }
62
+
63
+ return true
64
+ }
65
+
38
66
  class Doc {
39
67
  initialized
40
68
 
@@ -162,6 +190,12 @@ class Doc {
162
190
  if (isPlainObject(doc.data)) injectIdFields(doc.data, idFields, this.docId)
163
191
  const path = [this.collection, this.docId]
164
192
  const data = isObservable(doc.data) ? raw(doc.data) : doc.data
193
+ const current = _getRaw(path)
194
+ if (deepEqualDocData(current, data)) {
195
+ if (current != null && current !== raw(doc.data)) doc.data = current
196
+ if (!isObservable(doc.data)) doc.data = observable(doc.data)
197
+ return
198
+ }
165
199
  _set(path, data)
166
200
  const synced = _getRaw(path)
167
201
  if (synced != null && synced !== raw(doc.data)) doc.data = synced
package/orm/dataTree.js CHANGED
@@ -73,7 +73,7 @@ export function set (segments, value, tree = dataTree, eventContext) {
73
73
  const shouldEmit = shouldEmitModelEvents(tree, eventContext)
74
74
  const prevValue = shouldEmit ? get(segments, getTreeRaw(tree)) : undefined
75
75
  let dataNode = writableTree
76
- let dataNodeRaw = raw(writableTree)
76
+ let dataNodeRaw = getTreeRaw(writableTree)
77
77
  for (let i = 0; i < segments.length - 1; i++) {
78
78
  const segment = segments[i]
79
79
  const nextSegment = segments[i + 1]
@@ -84,34 +84,15 @@ export function set (segments, value, tree = dataTree, eventContext) {
84
84
  else dataNode[segment] = {}
85
85
  }
86
86
  dataNode = dataNode[segment]
87
- dataNodeRaw = raw(dataNode)
87
+ dataNodeRaw = getTreeRaw(dataNode)
88
88
  }
89
89
  const key = segments[segments.length - 1]
90
- // handle adding out of bounds empty element to the array
91
- if (value == null && Array.isArray(dataNodeRaw) && key >= dataNodeRaw.length) {
92
- // inject new undefined elements to the end of the array
93
- dataNode.splice(dataNodeRaw.length, key - dataNodeRaw.length + 1,
94
- ...Array(key - dataNodeRaw.length + 1).fill(undefined))
95
- return
96
- }
97
- // handle when the value didn't change
98
- if (value === dataNodeRaw[key]) return
99
- // handle setting undefined value
100
- if (value == null) {
101
- if (Array.isArray(dataNodeRaw)) {
102
- // if parent is an array -- we set array element to undefined
103
- // IMPORTANT: JSON serialization will replace `undefined` with `null`
104
- // so if the data will go to the server, it will be serialized as `null`.
105
- // And when it comes back from the server it will be still `null`.
106
- // This can lead to confusion since when you set `undefined` the value
107
- // might end up becoming `null` for seemingly no reason (like in this case).
108
- dataNode[key] = undefined
109
- } else {
110
- // if parent is an object -- we completely delete the property.
111
- // Deleting the property is better for the JSON serialization
112
- // since JSON does not have `undefined` values and replaces them with `null`.
113
- delete dataNode[key]
114
- }
90
+ const keyExists = hasOwnDataKey(dataNodeRaw, key)
91
+ // Preserve racer local semantics: assigning undefined creates/keeps the slot/key
92
+ // instead of deleting it, and sparse array writes keep holes intact.
93
+ if (keyExists && value === dataNodeRaw[key]) return
94
+ if (value == null || typeof value !== 'object') {
95
+ dataNode[key] = value
115
96
  emitModelEvent(segments, prevValue, { op: 'set' }, tree, eventContext)
116
97
  return
117
98
  }
@@ -124,6 +105,12 @@ export function set (segments, value, tree = dataTree, eventContext) {
124
105
  emitModelEvent(segments, prevValue, { op: 'set' }, tree, eventContext)
125
106
  }
126
107
 
108
+ function hasOwnDataKey (node, key) {
109
+ if (node == null) return false
110
+ if (Array.isArray(node)) return key in node
111
+ return Object.prototype.hasOwnProperty.call(node, key)
112
+ }
113
+
127
114
  // Like set(), but always assigns the value without equality checks or delete-on-null behavior
128
115
  export function setReplace (segments, value, tree = dataTree, eventContext) {
129
116
  const writableTree = getWritableTree(tree)
@@ -253,7 +240,7 @@ export async function setPublicDoc (segments, value, deleteValue = false) {
253
240
  if (deleteValue) {
254
241
  del(segments.slice(2), newDoc)
255
242
  } else {
256
- set(segments.slice(2), value, newDoc)
243
+ set(segments.slice(2), normalizeUndefined(value), newDoc)
257
244
  }
258
245
  const diff = jsonDiff(oldDoc, newDoc, diffMatchPatch)
259
246
  return new Promise((resolve, reject) => {
@@ -349,7 +336,13 @@ export async function setPublicDocReplace (segments, value) {
349
336
  return new Promise((resolve, reject) => {
350
337
  doc.submitOp(op, err => {
351
338
  if (err) return reject(err)
352
- ensureLocalDocSyncedWithShareDoc({ collection, docId, doc, idFields })
339
+ syncLocalDocAfterPublicWrite({
340
+ collection,
341
+ docId,
342
+ doc,
343
+ idFields,
344
+ relativePath
345
+ })
353
346
  resolve()
354
347
  })
355
348
  })
@@ -447,6 +440,33 @@ function ensureLocalDocSyncedWithShareDoc ({
447
440
  setReplace([collection, docId], shared)
448
441
  }
449
442
 
443
+ function syncLocalDocAfterPublicWrite ({
444
+ collection,
445
+ docId,
446
+ doc,
447
+ idFields,
448
+ relativePath = []
449
+ }) {
450
+ if (!Array.isArray(relativePath) || relativePath.length === 0) {
451
+ ensureLocalDocSyncedWithShareDoc({ collection, docId, doc, idFields })
452
+ return
453
+ }
454
+ if (isMissingShareDoc(doc)) return
455
+ if (doc?.data == null) return
456
+ const shared = raw(doc.data)
457
+ const nextValue = get(relativePath, shared)
458
+ setReplace([collection, docId, ...relativePath], clonePublicLocalSyncValue(nextValue))
459
+ }
460
+
461
+ function clonePublicLocalSyncValue (value) {
462
+ const rawValue = raw(value)
463
+ if (rawValue == null || typeof rawValue !== 'object') return rawValue
464
+ if (typeof globalThis.structuredClone === 'function') {
465
+ return globalThis.structuredClone(rawValue)
466
+ }
467
+ return JSON.parse(JSON.stringify(rawValue))
468
+ }
469
+
450
470
  function normalizeUndefined (value) {
451
471
  return value === undefined ? null : value
452
472
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamplay",
3
- "version": "0.4.0-alpha.98",
3
+ "version": "0.4.0",
4
4
  "description": "Full-stack signals ORM with multiplayer",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -35,12 +35,12 @@
35
35
  "dependencies": {
36
36
  "@nx-js/observer-util": "^4.1.3",
37
37
  "@startupjs/sharedb-mingo-memory": "^4.0.0-2",
38
- "@teamplay/backend": "^0.4.0-alpha.9",
39
- "@teamplay/cache": "^0.4.0-alpha.9",
40
- "@teamplay/channel": "^0.4.0-alpha.9",
41
- "@teamplay/debug": "^0.4.0-alpha.9",
42
- "@teamplay/schema": "^0.4.0-alpha.9",
43
- "@teamplay/utils": "^0.4.0-alpha.9",
38
+ "@teamplay/backend": "^0.4.0",
39
+ "@teamplay/cache": "^0.4.0",
40
+ "@teamplay/channel": "^0.4.0",
41
+ "@teamplay/debug": "^0.4.0",
42
+ "@teamplay/schema": "^0.4.0",
43
+ "@teamplay/utils": "^0.4.0",
44
44
  "diff-match-patch": "^1.0.5",
45
45
  "events": "^3.3.0",
46
46
  "json0-ot-diff": "^1.1.2",
@@ -83,5 +83,5 @@
83
83
  ]
84
84
  },
85
85
  "license": "MIT",
86
- "gitHead": "ae4e0e6ee39e395fa9eb48a2099cc1d5b2a4630d"
86
+ "gitHead": "c944a34b27e2dbbe5c81b1c39739caa012212e3f"
87
87
  }