teamplay 0.4.0-alpha.81 → 0.4.0-alpha.83

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.
package/index.js CHANGED
@@ -65,7 +65,15 @@ export {
65
65
  useOnce,
66
66
  useSyncEffect
67
67
  } from './react/helpers.js'
68
- export { connection, setConnection, getConnection, fetchOnly, setFetchOnly, publicOnly, setPublicOnly } from './orm/connection.js'
68
+ export {
69
+ connection,
70
+ setConnection,
71
+ getConnection,
72
+ getDefaultFetchOnly,
73
+ setDefaultFetchOnly,
74
+ publicOnly,
75
+ setPublicOnly
76
+ } from './orm/connection.js'
69
77
  export { getSubscriptionGcDelay, setSubscriptionGcDelay } from './orm/subscriptionGcDelay.js'
70
78
  export { useId, useNow, useScheduleUpdate, useTriggerUpdate } from './react/helpers.js'
71
79
  export { GUID_PATTERN, hasMany, hasOne, hasManyFlags, belongsTo, pickFormFields } from '@teamplay/schema'
@@ -9,7 +9,7 @@ import {
9
9
  isPublicDocumentSignal
10
10
  } from '../SignalBase.js'
11
11
  import { getRoot, ROOT, ROOT_ID, getRootSignal, GLOBAL_ROOT_ID, unregisterRootFinalizer } from '../Root.js'
12
- import { publicOnly, fetchOnly, setFetchOnly } from '../connection.js'
12
+ import { isPrivateMutationForbidden } 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'
@@ -123,15 +123,13 @@ class SignalCompat extends Signal {
123
123
  }
124
124
 
125
125
  fetch (...items) {
126
- return withFetchOnly(() => {
127
- if (items.length > 0) return subscribeMany(items, 'subscribe')
128
- return subscribeSelf(this)
129
- })
126
+ if (items.length > 0) return subscribeMany(items, 'subscribe', 'fetch')
127
+ return subscribeSelf(this, 'fetch')
130
128
  }
131
129
 
132
130
  unfetch (...items) {
133
- if (items.length > 0) return subscribeMany(items, 'unsubscribe')
134
- return unsubscribeSelf(this)
131
+ if (items.length > 0) return subscribeMany(items, 'unsubscribe', 'fetch')
132
+ return unsubscribeSelf(this, 'fetch')
135
133
  }
136
134
 
137
135
  getExtra () {
@@ -1108,7 +1106,7 @@ async function setReplaceOnSignal ($signal, value) {
1108
1106
  mirrorRefMutationFromTarget(segments, value)
1109
1107
  return result
1110
1108
  }
1111
- if (publicOnly) throw Error(ERRORS.publicOnly)
1109
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1112
1110
  const result = setReplacePrivateData(getOwningRootId($signal), segments, value)
1113
1111
  mirrorRefMutationFromTarget(segments, value)
1114
1112
  return result
@@ -1128,7 +1126,7 @@ async function incrementOnSignal ($signal, byNumber) {
1128
1126
  await _incrementPublic(segments, byNumber)
1129
1127
  return currentValue + byNumber
1130
1128
  }
1131
- if (publicOnly) throw Error(ERRORS.publicOnly)
1129
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1132
1130
  setReplacePrivateData(getOwningRootId($signal), segments, currentValue + byNumber)
1133
1131
  return currentValue + byNumber
1134
1132
  }
@@ -1161,7 +1159,7 @@ async function arrayPushOnSignal ($signal, value) {
1161
1159
  const idFields = getIdFieldsForSegments(segments)
1162
1160
  if (isIdFieldPath(segments, idFields)) return
1163
1161
  if (isPublicCollection(segments[0])) return _arrayPushPublic(segments, value)
1164
- if (publicOnly) throw Error(ERRORS.publicOnly)
1162
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1165
1163
  return arrayPushPrivateData(getOwningRootId($signal), segments, value)
1166
1164
  }
1167
1165
 
@@ -1170,7 +1168,7 @@ async function arrayUnshiftOnSignal ($signal, value) {
1170
1168
  const idFields = getIdFieldsForSegments(segments)
1171
1169
  if (isIdFieldPath(segments, idFields)) return
1172
1170
  if (isPublicCollection(segments[0])) return _arrayUnshiftPublic(segments, value)
1173
- if (publicOnly) throw Error(ERRORS.publicOnly)
1171
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1174
1172
  return arrayUnshiftPrivateData(getOwningRootId($signal), segments, value)
1175
1173
  }
1176
1174
 
@@ -1179,7 +1177,7 @@ async function arrayInsertOnSignal ($signal, index, values) {
1179
1177
  const idFields = getIdFieldsForSegments(segments)
1180
1178
  if (isIdFieldPath(segments, idFields)) return
1181
1179
  if (isPublicCollection(segments[0])) return _arrayInsertPublic(segments, index, values)
1182
- if (publicOnly) throw Error(ERRORS.publicOnly)
1180
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1183
1181
  return arrayInsertPrivateData(getOwningRootId($signal), segments, index, values)
1184
1182
  }
1185
1183
 
@@ -1188,7 +1186,7 @@ async function arrayPopOnSignal ($signal) {
1188
1186
  const idFields = getIdFieldsForSegments(segments)
1189
1187
  if (isIdFieldPath(segments, idFields)) return
1190
1188
  if (isPublicCollection(segments[0])) return _arrayPopPublic(segments)
1191
- if (publicOnly) throw Error(ERRORS.publicOnly)
1189
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1192
1190
  return arrayPopPrivateData(getOwningRootId($signal), segments)
1193
1191
  }
1194
1192
 
@@ -1197,7 +1195,7 @@ async function arrayShiftOnSignal ($signal) {
1197
1195
  const idFields = getIdFieldsForSegments(segments)
1198
1196
  if (isIdFieldPath(segments, idFields)) return
1199
1197
  if (isPublicCollection(segments[0])) return _arrayShiftPublic(segments)
1200
- if (publicOnly) throw Error(ERRORS.publicOnly)
1198
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1201
1199
  return arrayShiftPrivateData(getOwningRootId($signal), segments)
1202
1200
  }
1203
1201
 
@@ -1206,7 +1204,7 @@ async function arrayRemoveOnSignal ($signal, index, howMany) {
1206
1204
  const idFields = getIdFieldsForSegments(segments)
1207
1205
  if (isIdFieldPath(segments, idFields)) return
1208
1206
  if (isPublicCollection(segments[0])) return _arrayRemovePublic(segments, index, howMany)
1209
- if (publicOnly) throw Error(ERRORS.publicOnly)
1207
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1210
1208
  return arrayRemovePrivateData(getOwningRootId($signal), segments, index, howMany)
1211
1209
  }
1212
1210
 
@@ -1215,7 +1213,7 @@ async function arrayMoveOnSignal ($signal, from, to, howMany) {
1215
1213
  const idFields = getIdFieldsForSegments(segments)
1216
1214
  if (isIdFieldPath(segments, idFields)) return
1217
1215
  if (isPublicCollection(segments[0])) return _arrayMovePublic(segments, from, to, howMany)
1218
- if (publicOnly) throw Error(ERRORS.publicOnly)
1216
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1219
1217
  return arrayMovePrivateData(getOwningRootId($signal), segments, from, to, howMany)
1220
1218
  }
1221
1219
 
@@ -1224,7 +1222,7 @@ async function stringInsertOnSignal ($signal, index, text) {
1224
1222
  const idFields = getIdFieldsForSegments(segments)
1225
1223
  if (isIdFieldPath(segments, idFields)) return
1226
1224
  if (isPublicCollection(segments[0])) return _stringInsertPublic(segments, index, text)
1227
- if (publicOnly) throw Error(ERRORS.publicOnly)
1225
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1228
1226
  return stringInsertPrivateData(getOwningRootId($signal), segments, index, text)
1229
1227
  }
1230
1228
 
@@ -1233,7 +1231,7 @@ async function stringRemoveOnSignal ($signal, index, howMany) {
1233
1231
  const idFields = getIdFieldsForSegments(segments)
1234
1232
  if (isIdFieldPath(segments, idFields)) return
1235
1233
  if (isPublicCollection(segments[0])) return _stringRemovePublic(segments, index, howMany)
1236
- if (publicOnly) throw Error(ERRORS.publicOnly)
1234
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
1237
1235
  return stringRemovePrivateData(getOwningRootId($signal), segments, index, howMany)
1238
1236
  }
1239
1237
 
@@ -1297,17 +1295,7 @@ function withQueryScopeOptions (options, $root) {
1297
1295
  return nextOptions
1298
1296
  }
1299
1297
 
1300
- function withFetchOnly (fn) {
1301
- const prevFetchOnly = fetchOnly
1302
- setFetchOnly(true)
1303
- try {
1304
- return fn()
1305
- } finally {
1306
- setFetchOnly(prevFetchOnly)
1307
- }
1308
- }
1309
-
1310
- function subscribeMany (items, action) {
1298
+ function subscribeMany (items, action, intent = 'subscribe') {
1311
1299
  const targets = flattenItems(items)
1312
1300
  const promises = []
1313
1301
  for (const target of targets) {
@@ -1316,8 +1304,8 @@ function subscribeMany (items, action) {
1316
1304
  throw Error(`Signal.${action}() accepts only Signal instances. Got: ${target}`)
1317
1305
  }
1318
1306
  const result = action === 'subscribe'
1319
- ? subscribeSelf(target)
1320
- : unsubscribeSelf(target)
1307
+ ? subscribeSelf(target, intent)
1308
+ : unsubscribeSelf(target, intent)
1321
1309
  if (result?.then) promises.push(result)
1322
1310
  }
1323
1311
  if (promises.length) return Promise.all(promises)
@@ -1335,20 +1323,20 @@ function flattenItems (items, result = []) {
1335
1323
  return result
1336
1324
  }
1337
1325
 
1338
- function subscribeSelf ($signal) {
1326
+ function subscribeSelf ($signal, intent = 'subscribe') {
1339
1327
  if ($signal[IS_QUERY]) {
1340
1328
  return (async () => {
1341
- await querySubscriptions.subscribe($signal)
1329
+ await querySubscriptions.subscribe($signal, { intent })
1342
1330
  await waitForImperativeQueryReady($signal)
1343
1331
  })()
1344
1332
  }
1345
1333
  if ($signal[IS_AGGREGATION]) {
1346
1334
  return (async () => {
1347
- await aggregationSubscriptions.subscribe($signal)
1335
+ await aggregationSubscriptions.subscribe($signal, { intent })
1348
1336
  await waitForImperativeQueryReady($signal)
1349
1337
  })()
1350
1338
  }
1351
- if (isPublicDocumentSignal($signal)) return docSubscriptions.subscribe($signal)
1339
+ if (isPublicDocumentSignal($signal)) return docSubscriptions.subscribe($signal, { intent })
1352
1340
  if (isPublicCollectionSignal($signal)) {
1353
1341
  throw Error('Signal.subscribe() expects a query signal. Use .query() for collections.')
1354
1342
  }
@@ -1358,10 +1346,10 @@ function subscribeSelf ($signal) {
1358
1346
  throw Error('Signal.subscribe() expects a document or query signal')
1359
1347
  }
1360
1348
 
1361
- function unsubscribeSelf ($signal) {
1362
- if ($signal[IS_QUERY]) return querySubscriptions.unsubscribe($signal)
1363
- if ($signal[IS_AGGREGATION]) return aggregationSubscriptions.unsubscribe($signal)
1364
- if (isPublicDocumentSignal($signal)) return docSubscriptions.unsubscribe($signal)
1349
+ function unsubscribeSelf ($signal, intent = 'subscribe') {
1350
+ if ($signal[IS_QUERY]) return querySubscriptions.unsubscribe($signal, { intent })
1351
+ if ($signal[IS_AGGREGATION]) return aggregationSubscriptions.unsubscribe($signal, { intent })
1352
+ if (isPublicDocumentSignal($signal)) return docSubscriptions.unsubscribe($signal, { intent })
1365
1353
  if (isPublicCollectionSignal($signal)) {
1366
1354
  throw Error('Signal.unsubscribe() expects a query signal. Use .query() for collections.')
1367
1355
  }
package/orm/Doc.js CHANGED
@@ -1,14 +1,14 @@
1
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
- import { getConnection, fetchOnly } from './connection.js'
4
+ import { getConnection } from './connection.js'
5
5
  import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
6
6
  import SubscriptionState from './SubscriptionState.js'
7
7
  import { getIdFieldsForSegments, injectIdFields, isPlainObject } from './idFields.js'
8
8
  import { emitModelChange, isModelEventsEnabled } from './Compat/modelEvents.js'
9
9
  import { getSubscriptionGcDelay } from './subscriptionGcDelay.js'
10
10
  import { isMissingShareDoc } from './missingDoc.js'
11
- import { getRoot, ROOT_ID, GLOBAL_ROOT_ID } from './Root.js'
11
+ import { getRoot, ROOT_ID, GLOBAL_ROOT_ID, getRootTransportMode } from './Root.js'
12
12
  import {
13
13
  registerRootOwnedDirectDocSubscription,
14
14
  unregisterRootOwnedDirectDocSubscription,
@@ -45,6 +45,8 @@ class Doc {
45
45
  onSubscribe: () => this._subscribe(),
46
46
  onUnsubscribe: () => this._unsubscribe()
47
47
  })
48
+ this.requestedTransportMode = 'subscribe'
49
+ this.activeTransportMode = 'idle'
48
50
  this.init()
49
51
  }
50
52
 
@@ -58,7 +60,8 @@ class Doc {
58
60
  this._initData()
59
61
  }
60
62
 
61
- async subscribe () {
63
+ async subscribe ({ mode } = {}) {
64
+ if (mode) this.requestedTransportMode = mode
62
65
  await this.lifecycle.subscribe()
63
66
  this.init()
64
67
  }
@@ -69,10 +72,12 @@ class Doc {
69
72
 
70
73
  async _subscribe () {
71
74
  const doc = getConnection().get(this.collection, this.docId)
75
+ const mode = this.requestedTransportMode
72
76
  await new Promise((resolve, reject) => {
73
- const method = fetchOnly ? 'fetch' : 'subscribe'
77
+ const method = mode === 'fetch' ? 'fetch' : 'subscribe'
74
78
  doc[method](err => {
75
79
  if (err) return reject(err)
80
+ this.activeTransportMode = mode
76
81
  resolve()
77
82
  })
78
83
  })
@@ -81,8 +86,12 @@ class Doc {
81
86
  async _unsubscribe () {
82
87
  const doc = getConnection().get(this.collection, this.docId)
83
88
  await new Promise((resolve, reject) => {
84
- doc.unsubscribe(err => {
89
+ const method = this.activeTransportMode === 'fetch' && typeof doc.unfetch === 'function'
90
+ ? 'unfetch'
91
+ : 'unsubscribe'
92
+ doc[method](err => {
85
93
  if (err) return reject(err)
94
+ this.activeTransportMode = 'idle'
86
95
  resolve()
87
96
  })
88
97
  })
@@ -167,10 +176,15 @@ class Doc {
167
176
  export class DocSubscriptions {
168
177
  constructor (DocClass = Doc) {
169
178
  this.DocClass = DocClass
170
- this.subCount = new Map()
179
+ this.subCount = new Map() // transportHash -> total ref count (owners + retained docs)
180
+ this.ownerFetchCount = new Map() // ownerKey -> fetch intent count
181
+ this.ownerSubscribeCount = new Map() // ownerKey -> subscribe intent count
182
+ this.ownerMeta = new Map() // ownerKey -> { hash, segments, rootId }
183
+ this.ownerKeysByHash = new Map() // transportHash -> Set(ownerKey)
171
184
  this.docs = new Map()
172
185
  this.pendingDestroyTimers = new Map()
173
- this.fr = new FinalizationRegistry(segments => this.scheduleDestroy(segments, { force: true }))
186
+ this.transportTasks = new Map()
187
+ this.fr = new FinalizationRegistry(({ hash, ownerKey }) => this.destroyByOwnerKey(ownerKey, { hash, force: true }))
174
188
  }
175
189
 
176
190
  init ($doc) {
@@ -183,34 +197,36 @@ export class DocSubscriptions {
183
197
  } else {
184
198
  doc = new this.DocClass(...segments)
185
199
  this.docs.set(hash, doc)
186
- this.fr.register($doc, segments, getDocFinalizationToken($doc))
187
200
  doc.init()
188
201
  }
189
202
  }
190
203
 
191
- subscribe ($doc) {
204
+ subscribe ($doc, { intent = 'subscribe' } = {}) {
192
205
  const segments = [...$doc[SEGMENTS]]
193
206
  const hash = hashDoc(segments)
194
207
  const rootId = getOwningRootId($doc)
208
+ const ownerKey = getDocOwnerKey(rootId, hash)
209
+ const token = getDocFinalizationToken($doc)
210
+ const previousCount = this.subCount.get(hash) || 0
195
211
  this.cancelDestroy(hash)
196
- let count = this.subCount.get(hash) || 0
197
- count += 1
198
- this.subCount.set(hash, count)
212
+ this.incrementOwnerIntent(ownerKey, intent)
213
+ this.addOwnerMeta(ownerKey, hash, segments, rootId)
214
+ this.subCount.set(hash, previousCount + 1)
199
215
  if (rootId) {
200
- registerRootOwnedDirectDocSubscription(rootId, hash, segments, getDocFinalizationToken($doc))
201
- }
202
- if (count > 1) {
203
- const existingDoc = this.docs.get(hash)
204
- if (existingDoc) return existingDoc._subscribing
205
- // Recover from stale ref-count state when doc entry was already cleaned up.
206
- count = 1
207
- this.subCount.set(hash, count)
216
+ registerRootOwnedDirectDocSubscription(rootId, hash, segments, token)
208
217
  }
218
+ this.fr.register($doc, { hash, ownerKey }, token)
209
219
 
210
220
  this.init($doc)
211
221
  const doc = this.docs.get(hash)
212
- doc._subscribing = doc.subscribe().then(() => { doc._subscribing = undefined })
213
- return doc._subscribing
222
+ if (
223
+ previousCount > 0 &&
224
+ doc &&
225
+ !doc._subscribing &&
226
+ !this.transportTasks.get(hash) &&
227
+ this.getDesiredTransportMode(hash) === doc.activeTransportMode
228
+ ) return
229
+ return this.reconcileTransport(hash)
214
230
  }
215
231
 
216
232
  retain ($doc) {
@@ -222,26 +238,33 @@ export class DocSubscriptions {
222
238
  this.init($doc)
223
239
  }
224
240
 
225
- async unsubscribe ($doc) {
241
+ async unsubscribe ($doc, { intent = 'subscribe' } = {}) {
226
242
  const segments = [...$doc[SEGMENTS]]
227
243
  const hash = hashDoc(segments)
228
244
  const rootId = getOwningRootId($doc)
229
- let count = this.subCount.get(hash) || 0
230
- count -= 1
231
- if (count < 0) {
245
+ const ownerKey = getDocOwnerKey(rootId, hash)
246
+ const token = getDocFinalizationToken($doc)
247
+ const currentIntentCount = this.getOwnerIntentCount(ownerKey, intent)
248
+ if (currentIntentCount <= 0) {
232
249
  if (ERROR_ON_EXCESSIVE_UNSUBSCRIBES) throw ERRORS.notSubscribed($doc)
233
250
  return
234
251
  }
235
- if (count > 0) {
236
- this.subCount.set(hash, count)
237
- return
238
- }
239
- this.subCount.set(hash, 0)
240
- this.fr.unregister(getDocFinalizationToken($doc))
252
+ this.setOwnerIntentCount(ownerKey, intent, currentIntentCount - 1)
253
+ const nextOwnerCount = this.getOwnerTotalCount(ownerKey)
254
+ const count = Math.max((this.subCount.get(hash) || 0) - 1, 0)
255
+ if (count > 0) this.subCount.set(hash, count)
256
+ else this.subCount.set(hash, 0)
241
257
  if (rootId) {
242
- unregisterRootOwnedDirectDocSubscription(rootId, hash, getDocFinalizationToken($doc))
258
+ unregisterRootOwnedDirectDocSubscription(rootId, hash, token)
243
259
  }
244
- await this.scheduleDestroy(segments)
260
+ if (nextOwnerCount === 0) {
261
+ this.fr.unregister(token)
262
+ this.removeOwnerMeta(ownerKey, hash)
263
+ }
264
+ const destroyPromise = count === 0 ? this.scheduleDestroy(segments) : undefined
265
+ await this.reconcileTransport(hash)
266
+ if (count > 0) return
267
+ await destroyPromise
245
268
  }
246
269
 
247
270
  async release ($doc) {
@@ -275,6 +298,10 @@ export class DocSubscriptions {
275
298
  await this.destroyByHash(hash, { force: true })
276
299
  }
277
300
  this.subCount.clear()
301
+ this.ownerFetchCount.clear()
302
+ this.ownerSubscribeCount.clear()
303
+ this.ownerMeta.clear()
304
+ this.ownerKeysByHash.clear()
278
305
  }
279
306
 
280
307
  async releaseRootOwnedSubscriptions (rootId) {
@@ -284,14 +311,7 @@ export class DocSubscriptions {
284
311
  for (const token of entry.tokenCounts.keys()) {
285
312
  this.fr.unregister(token)
286
313
  }
287
- const currentCount = this.subCount.get(hash) || 0
288
- const nextCount = Math.max(currentCount - entry.count, 0)
289
- if (nextCount > 0) {
290
- this.subCount.set(hash, nextCount)
291
- continue
292
- }
293
- this.subCount.set(hash, 0)
294
- await this.destroyByHash(hash, { force: true })
314
+ await this.destroyByOwnerKey(getDocOwnerKey(rootId, hash), { hash, force: true })
295
315
  }
296
316
  clearRootOwnedDirectDocSubscriptions(rootId)
297
317
  }
@@ -330,6 +350,40 @@ export class DocSubscriptions {
330
350
  entry.resolve()
331
351
  }
332
352
 
353
+ async reconcileTransport (hash) {
354
+ const previous = this.transportTasks.get(hash) || Promise.resolve()
355
+ const next = previous
356
+ .catch(ignoreDestroyError)
357
+ .then(() => this.reconcileTransportNow(hash))
358
+ this.transportTasks.set(hash, next)
359
+ try {
360
+ await next
361
+ } finally {
362
+ if (this.transportTasks.get(hash) === next) this.transportTasks.delete(hash)
363
+ }
364
+ }
365
+
366
+ async reconcileTransportNow (hash) {
367
+ const doc = this.docs.get(hash)
368
+ if (!doc) return
369
+ while (true) {
370
+ const desiredMode = this.getDesiredTransportMode(hash)
371
+ const currentMode = doc.activeTransportMode
372
+ if (desiredMode === currentMode) return
373
+ if (desiredMode === 'idle') {
374
+ if (currentMode === 'idle') return
375
+ await doc.unsubscribe()
376
+ continue
377
+ }
378
+ if (currentMode !== 'idle') {
379
+ await doc.unsubscribe()
380
+ continue
381
+ }
382
+ doc._subscribing = doc.subscribe({ mode: desiredMode }).then(() => { doc._subscribing = undefined })
383
+ await doc._subscribing
384
+ }
385
+ }
386
+
333
387
  async destroyByHash (hash, options = {}) {
334
388
  let pendingDestroy = options._pendingDestroy
335
389
  if (pendingDestroy) this.takePendingDestroy(hash, pendingDestroy)
@@ -354,12 +408,13 @@ export class DocSubscriptions {
354
408
  settlePending()
355
409
  return
356
410
  }
357
- // Always call unsubscribe() - if doc is in SUBSCRIBING state, the state machine
358
- // will queue a pending unsubscribe to execute after subscribe completes
359
- await doc.unsubscribe()
360
- if (doc.subscribed) {
411
+ await this.reconcileTransport(hash)
412
+ if (!options.force && (this.subCount.get(hash) || 0) > 0) {
361
413
  settlePending()
362
- return // Subscribed again while unsubscribing
414
+ return
415
+ }
416
+ if (doc.activeTransportMode !== 'idle') {
417
+ await doc.unsubscribe()
363
418
  }
364
419
  if (!options.force && (this.subCount.get(hash) || 0) > 0) {
365
420
  settlePending()
@@ -381,6 +436,7 @@ export class DocSubscriptions {
381
436
  if (typeof doc.dispose === 'function') doc.dispose()
382
437
  this.docs.delete(hash)
383
438
  this.subCount.delete(hash)
439
+ this.ownerKeysByHash.delete(hash)
384
440
  settlePending()
385
441
  } catch (err) {
386
442
  settlePending(err)
@@ -396,6 +452,83 @@ export class DocSubscriptions {
396
452
  this.pendingDestroyTimers.delete(hash)
397
453
  return entry
398
454
  }
455
+
456
+ getOwnerIntentCount (ownerKey, intent) {
457
+ const store = intent === 'fetch' ? this.ownerFetchCount : this.ownerSubscribeCount
458
+ return store.get(ownerKey) || 0
459
+ }
460
+
461
+ setOwnerIntentCount (ownerKey, intent, count) {
462
+ const store = intent === 'fetch' ? this.ownerFetchCount : this.ownerSubscribeCount
463
+ if (count > 0) store.set(ownerKey, count)
464
+ else store.delete(ownerKey)
465
+ }
466
+
467
+ incrementOwnerIntent (ownerKey, intent) {
468
+ this.setOwnerIntentCount(ownerKey, intent, this.getOwnerIntentCount(ownerKey, intent) + 1)
469
+ }
470
+
471
+ getOwnerTotalCount (ownerKey) {
472
+ return (this.ownerFetchCount.get(ownerKey) || 0) + (this.ownerSubscribeCount.get(ownerKey) || 0)
473
+ }
474
+
475
+ addOwnerMeta (ownerKey, hash, segments, rootId) {
476
+ if (this.ownerMeta.has(ownerKey)) return
477
+ this.ownerMeta.set(ownerKey, { hash, segments: [...segments], rootId })
478
+ let ownerKeys = this.ownerKeysByHash.get(hash)
479
+ if (!ownerKeys) {
480
+ ownerKeys = new Set()
481
+ this.ownerKeysByHash.set(hash, ownerKeys)
482
+ }
483
+ ownerKeys.add(ownerKey)
484
+ }
485
+
486
+ removeOwnerMeta (ownerKey, hash) {
487
+ const meta = this.ownerMeta.get(ownerKey)
488
+ const knownHash = hash ?? meta?.hash
489
+ this.ownerMeta.delete(ownerKey)
490
+ this.ownerFetchCount.delete(ownerKey)
491
+ this.ownerSubscribeCount.delete(ownerKey)
492
+ if (!knownHash) return
493
+ const ownerKeys = this.ownerKeysByHash.get(knownHash)
494
+ if (!ownerKeys) return
495
+ ownerKeys.delete(ownerKey)
496
+ if (ownerKeys.size === 0) this.ownerKeysByHash.delete(knownHash)
497
+ }
498
+
499
+ getDesiredTransportMode (hash) {
500
+ const ownerKeys = this.ownerKeysByHash.get(hash)
501
+ if (!ownerKeys || ownerKeys.size === 0) return 'idle'
502
+ let hasFetchBackedOwner = false
503
+ for (const ownerKey of ownerKeys) {
504
+ const subscribeCount = this.ownerSubscribeCount.get(ownerKey) || 0
505
+ const fetchCount = this.ownerFetchCount.get(ownerKey) || 0
506
+ const rootId = this.ownerMeta.get(ownerKey)?.rootId
507
+ const subscribeMode = getRootTransportMode(rootId, 'subscribe')
508
+ if (subscribeCount > 0 && subscribeMode === 'subscribe') return 'subscribe'
509
+ if (fetchCount > 0 || (subscribeCount > 0 && subscribeMode === 'fetch')) {
510
+ hasFetchBackedOwner = true
511
+ }
512
+ }
513
+ return hasFetchBackedOwner ? 'fetch' : 'idle'
514
+ }
515
+
516
+ async destroyByOwnerKey (ownerKey, options = {}) {
517
+ const meta = this.ownerMeta.get(ownerKey)
518
+ if (!meta) return
519
+ const { hash, segments } = meta
520
+ const ownerCount = this.getOwnerTotalCount(ownerKey)
521
+ if (!options.force && ownerCount > 0) return
522
+
523
+ const currentCount = this.subCount.get(hash) || 0
524
+ const nextCount = Math.max(currentCount - ownerCount, 0)
525
+ if (nextCount > 0) this.subCount.set(hash, nextCount)
526
+ else this.subCount.set(hash, 0)
527
+ this.removeOwnerMeta(ownerKey, hash)
528
+ await this.reconcileTransport(hash)
529
+ if (nextCount > 0) return
530
+ await this.scheduleDestroy(segments, { force: !!options.force })
531
+ }
399
532
  }
400
533
 
401
534
  export const docSubscriptions = new DocSubscriptions()
@@ -404,6 +537,10 @@ function hashDoc (segments) {
404
537
  return JSON.stringify(segments)
405
538
  }
406
539
 
540
+ function getDocOwnerKey (rootId, hash) {
541
+ return JSON.stringify({ owner: [rootId, hash] })
542
+ }
543
+
407
544
  function ignoreDestroyError () {}
408
545
 
409
546
  function createPendingDestroyEntry () {
package/orm/Query.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { raw } from '@nx-js/observer-util'
2
2
  import { set as _set, getRaw } from './dataTree.js'
3
3
  import getSignal from './getSignal.js'
4
- import { getConnection, fetchOnly } from './connection.js'
4
+ import { getConnection } from './connection.js'
5
5
  import { emitModelChange, isModelEventsEnabled } from './Compat/modelEvents.js'
6
6
  import { isCompatEnv } from './compatEnv.js'
7
7
  import { docSubscriptions } from './Doc.js'
@@ -10,7 +10,7 @@ import SubscriptionState from './SubscriptionState.js'
10
10
  import { getIdFieldsForSegments, injectIdFields, isPlainObject } from './idFields.js'
11
11
  import { getSubscriptionGcDelay } from './subscriptionGcDelay.js'
12
12
  import { getScopedSignalHash } from './rootScope.js'
13
- import { getRoot, ROOT_ID } from './Root.js'
13
+ import { getRoot, ROOT_ID, getRootTransportMode } from './Root.js'
14
14
  import { registerRootOwnedRuntime, unregisterRootOwnedRuntime } from './rootContext.js'
15
15
  import {
16
16
  delPrivateData,
@@ -39,10 +39,12 @@ export class Query {
39
39
  onSubscribe: () => this._subscribe(),
40
40
  onUnsubscribe: () => this._unsubscribe()
41
41
  })
42
+ this.requestedTransportMode = 'subscribe'
43
+ this.activeTransportMode = 'idle'
42
44
  }
43
45
 
44
46
  get subscribed () {
45
- return this.lifecycle.subscribed
47
+ return this.activeTransportMode !== 'idle' || this.lifecycle.subscribed
46
48
  }
47
49
 
48
50
  init () {
@@ -51,7 +53,8 @@ export class Query {
51
53
  this._initData()
52
54
  }
53
55
 
54
- async subscribe () {
56
+ async subscribe ({ mode } = {}) {
57
+ if (mode) this.requestedTransportMode = mode
55
58
  await this.lifecycle.subscribe()
56
59
  this.init()
57
60
  }
@@ -60,7 +63,7 @@ export class Query {
60
63
  await this.lifecycle.unsubscribe()
61
64
  if (!this.subscribed) {
62
65
  this.initialized = undefined
63
- this._removeData()
66
+ this._detachTransportData({ keepRoots: false })
64
67
  }
65
68
  }
66
69
 
@@ -78,10 +81,12 @@ export class Query {
78
81
  }
79
82
 
80
83
  async _subscribe () {
84
+ const mode = this.requestedTransportMode
81
85
  await new Promise((resolve, reject) => {
82
- const method = fetchOnly ? 'createFetchQuery' : 'createSubscribeQuery'
86
+ const method = mode === 'fetch' ? 'createFetchQuery' : 'createSubscribeQuery'
83
87
  this.shareQuery = getConnection()[method](this.collectionName, this.params, {}, err => {
84
88
  if (err) return reject(err)
89
+ this.activeTransportMode = mode
85
90
  resolve()
86
91
  })
87
92
  })
@@ -92,6 +97,7 @@ export class Query {
92
97
  await new Promise((resolve, reject) => {
93
98
  this.shareQuery.destroy(err => {
94
99
  if (err) return reject(err)
100
+ this.activeTransportMode = 'idle'
95
101
  resolve()
96
102
  })
97
103
  this.shareQuery = undefined
@@ -250,13 +256,17 @@ export class Query {
250
256
  })
251
257
  }
252
258
 
253
- _removeData () {
259
+ _detachTransportData ({ keepRoots = true } = {}) {
254
260
  for (const $doc of this.docSignals) {
255
261
  docSubscriptions.release($doc).catch(ignoreDestroyError)
256
262
  }
257
263
  this.docSignals.clear()
258
264
  this._forEachRoot(rootId => this._removeRootData(rootId))
259
- this.rootIds.clear()
265
+ if (!keepRoots) this.rootIds.clear()
266
+ }
267
+
268
+ _removeData () {
269
+ this._detachTransportData({ keepRoots: false })
260
270
  }
261
271
  }
262
272
 
@@ -264,39 +274,44 @@ export class QuerySubscriptions {
264
274
  constructor (QueryClass = Query) {
265
275
  this.QueryClass = QueryClass
266
276
  this.runtimeKind = 'query'
267
- this.subCount = new Map() // ownerKey -> count
268
- this.transportSubCount = new Map() // transportHash -> attached roots count
277
+ this.subCount = new Map() // ownerKey -> total ref count
278
+ this.transportSubCount = new Map() // transportHash -> attached owner count
279
+ this.ownerFetchCount = new Map() // ownerKey -> fetch intent count
280
+ this.ownerSubscribeCount = new Map() // ownerKey -> subscribe intent count
269
281
  this.queries = new Map()
270
282
  this.ownerToTransport = new Map() // ownerKey -> transportHash
271
283
  this.ownerMeta = new Map() // ownerKey -> { collectionName, params, transportHash, rootId }
272
284
  this.ownerKeysByTransport = new Map() // transportHash -> Set(ownerKey)
273
285
  this.pendingDestroyTimers = new Map()
286
+ this.transportTasks = new Map()
274
287
  this.fr = new FinalizationRegistry(({ collectionName, params, ownerKey }) => {
275
288
  this.scheduleDestroy(collectionName, params, ownerKey, { force: true })
276
289
  })
277
290
  }
278
291
 
279
- subscribe ($query) {
292
+ subscribe ($query, { intent = 'subscribe' } = {}) {
280
293
  const collectionName = $query[COLLECTION_NAME]
281
294
  const params = cloneQueryParams($query[PARAMS])
282
295
  const transportHash = $query[HASH]
283
296
  const rootId = getOwningRootId($query)
284
297
  const ownerKey = getQueryOwnerKey(rootId, transportHash)
285
298
  this.cancelDestroy(ownerKey)
286
- let count = this.subCount.get(ownerKey) || 0
287
- count += 1
288
- this.subCount.set(ownerKey, count)
289
- if (count > 1) {
290
- const existingQuery = this.queries.get(transportHash)
291
- if (existingQuery) return existingQuery._subscribing
292
- // Recover from stale ref-count state when query was already cleaned up.
293
- count = 1
294
- this.subCount.set(ownerKey, count)
299
+
300
+ let query = this.queries.get(transportHash)
301
+ let previousCount = this.subCount.get(ownerKey) || 0
302
+ if (previousCount > 0 && !query) {
303
+ this.subCount.delete(ownerKey)
304
+ this.ownerFetchCount.delete(ownerKey)
305
+ this.ownerSubscribeCount.delete(ownerKey)
306
+ const staleTransportHash = this.ownerToTransport.get(ownerKey)
307
+ if (staleTransportHash) this.removeOwnerMeta(ownerKey, staleTransportHash)
308
+ previousCount = 0
295
309
  }
296
310
 
311
+ this.incrementOwnerIntent(ownerKey, intent)
312
+ this.subCount.set(ownerKey, previousCount + 1)
297
313
  this.fr.register($query, { collectionName, params, ownerKey }, $query)
298
314
 
299
- let query = this.queries.get(transportHash)
300
315
  if (!query) {
301
316
  query = new this.QueryClass(collectionName, params, { hash: transportHash })
302
317
  this.queries.set(transportHash, query)
@@ -320,29 +335,66 @@ export class QuerySubscriptions {
320
335
 
321
336
  const transportCount = (this.transportSubCount.get(transportHash) || 0) + 1
322
337
  this.transportSubCount.set(transportHash, transportCount)
323
- if (transportCount === 1) {
324
- query._subscribing = query.subscribe().then(() => { query._subscribing = undefined })
325
- }
326
338
  }
327
339
 
328
- return query._subscribing
340
+ if (
341
+ previousCount > 0 &&
342
+ query &&
343
+ !query._subscribing &&
344
+ !this.transportTasks.get(transportHash) &&
345
+ this.getDesiredTransportMode(transportHash) === query.activeTransportMode
346
+ ) return
347
+
348
+ return this.reconcileTransport(transportHash)
329
349
  }
330
350
 
331
- async unsubscribe ($query) {
351
+ async unsubscribe ($query, { intent = 'subscribe' } = {}) {
332
352
  const ownerKey = getQueryOwnerKey(getOwningRootId($query), $query[HASH])
333
- let count = this.subCount.get(ownerKey) || 0
334
- count -= 1
335
- if (count < 0) {
353
+ const currentIntentCount = this.getOwnerIntentCount(ownerKey, intent)
354
+ if (currentIntentCount <= 0) {
355
+ if ((this.subCount.get(ownerKey) || 0) > 0 && !this.queries.get($query[HASH])) {
356
+ this.subCount.delete(ownerKey)
357
+ this.ownerFetchCount.delete(ownerKey)
358
+ this.ownerSubscribeCount.delete(ownerKey)
359
+ this.removeOwnerMeta(ownerKey)
360
+ }
336
361
  if (ERROR_ON_EXCESSIVE_UNSUBSCRIBES) throw Error(ERRORS.notSubscribed($query))
337
362
  return
338
363
  }
364
+
365
+ const meta = this.ownerMeta.get(ownerKey)
366
+ const transportHash = meta?.transportHash ?? $query[HASH]
367
+
368
+ this.setOwnerIntentCount(ownerKey, intent, currentIntentCount - 1)
369
+
370
+ const count = Math.max((this.subCount.get(ownerKey) || 0) - 1, 0)
339
371
  if (count > 0) {
340
372
  this.subCount.set(ownerKey, count)
341
- return
373
+ } else {
374
+ this.subCount.set(ownerKey, 0)
342
375
  }
343
- this.subCount.set(ownerKey, 0)
344
- this.fr.unregister($query)
345
- await this.scheduleDestroy($query[COLLECTION_NAME], $query[PARAMS], ownerKey)
376
+
377
+ if (count === 0) {
378
+ this.fr.unregister($query)
379
+ if (meta) {
380
+ const query = this.queries.get(transportHash)
381
+ this.removeOwnerMeta(ownerKey, transportHash)
382
+ detachQueryRoot(query, meta.rootId)
383
+ unregisterRootOwnedRuntime(meta.rootId, this.runtimeKind, transportHash)
384
+
385
+ const nextTransportCount = Math.max((this.transportSubCount.get(transportHash) || 0) - 1, 0)
386
+ if (nextTransportCount > 0) this.transportSubCount.set(transportHash, nextTransportCount)
387
+ else this.transportSubCount.set(transportHash, 0)
388
+ }
389
+ }
390
+
391
+ const destroyPromise = count === 0
392
+ ? this.scheduleDestroy($query[COLLECTION_NAME], $query[PARAMS], ownerKey)
393
+ : undefined
394
+
395
+ await this.reconcileTransport(transportHash)
396
+ if (count > 0) return
397
+ await destroyPromise
346
398
  }
347
399
 
348
400
  async destroy (collectionName, params, options = {}) {
@@ -367,9 +419,12 @@ export class QuerySubscriptions {
367
419
  }
368
420
  this.subCount.clear()
369
421
  this.transportSubCount.clear()
422
+ this.ownerFetchCount.clear()
423
+ this.ownerSubscribeCount.clear()
370
424
  this.ownerToTransport.clear()
371
425
  this.ownerMeta.clear()
372
426
  this.ownerKeysByTransport.clear()
427
+ this.transportTasks.clear()
373
428
  }
374
429
 
375
430
  async flushPendingDestroys () {
@@ -393,6 +448,8 @@ export class QuerySubscriptions {
393
448
  }
394
449
  const entry = createPendingDestroyEntry()
395
450
  if (options.force) entry.force = true
451
+ entry.collectionName = collectionName
452
+ entry.params = params
396
453
  entry.timer = setTimeout(() => {
397
454
  this.destroyByOwnerKey(fallbackOwnerKey, { collectionName, params, force: entry.force })
398
455
  .catch(ignoreDestroyError)
@@ -407,9 +464,80 @@ export class QuerySubscriptions {
407
464
  entry.resolve()
408
465
  }
409
466
 
467
+ async reconcileTransport (transportHash) {
468
+ const previous = this.transportTasks.get(transportHash) || Promise.resolve()
469
+ const next = previous
470
+ .catch(ignoreDestroyError)
471
+ .then(() => this.reconcileTransportNow(transportHash))
472
+ this.transportTasks.set(transportHash, next)
473
+ try {
474
+ await next
475
+ } finally {
476
+ if (this.transportTasks.get(transportHash) === next) this.transportTasks.delete(transportHash)
477
+ }
478
+ }
479
+
480
+ async reconcileTransportNow (transportHash) {
481
+ const query = this.queries.get(transportHash)
482
+ if (!query) return
483
+ while (true) {
484
+ const desiredMode = this.getDesiredTransportMode(transportHash)
485
+ const currentMode = query.activeTransportMode
486
+ if (desiredMode === currentMode) return
487
+ if (desiredMode === 'idle') {
488
+ if (currentMode === 'idle') return
489
+ await unsubscribeQueryTransport(query, { keepRoots: true })
490
+ continue
491
+ }
492
+ if (currentMode !== 'idle') {
493
+ await unsubscribeQueryTransport(query, { keepRoots: true })
494
+ continue
495
+ }
496
+ await subscribeQueryTransport(query, desiredMode)
497
+ }
498
+ }
499
+
500
+ getOwnerIntentCount (ownerKey, intent) {
501
+ const store = intent === 'fetch' ? this.ownerFetchCount : this.ownerSubscribeCount
502
+ return store.get(ownerKey) || 0
503
+ }
504
+
505
+ setOwnerIntentCount (ownerKey, intent, count) {
506
+ const store = intent === 'fetch' ? this.ownerFetchCount : this.ownerSubscribeCount
507
+ if (count > 0) store.set(ownerKey, count)
508
+ else store.delete(ownerKey)
509
+ }
510
+
511
+ incrementOwnerIntent (ownerKey, intent) {
512
+ this.setOwnerIntentCount(ownerKey, intent, this.getOwnerIntentCount(ownerKey, intent) + 1)
513
+ }
514
+
515
+ getDesiredTransportMode (transportHash) {
516
+ const ownerKeys = this.ownerKeysByTransport.get(transportHash)
517
+ if (!ownerKeys || ownerKeys.size === 0) return 'idle'
518
+ let hasFetchBackedOwner = false
519
+ for (const ownerKey of ownerKeys) {
520
+ const subscribeCount = this.ownerSubscribeCount.get(ownerKey) || 0
521
+ const fetchCount = this.ownerFetchCount.get(ownerKey) || 0
522
+ const rootId = this.ownerMeta.get(ownerKey)?.rootId
523
+ const subscribeMode = getRootTransportMode(rootId, 'subscribe')
524
+ if (subscribeCount > 0 && subscribeMode === 'subscribe') return 'subscribe'
525
+ if (fetchCount > 0 || (subscribeCount > 0 && subscribeMode === 'fetch')) {
526
+ hasFetchBackedOwner = true
527
+ }
528
+ }
529
+ return hasFetchBackedOwner ? 'fetch' : 'idle'
530
+ }
531
+
410
532
  async destroyByOwnerKey (ownerKey, options = {}) {
411
533
  const pendingDestroy = this.takePendingDestroy(ownerKey)
412
534
  if (pendingDestroy?.force) options.force = true
535
+ if (options.collectionName == null && pendingDestroy?.collectionName != null) {
536
+ options.collectionName = pendingDestroy.collectionName
537
+ }
538
+ if (options.params == null && pendingDestroy?.params != null) {
539
+ options.params = pendingDestroy.params
540
+ }
413
541
 
414
542
  const settlePending = err => {
415
543
  if (!pendingDestroy) return
@@ -425,43 +553,59 @@ export class QuerySubscriptions {
425
553
  }
426
554
  const meta = this.ownerMeta.get(ownerKey)
427
555
  if (!meta) {
556
+ const transportHash = options.collectionName && options.params
557
+ ? hashQuery(options.collectionName, options.params)
558
+ : this.ownerToTransport.get(ownerKey)
428
559
  this.subCount.delete(ownerKey)
560
+ this.ownerFetchCount.delete(ownerKey)
561
+ this.ownerSubscribeCount.delete(ownerKey)
562
+ this.ownerToTransport.delete(ownerKey)
563
+ if (!transportHash) {
564
+ settlePending()
565
+ return
566
+ }
567
+ const query = this.queries.get(transportHash)
568
+ await this.reconcileTransport(transportHash)
569
+ if ((this.transportSubCount.get(transportHash) || 0) <= 0) {
570
+ if (query?.activeTransportMode !== 'idle') await unsubscribeQueryTransport(query, { keepRoots: true })
571
+ query?._detachTransportData?.({ keepRoots: false })
572
+ this.transportSubCount.delete(transportHash)
573
+ this.ownerKeysByTransport.delete(transportHash)
574
+ this.queries.delete(transportHash)
575
+ }
429
576
  settlePending()
430
577
  return
431
578
  }
432
579
  const { transportHash, rootId } = meta
433
580
  const query = this.queries.get(transportHash)
434
- if (!query) {
435
- this.subCount.delete(ownerKey)
436
- this.removeOwnerMeta(ownerKey, transportHash)
437
- unregisterRootOwnedRuntime(rootId, this.runtimeKind, transportHash)
438
- const nextTransportCount = Math.max((this.transportSubCount.get(transportHash) || 0) - 1, 0)
439
- if (nextTransportCount > 0) this.transportSubCount.set(transportHash, nextTransportCount)
440
- else this.transportSubCount.delete(transportHash)
441
- settlePending()
442
- return
443
- }
581
+
444
582
  this.subCount.delete(ownerKey)
445
583
  this.removeOwnerMeta(ownerKey, transportHash)
446
584
  detachQueryRoot(query, rootId)
447
585
  unregisterRootOwnedRuntime(rootId, this.runtimeKind, transportHash)
448
586
 
449
587
  const nextTransportCount = Math.max((this.transportSubCount.get(transportHash) || 0) - 1, 0)
450
- this.transportSubCount.set(transportHash, nextTransportCount)
451
- if (nextTransportCount > 0) {
588
+ if (nextTransportCount > 0) this.transportSubCount.set(transportHash, nextTransportCount)
589
+ else this.transportSubCount.set(transportHash, 0)
590
+
591
+ await this.reconcileTransport(transportHash)
592
+ if ((this.transportSubCount.get(transportHash) || 0) > 0) {
452
593
  settlePending()
453
594
  return
454
595
  }
455
- await query.unsubscribe()
456
- if (query.subscribed) {
596
+ if (!query) {
597
+ this.transportSubCount.delete(transportHash)
457
598
  settlePending()
458
- return // if we subscribed again while waiting for unsubscribe, we don't delete the query
599
+ return
459
600
  }
601
+ if (query.activeTransportMode !== 'idle') await unsubscribeQueryTransport(query, { keepRoots: true })
602
+ query._detachTransportData({ keepRoots: false })
460
603
  if ((this.transportSubCount.get(transportHash) || 0) > 0) {
461
604
  settlePending()
462
605
  return
463
606
  }
464
607
  this.transportSubCount.delete(transportHash)
608
+ this.ownerKeysByTransport.delete(transportHash)
465
609
  this.queries.delete(transportHash)
466
610
  settlePending()
467
611
  } catch (err) {
@@ -625,8 +769,45 @@ function createPendingDestroyEntry () {
625
769
  return {
626
770
  timer: undefined,
627
771
  force: false,
772
+ collectionName: undefined,
773
+ params: undefined,
628
774
  promise,
629
775
  resolve: resolvePending,
630
776
  reject: rejectPending
631
777
  }
632
778
  }
779
+
780
+ async function subscribeQueryTransport (query, mode) {
781
+ query.requestedTransportMode = mode
782
+ if (typeof query._subscribe === 'function') {
783
+ query._subscribing = query._subscribe()
784
+ .then(() => {
785
+ query._subscribing = undefined
786
+ query.initialized = undefined
787
+ query.init?.()
788
+ }, err => {
789
+ query._subscribing = undefined
790
+ throw err
791
+ })
792
+ await query._subscribing
793
+ return
794
+ }
795
+ await query.subscribe({ mode })
796
+ if (query.activeTransportMode == null || query.activeTransportMode === 'idle') {
797
+ query.activeTransportMode = mode
798
+ }
799
+ if (query.initialized !== true) query.init?.()
800
+ }
801
+
802
+ async function unsubscribeQueryTransport (query, { keepRoots = true } = {}) {
803
+ if (query.initialized) {
804
+ query.initialized = undefined
805
+ query._detachTransportData?.({ keepRoots })
806
+ }
807
+ if (typeof query._unsubscribe === 'function') {
808
+ await query._unsubscribe()
809
+ return
810
+ }
811
+ await query.unsubscribe?.()
812
+ query.activeTransportMode = 'idle'
813
+ }
package/orm/Root.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import getSignal from './getSignal.js'
2
2
  import disposeRootContext from './disposeRootContext.js'
3
- import { reviveRootContext } from './rootContext.js'
3
+ import { getRootContext, reviveRootContext } from './rootContext.js'
4
4
  import { isGlobalRootId, normalizeRootId } from './rootScope.js'
5
5
  import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
6
6
 
@@ -22,11 +22,13 @@ const REGISTERED_ROOT_SIGNALS = new WeakSet()
22
22
  // TODO: create a separate local root for private collections
23
23
  export function getRootSignal ({
24
24
  rootFunction,
25
+ fetchOnly,
25
26
  // connection,
26
27
  rootId = '_' + createRandomString(8),
27
28
  ...options
28
29
  }) {
29
30
  reviveRootContext(rootId)
31
+ getRootContext(rootId, true, { fetchOnly })
30
32
  const $root = getSignal(undefined, [], {
31
33
  rootId,
32
34
  ...options
@@ -39,11 +41,28 @@ export function getRootSignal ({
39
41
  }
40
42
 
41
43
  export function getRoot (signal) {
44
+ if (!signal) return undefined
42
45
  if (signal[ROOT]) return signal[ROOT]
43
46
  else if (signal[ROOT_ID]) return signal
44
47
  else return undefined
45
48
  }
46
49
 
50
+ export function getRootFetchOnly (rootOrRootId) {
51
+ const $root = typeof rootOrRootId === 'string'
52
+ ? undefined
53
+ : (getRoot(rootOrRootId) || rootOrRootId)
54
+ const rootId = typeof rootOrRootId === 'string'
55
+ ? rootOrRootId
56
+ : $root?.[ROOT_ID]
57
+ const context = getRootContext(rootId, false)
58
+ return context?.getFetchOnly() ?? false
59
+ }
60
+
61
+ export function getRootTransportMode (rootOrRootId, intent = 'subscribe') {
62
+ if (intent === 'fetch') return 'fetch'
63
+ return getRootFetchOnly(rootOrRootId) ? 'fetch' : 'subscribe'
64
+ }
65
+
47
66
  export function registerRootFinalizer ($root) {
48
67
  if (!$root?.[ROOT_ID]) return
49
68
  if (REGISTERED_ROOT_SIGNALS.has($root)) return
package/orm/SignalBase.js CHANGED
@@ -34,7 +34,7 @@ import { docSubscriptions } from './Doc.js'
34
34
  import { IS_QUERY, HASH, QUERIES } from './Query.js'
35
35
  import { AGGREGATIONS, IS_AGGREGATION, getAggregationCollectionName, getAggregationDocId } from './Aggregation.js'
36
36
  import { ROOT_FUNCTION, ROOT_ID, getRoot } from './Root.js'
37
- import { publicOnly } from './connection.js'
37
+ import { isPrivateMutationForbidden } from './connection.js'
38
38
  import {
39
39
  DEFAULT_ID_FIELDS,
40
40
  getIdFieldsForSegments,
@@ -300,7 +300,7 @@ export class Signal extends Function {
300
300
  if (isPublicCollection(this[SEGMENTS][0])) {
301
301
  await _setPublicDoc(this[SEGMENTS], value)
302
302
  } else {
303
- if (publicOnly) throw Error(ERRORS.publicOnly)
303
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
304
304
  setPrivateData(getOwningRootId(this), this[SEGMENTS], value)
305
305
  }
306
306
  }
@@ -330,7 +330,7 @@ export class Signal extends Function {
330
330
  const idFields = getIdFieldsForSegments(segments)
331
331
  if (isIdFieldPath(segments, idFields)) return
332
332
  if (isPublicCollection(segments[0])) return _arrayPushPublic(segments, value)
333
- if (publicOnly) throw Error(ERRORS.publicOnly)
333
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
334
334
  return arrayPushPrivateData(getOwningRootId(this), segments, value)
335
335
  }
336
336
 
@@ -340,7 +340,7 @@ export class Signal extends Function {
340
340
  const idFields = getIdFieldsForSegments(segments)
341
341
  if (isIdFieldPath(segments, idFields)) return
342
342
  if (isPublicCollection(segments[0])) return _arrayPopPublic(segments)
343
- if (publicOnly) throw Error(ERRORS.publicOnly)
343
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
344
344
  return arrayPopPrivateData(getOwningRootId(this), segments)
345
345
  }
346
346
 
@@ -350,7 +350,7 @@ export class Signal extends Function {
350
350
  const idFields = getIdFieldsForSegments(segments)
351
351
  if (isIdFieldPath(segments, idFields)) return
352
352
  if (isPublicCollection(segments[0])) return _arrayUnshiftPublic(segments, value)
353
- if (publicOnly) throw Error(ERRORS.publicOnly)
353
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
354
354
  return arrayUnshiftPrivateData(getOwningRootId(this), segments, value)
355
355
  }
356
356
 
@@ -360,7 +360,7 @@ export class Signal extends Function {
360
360
  const idFields = getIdFieldsForSegments(segments)
361
361
  if (isIdFieldPath(segments, idFields)) return
362
362
  if (isPublicCollection(segments[0])) return _arrayShiftPublic(segments)
363
- if (publicOnly) throw Error(ERRORS.publicOnly)
363
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
364
364
  return arrayShiftPrivateData(getOwningRootId(this), segments)
365
365
  }
366
366
 
@@ -374,7 +374,7 @@ export class Signal extends Function {
374
374
  const idFields = getIdFieldsForSegments(segments)
375
375
  if (isIdFieldPath(segments, idFields)) return
376
376
  if (isPublicCollection(segments[0])) return _arrayInsertPublic(segments, index, values)
377
- if (publicOnly) throw Error(ERRORS.publicOnly)
377
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
378
378
  return arrayInsertPrivateData(getOwningRootId(this), segments, index, values)
379
379
  }
380
380
 
@@ -388,7 +388,7 @@ export class Signal extends Function {
388
388
  const idFields = getIdFieldsForSegments(segments)
389
389
  if (isIdFieldPath(segments, idFields)) return
390
390
  if (isPublicCollection(segments[0])) return _arrayRemovePublic(segments, index, howMany)
391
- if (publicOnly) throw Error(ERRORS.publicOnly)
391
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
392
392
  return arrayRemovePrivateData(getOwningRootId(this), segments, index, howMany)
393
393
  }
394
394
 
@@ -402,7 +402,7 @@ export class Signal extends Function {
402
402
  const idFields = getIdFieldsForSegments(segments)
403
403
  if (isIdFieldPath(segments, idFields)) return
404
404
  if (isPublicCollection(segments[0])) return _arrayMovePublic(segments, from, to, howMany)
405
- if (publicOnly) throw Error(ERRORS.publicOnly)
405
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
406
406
  return arrayMovePrivateData(getOwningRootId(this), segments, from, to, howMany)
407
407
  }
408
408
 
@@ -416,7 +416,7 @@ export class Signal extends Function {
416
416
  const idFields = getIdFieldsForSegments(segments)
417
417
  if (isIdFieldPath(segments, idFields)) return
418
418
  if (isPublicCollection(segments[0])) return _stringInsertPublic(segments, index, text)
419
- if (publicOnly) throw Error(ERRORS.publicOnly)
419
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
420
420
  return stringInsertPrivateData(getOwningRootId(this), segments, index, text)
421
421
  }
422
422
 
@@ -430,7 +430,7 @@ export class Signal extends Function {
430
430
  const idFields = getIdFieldsForSegments(segments)
431
431
  if (isIdFieldPath(segments, idFields)) return
432
432
  if (isPublicCollection(segments[0])) return _stringRemovePublic(segments, index, howMany)
433
- if (publicOnly) throw Error(ERRORS.publicOnly)
433
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
434
434
  return stringRemovePrivateData(getOwningRootId(this), segments, index, howMany)
435
435
  }
436
436
 
@@ -449,7 +449,7 @@ export class Signal extends Function {
449
449
  await _incrementPublic(segments, value)
450
450
  return currentValue + value
451
451
  }
452
- if (publicOnly) throw Error(ERRORS.publicOnly)
452
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
453
453
  setReplacePrivateData(getOwningRootId(this), segments, currentValue + value)
454
454
  return currentValue + value
455
455
  }
@@ -471,7 +471,7 @@ export class Signal extends Function {
471
471
  if (this[SEGMENTS].length === 1) throw Error('Can\'t delete the whole collection')
472
472
  await _setPublicDoc(this[SEGMENTS], undefined, true)
473
473
  } else {
474
- if (publicOnly) throw Error(ERRORS.publicOnly)
474
+ if (isPrivateMutationForbidden()) throw Error(ERRORS.publicOnly)
475
475
  delPrivateData(getOwningRootId(this), this[SEGMENTS])
476
476
  }
477
477
  }
package/orm/connection.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { isCompatEnv } from './compatEnv.js'
2
+
1
3
  export let connection
2
- export let fetchOnly
4
+ let defaultFetchOnly
3
5
  export let publicOnly
4
6
 
5
7
  export function setConnection (_connection) {
@@ -11,14 +13,27 @@ export function getConnection () {
11
13
  return connection
12
14
  }
13
15
 
16
+ export function setDefaultFetchOnly (_fetchOnly) {
17
+ defaultFetchOnly = !!_fetchOnly
18
+ }
19
+
20
+ export function getDefaultFetchOnly () {
21
+ return !!defaultFetchOnly
22
+ }
23
+
24
+ // Deprecated alias kept for internal transition.
14
25
  export function setFetchOnly (_fetchOnly) {
15
- fetchOnly = _fetchOnly
26
+ setDefaultFetchOnly(_fetchOnly)
16
27
  }
17
28
 
18
29
  export function setPublicOnly (_publicOnly) {
19
30
  publicOnly = _publicOnly
20
31
  }
21
32
 
33
+ export function isPrivateMutationForbidden () {
34
+ return !!publicOnly && !isCompatEnv()
35
+ }
36
+
22
37
  const ERRORS = {
23
38
  notSet: `
24
39
  Connection is not set.
@@ -1,5 +1,6 @@
1
1
  import { observable } from '@nx-js/observer-util'
2
2
  import { normalizeRootId } from './rootScope.js'
3
+ import { getDefaultFetchOnly } from './connection.js'
3
4
 
4
5
  const ROOT_CONTEXTS = new Map()
5
6
  const CLOSED_ROOT_CONTEXTS = new Set()
@@ -9,8 +10,9 @@ const RUNTIME_KIND_QUERY = 'query'
9
10
  const RUNTIME_KIND_AGGREGATION = 'aggregation'
10
11
 
11
12
  export default class RootContext {
12
- constructor (rootId) {
13
+ constructor (rootId, { fetchOnly } = {}) {
13
14
  this.rootId = normalizeRootId(rootId)
15
+ this.fetchOnly = fetchOnly == null ? getDefaultFetchOnly() : !!fetchOnly
14
16
  this.privateDataRaw = {}
15
17
  this.privateData = observable(this.privateDataRaw)
16
18
  this.refLinks = new Map()
@@ -34,6 +36,14 @@ export default class RootContext {
34
36
  return store
35
37
  }
36
38
 
39
+ getFetchOnly () {
40
+ return !!this.fetchOnly
41
+ }
42
+
43
+ setFetchOnly (value) {
44
+ this.fetchOnly = !!value
45
+ }
46
+
37
47
  getPrivateDataRoot () {
38
48
  return this.privateData
39
49
  }
@@ -164,12 +174,12 @@ export default class RootContext {
164
174
  }
165
175
  }
166
176
 
167
- export function getRootContext (rootId, create = true) {
177
+ export function getRootContext (rootId, create = true, options = {}) {
168
178
  const normalizedRootId = normalizeRootId(rootId)
169
179
  if (create && CLOSED_ROOT_CONTEXTS.has(normalizedRootId)) return undefined
170
180
  let context = ROOT_CONTEXTS.get(normalizedRootId)
171
181
  if (!context && create) {
172
- context = new RootContext(normalizedRootId)
182
+ context = new RootContext(normalizedRootId, options)
173
183
  ROOT_CONTEXTS.set(normalizedRootId, context)
174
184
  }
175
185
  return context
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamplay",
3
- "version": "0.4.0-alpha.81",
3
+ "version": "0.4.0-alpha.83",
4
4
  "description": "Full-stack signals ORM with multiplayer",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -83,5 +83,5 @@
83
83
  ]
84
84
  },
85
85
  "license": "MIT",
86
- "gitHead": "5e534aeafebd1147d3fb9939a3017a2a4a70a0b8"
86
+ "gitHead": "cf9710fd110129aafbfc8f0e7260ec3186795cb2"
87
87
  }
package/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import createChannel from '@teamplay/channel/server'
2
- import { connection, setConnection, setFetchOnly, setPublicOnly } from './orm/connection.js'
2
+ import { connection, setConnection, setDefaultFetchOnly, setPublicOnly } from './orm/connection.js'
3
3
 
4
4
  export { default as ShareDB } from 'sharedb'
5
5
  export {
@@ -25,7 +25,7 @@ export function initConnection (backend, {
25
25
  if (!backend) throw Error('backend is required')
26
26
  if (connection) throw Error('Connection already exists')
27
27
  setConnection(backend.connect())
28
- setFetchOnly(fetchOnly)
28
+ setDefaultFetchOnly(fetchOnly)
29
29
  setPublicOnly(publicOnly)
30
30
  return createChannel(backend, options)
31
31
  }