teamplay 0.4.0-alpha.32 → 0.4.0-alpha.34
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.d.ts +2 -0
- package/index.js +1 -0
- package/orm/Compat/README.md +44 -14
- package/orm/Compat/hooksCompat.js +89 -1
- package/orm/Doc.js +83 -7
- package/orm/Query.js +83 -9
- package/orm/subscriptionGcDelay.js +32 -0
- package/package.json +2 -2
- package/react/promiseBatcher.js +95 -2
package/index.d.ts
CHANGED
|
@@ -94,6 +94,8 @@ export function useDidUpdate (fn: () => EffectCleanup, deps?: any[]): void
|
|
|
94
94
|
export function useOnce (condition: any, fn: () => EffectCleanup): void
|
|
95
95
|
export function useSyncEffect (fn: () => EffectCleanup, deps?: any[]): void
|
|
96
96
|
export { connection, setConnection, getConnection, fetchOnly, setFetchOnly, publicOnly, setPublicOnly } from './orm/connection.js'
|
|
97
|
+
export function getSubscriptionGcDelay (): number
|
|
98
|
+
export function setSubscriptionGcDelay (ms?: number | null): number
|
|
97
99
|
export { useId, useNow, useScheduleUpdate, useTriggerUpdate } from './react/helpers.js'
|
|
98
100
|
export { GUID_PATTERN, hasMany, hasOne, hasManyFlags, belongsTo, pickFormFields } from '@teamplay/schema'
|
|
99
101
|
export { aggregation, aggregationHeader as __aggregationHeader } from '@teamplay/utils/aggregation'
|
package/index.js
CHANGED
|
@@ -66,6 +66,7 @@ export {
|
|
|
66
66
|
useSyncEffect
|
|
67
67
|
} from './react/helpers.js'
|
|
68
68
|
export { connection, setConnection, getConnection, fetchOnly, setFetchOnly, publicOnly, setPublicOnly } from './orm/connection.js'
|
|
69
|
+
export { getSubscriptionGcDelay, setSubscriptionGcDelay } from './orm/subscriptionGcDelay.js'
|
|
69
70
|
export { useId, useNow, useScheduleUpdate, useTriggerUpdate } from './react/helpers.js'
|
|
70
71
|
export { GUID_PATTERN, hasMany, hasOne, hasManyFlags, belongsTo, pickFormFields } from '@teamplay/schema'
|
|
71
72
|
export { aggregation, aggregationHeader as __aggregationHeader } from '@teamplay/utils/aggregation'
|
package/orm/Compat/README.md
CHANGED
|
@@ -370,6 +370,25 @@ Compatibility mode intentionally aligns mutators with Racer. This differs from c
|
|
|
370
370
|
Migration note: compat behavior is intentionally Racer-aligned and may differ from core mutators.
|
|
371
371
|
Composite compat mutators (`setEach`, `setDiffDeep`) apply updates atomically for Teamplay-scheduled observers via the runtime batch scheduler.
|
|
372
372
|
|
|
373
|
+
### Subscription GC Delay (Compat)
|
|
374
|
+
|
|
375
|
+
To reduce UI blink on rapid `unsub -> sub` cycles, compat uses an unload grace period for docs/queries.
|
|
376
|
+
|
|
377
|
+
- Default in compat: `300ms`
|
|
378
|
+
- Default in non-compat: `0ms` (immediate cleanup)
|
|
379
|
+
|
|
380
|
+
You can tune it globally:
|
|
381
|
+
|
|
382
|
+
```js
|
|
383
|
+
import { getSubscriptionGcDelay, setSubscriptionGcDelay } from 'teamplay'
|
|
384
|
+
|
|
385
|
+
setSubscriptionGcDelay(500)
|
|
386
|
+
console.log(getSubscriptionGcDelay()) // 500
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
When refCount drops to `0`, unsubscribe/destroy is scheduled after this delay.
|
|
390
|
+
If a new subscribe arrives before timeout, pending destroy is cancelled and the same doc/query instance is reused.
|
|
391
|
+
|
|
373
392
|
### set(value) and set(path, value)
|
|
374
393
|
|
|
375
394
|
`SignalCompat` accepts both:
|
|
@@ -571,7 +590,8 @@ General notes:
|
|
|
571
590
|
- Hooks should be used inside `observer()` components to get reactive updates.
|
|
572
591
|
- Sync hooks (`useDoc`, `useQuery`) use Suspense by default (via `useSub`).
|
|
573
592
|
- Async hooks (`useAsyncDoc`, `useAsyncQuery`) never throw; they return `undefined` until ready.
|
|
574
|
-
- Batch hooks
|
|
593
|
+
- Batch hooks use a Suspense batch barrier (`useBatch`) and wait for both
|
|
594
|
+
subscribe promises and DataTree materialization readiness.
|
|
575
595
|
|
|
576
596
|
### Events
|
|
577
597
|
|
|
@@ -744,10 +764,12 @@ if (!user) return 'Loading...'
|
|
|
744
764
|
|
|
745
765
|
Returns `undefined` until subscription resolves.
|
|
746
766
|
|
|
747
|
-
#### Batch
|
|
767
|
+
#### Batch variants
|
|
748
768
|
|
|
749
|
-
`useBatchDoc` / `useBatchDoc$`
|
|
750
|
-
|
|
769
|
+
`useBatchDoc` / `useBatchDoc$` participate in batch Suspense flow:
|
|
770
|
+
- they register subscribe promises for `useBatch()`;
|
|
771
|
+
- they also register a **materialization readiness check**:
|
|
772
|
+
doc is considered ready only when it is visible in DataTree (or explicitly missing).
|
|
751
773
|
|
|
752
774
|
### Query Hooks
|
|
753
775
|
|
|
@@ -784,9 +806,13 @@ if (!users) return 'Loading...'
|
|
|
784
806
|
|
|
785
807
|
Async variant: no Suspense, returns `undefined` until ready.
|
|
786
808
|
|
|
787
|
-
#### Batch
|
|
809
|
+
#### Batch variants
|
|
788
810
|
|
|
789
|
-
`useBatchQuery` / `useBatchQuery$`
|
|
811
|
+
`useBatchQuery` / `useBatchQuery$` participate in batch Suspense flow:
|
|
812
|
+
- they register subscribe promises for `useBatch()`;
|
|
813
|
+
- they register a **query readiness check**:
|
|
814
|
+
query ids must be materialized in DataTree, and each `collection.id` from ids must
|
|
815
|
+
be visible in DataTree (or explicitly missing).
|
|
790
816
|
|
|
791
817
|
### Query Helpers
|
|
792
818
|
|
|
@@ -800,7 +826,7 @@ const [users] = useQueryIds('users', ['b', 'a'])
|
|
|
800
826
|
Options:
|
|
801
827
|
- `reverse: true` — reverse order of IDs before mapping.
|
|
802
828
|
|
|
803
|
-
`useBatchQueryIds` and `useAsyncQueryIds` are
|
|
829
|
+
`useBatchQueryIds` and `useAsyncQueryIds` are batch/async variants.
|
|
804
830
|
|
|
805
831
|
#### `useQueryDoc`
|
|
806
832
|
|
|
@@ -815,16 +841,20 @@ Implementation details:
|
|
|
815
841
|
- Adds default `$sort: { createdAt: -1 }` if `$sort` is missing
|
|
816
842
|
|
|
817
843
|
`useQueryDoc$` returns only the doc signal (or `undefined`).
|
|
818
|
-
`useBatchQueryDoc` / `useAsyncQueryDoc` are
|
|
844
|
+
`useBatchQueryDoc` / `useAsyncQueryDoc` are batch/async variants.
|
|
819
845
|
|
|
820
|
-
###
|
|
846
|
+
### Batch Barrier
|
|
821
847
|
|
|
822
|
-
`useBatch()` is a
|
|
823
|
-
All batch hooks are **aliases** to their non-batch versions.
|
|
848
|
+
`useBatch()` is a Suspense barrier for batch hooks.
|
|
824
849
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
850
|
+
It throws while:
|
|
851
|
+
- batch subscribe promises are pending;
|
|
852
|
+
- or subscribe promises are resolved but requested docs/queries are not yet
|
|
853
|
+
materialized in DataTree.
|
|
854
|
+
|
|
855
|
+
After `useBatch()` stops throwing in compat mode, immediate reads via
|
|
856
|
+
`useLocal(...).get(...)` for already requested batch entities should not produce
|
|
857
|
+
transient `undefined` caused by materialization races.
|
|
828
858
|
|
|
829
859
|
## Examples
|
|
830
860
|
|
|
@@ -2,6 +2,10 @@ 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
4
|
import * as promiseBatcher from '../../react/promiseBatcher.js'
|
|
5
|
+
import { getRaw } from '../dataTree.js'
|
|
6
|
+
import { getConnection } from '../connection.js'
|
|
7
|
+
import { isCompatEnv } from '../compatEnv.js'
|
|
8
|
+
import { hashQuery, QUERIES } from '../Query.js'
|
|
5
9
|
|
|
6
10
|
const $root = getRootSignal({ rootId: GLOBAL_ROOT_ID, rootFunction: universal$ })
|
|
7
11
|
|
|
@@ -97,6 +101,7 @@ export function useBatchDoc (collection, id, options) {
|
|
|
97
101
|
|
|
98
102
|
export function useBatchDoc$ (collection, id, _options) {
|
|
99
103
|
const $doc = getDocSignal(collection, id, 'useBatchDoc')
|
|
104
|
+
registerBatchDocReadinessCheck(collection, getDocIdFromSignal($doc))
|
|
100
105
|
const options = _options ? { ..._options, ...BATCH_SUB_OPTIONS } : BATCH_SUB_OPTIONS
|
|
101
106
|
return useSub($doc, undefined, options)
|
|
102
107
|
}
|
|
@@ -140,9 +145,11 @@ export function useAsyncQuery (collection, query, options) {
|
|
|
140
145
|
}
|
|
141
146
|
|
|
142
147
|
export function useBatchQuery$ (collection, query, _options) {
|
|
148
|
+
const normalizedQuery = normalizeQuery(query, 'useBatchQuery')
|
|
143
149
|
const $collection = getCollectionSignal(collection, query, 'useBatchQuery')
|
|
150
|
+
registerBatchQueryReadinessCheck(collection, normalizedQuery)
|
|
144
151
|
const options = _options ? { ..._options, ...BATCH_SUB_OPTIONS } : BATCH_SUB_OPTIONS
|
|
145
|
-
return useSub($collection,
|
|
152
|
+
return useSub($collection, normalizedQuery, options)
|
|
146
153
|
}
|
|
147
154
|
|
|
148
155
|
export function useBatchQuery (collection, query, options) {
|
|
@@ -316,3 +323,84 @@ const BATCH_SUB_OPTIONS = Object.freeze({
|
|
|
316
323
|
// on route transitions and cause immediate reads from stale/empty local nodes.
|
|
317
324
|
defer: false
|
|
318
325
|
})
|
|
326
|
+
|
|
327
|
+
function getDocIdFromSignal ($doc) {
|
|
328
|
+
const path = typeof $doc?.path === 'function' ? $doc.path() : ''
|
|
329
|
+
const segments = path ? path.split('.').filter(Boolean) : []
|
|
330
|
+
return segments[segments.length - 1]
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function registerBatchDocReadinessCheck (collection, id) {
|
|
334
|
+
if (!isCompatEnv()) return
|
|
335
|
+
if (!collection || id == null) return
|
|
336
|
+
const docSegments = [collection, id]
|
|
337
|
+
promiseBatcher.addCheck({
|
|
338
|
+
key: `doc:${collection}.${id}`,
|
|
339
|
+
type: 'doc',
|
|
340
|
+
details: `${collection}.${id}`,
|
|
341
|
+
isReady: () => isDocReady(docSegments),
|
|
342
|
+
getState: () => {
|
|
343
|
+
const shareDoc = getShareDoc(collection, id)
|
|
344
|
+
return {
|
|
345
|
+
raw: getRaw(docSegments),
|
|
346
|
+
shareDoc: shareDoc
|
|
347
|
+
? {
|
|
348
|
+
type: shareDoc.type,
|
|
349
|
+
data: shareDoc.data
|
|
350
|
+
}
|
|
351
|
+
: undefined
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function registerBatchQueryReadinessCheck (collection, query) {
|
|
358
|
+
if (!isCompatEnv()) return
|
|
359
|
+
if (!collection || !query || typeof query !== 'object') return
|
|
360
|
+
const hash = hashQuery(collection, query)
|
|
361
|
+
const idsSegments = [QUERIES, hash, 'ids']
|
|
362
|
+
promiseBatcher.addCheck({
|
|
363
|
+
key: `query:${hash}`,
|
|
364
|
+
type: 'query',
|
|
365
|
+
details: { collection, hash, query },
|
|
366
|
+
isReady: () => isQueryReady(collection, idsSegments),
|
|
367
|
+
getState: () => {
|
|
368
|
+
const ids = getRaw(idsSegments)
|
|
369
|
+
return {
|
|
370
|
+
ids,
|
|
371
|
+
docs: Array.isArray(ids)
|
|
372
|
+
? ids.map(id => ({
|
|
373
|
+
id,
|
|
374
|
+
raw: getRaw([collection, id])
|
|
375
|
+
}))
|
|
376
|
+
: ids
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isQueryReady (collection, idsSegments) {
|
|
383
|
+
const ids = getRaw(idsSegments)
|
|
384
|
+
if (!Array.isArray(ids)) return false
|
|
385
|
+
for (const id of ids) {
|
|
386
|
+
if (!isDocReady([collection, id])) return false
|
|
387
|
+
}
|
|
388
|
+
return true
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isDocReady (segments) {
|
|
392
|
+
const rawDoc = getRaw(segments)
|
|
393
|
+
if (rawDoc !== undefined) return true
|
|
394
|
+
const [collection, id] = segments
|
|
395
|
+
const shareDoc = getShareDoc(collection, id)
|
|
396
|
+
// Missing docs should not block the batch barrier forever.
|
|
397
|
+
return !!(shareDoc && shareDoc.type === null && shareDoc.data == null)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function getShareDoc (collection, id) {
|
|
401
|
+
try {
|
|
402
|
+
return getConnection().get(collection, id)
|
|
403
|
+
} catch {
|
|
404
|
+
return undefined
|
|
405
|
+
}
|
|
406
|
+
}
|
package/orm/Doc.js
CHANGED
|
@@ -6,6 +6,7 @@ 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
|
+
import { getSubscriptionGcDelay } from './subscriptionGcDelay.js'
|
|
9
10
|
|
|
10
11
|
const ERROR_ON_EXCESSIVE_UNSUBSCRIBES = false
|
|
11
12
|
|
|
@@ -103,11 +104,13 @@ class Doc {
|
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
class DocSubscriptions {
|
|
107
|
-
constructor () {
|
|
107
|
+
export class DocSubscriptions {
|
|
108
|
+
constructor (DocClass = Doc) {
|
|
109
|
+
this.DocClass = DocClass
|
|
108
110
|
this.subCount = new Map()
|
|
109
111
|
this.docs = new Map()
|
|
110
|
-
this.
|
|
112
|
+
this.pendingDestroyTimers = new Map()
|
|
113
|
+
this.fr = new FinalizationRegistry(segments => this.scheduleDestroy(segments, { force: true }))
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
init ($doc) {
|
|
@@ -118,7 +121,7 @@ class DocSubscriptions {
|
|
|
118
121
|
if (doc.initialized) return
|
|
119
122
|
doc.init()
|
|
120
123
|
} else {
|
|
121
|
-
doc = new
|
|
124
|
+
doc = new this.DocClass(...segments)
|
|
122
125
|
this.docs.set(hash, doc)
|
|
123
126
|
this.fr.register($doc, segments, $doc)
|
|
124
127
|
doc.init()
|
|
@@ -128,10 +131,17 @@ class DocSubscriptions {
|
|
|
128
131
|
subscribe ($doc) {
|
|
129
132
|
const segments = [...$doc[SEGMENTS]]
|
|
130
133
|
const hash = hashDoc(segments)
|
|
134
|
+
this.cancelDestroy(hash)
|
|
131
135
|
let count = this.subCount.get(hash) || 0
|
|
132
136
|
count += 1
|
|
133
137
|
this.subCount.set(hash, count)
|
|
134
|
-
if (count > 1)
|
|
138
|
+
if (count > 1) {
|
|
139
|
+
const existingDoc = this.docs.get(hash)
|
|
140
|
+
if (existingDoc) return existingDoc._subscribing
|
|
141
|
+
// Recover from stale ref-count state when doc entry was already cleaned up.
|
|
142
|
+
count = 1
|
|
143
|
+
this.subCount.set(hash, count)
|
|
144
|
+
}
|
|
135
145
|
|
|
136
146
|
this.init($doc)
|
|
137
147
|
const doc = this.docs.get(hash)
|
|
@@ -152,19 +162,83 @@ class DocSubscriptions {
|
|
|
152
162
|
this.subCount.set(hash, count)
|
|
153
163
|
return
|
|
154
164
|
}
|
|
165
|
+
this.subCount.set(hash, 0)
|
|
155
166
|
this.fr.unregister($doc)
|
|
156
|
-
await this.
|
|
167
|
+
await this.scheduleDestroy(segments)
|
|
157
168
|
}
|
|
158
169
|
|
|
159
170
|
async destroy (segments) {
|
|
160
171
|
const hash = hashDoc(segments)
|
|
172
|
+
await this.destroyByHash(hash, { force: true })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async clear () {
|
|
176
|
+
for (const entry of this.pendingDestroyTimers.values()) {
|
|
177
|
+
clearTimeout(entry.timer)
|
|
178
|
+
}
|
|
179
|
+
this.pendingDestroyTimers.clear()
|
|
180
|
+
const hashes = Array.from(this.docs.keys())
|
|
181
|
+
for (const hash of hashes) {
|
|
182
|
+
await this.destroyByHash(hash, { force: true })
|
|
183
|
+
}
|
|
184
|
+
this.subCount.clear()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async flushPendingDestroys () {
|
|
188
|
+
const entries = Array.from(this.pendingDestroyTimers.entries())
|
|
189
|
+
for (const [hash, entry] of entries) {
|
|
190
|
+
clearTimeout(entry.timer)
|
|
191
|
+
this.pendingDestroyTimers.delete(hash)
|
|
192
|
+
await this.destroyByHash(hash, { force: entry.force })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async scheduleDestroy (segments, options = {}) {
|
|
197
|
+
const hash = hashDoc(segments)
|
|
198
|
+
const delay = getSubscriptionGcDelay()
|
|
199
|
+
if (delay <= 0) {
|
|
200
|
+
await this.destroyByHash(hash, options)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
const existing = this.pendingDestroyTimers.get(hash)
|
|
204
|
+
if (existing) {
|
|
205
|
+
if (options.force) existing.force = true
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
const timer = setTimeout(() => {
|
|
209
|
+
const entry = this.pendingDestroyTimers.get(hash)
|
|
210
|
+
if (!entry) return
|
|
211
|
+
this.pendingDestroyTimers.delete(hash)
|
|
212
|
+
this.destroyByHash(hash, { force: entry.force }).catch(ignoreDestroyError)
|
|
213
|
+
}, delay)
|
|
214
|
+
this.pendingDestroyTimers.set(hash, {
|
|
215
|
+
timer,
|
|
216
|
+
force: !!options.force
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
cancelDestroy (hash) {
|
|
221
|
+
const entry = this.pendingDestroyTimers.get(hash)
|
|
222
|
+
if (!entry) return
|
|
223
|
+
clearTimeout(entry.timer)
|
|
224
|
+
this.pendingDestroyTimers.delete(hash)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async destroyByHash (hash, options = {}) {
|
|
228
|
+
this.cancelDestroy(hash)
|
|
229
|
+
const count = this.subCount.get(hash) || 0
|
|
230
|
+
if (!options.force && count > 0) return
|
|
161
231
|
const doc = this.docs.get(hash)
|
|
162
|
-
if (!doc)
|
|
232
|
+
if (!doc) {
|
|
233
|
+
this.subCount.delete(hash)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
163
236
|
this.subCount.delete(hash)
|
|
164
237
|
// Always call unsubscribe() - if doc is in SUBSCRIBING state, the state machine
|
|
165
238
|
// will queue a pending unsubscribe to execute after subscribe completes
|
|
166
239
|
await doc.unsubscribe()
|
|
167
240
|
if (doc.subscribed) return // Subscribed again while unsubscribing
|
|
241
|
+
if ((this.subCount.get(hash) || 0) > 0) return
|
|
168
242
|
this.docs.delete(hash)
|
|
169
243
|
}
|
|
170
244
|
}
|
|
@@ -175,6 +249,8 @@ function hashDoc (segments) {
|
|
|
175
249
|
return JSON.stringify(segments)
|
|
176
250
|
}
|
|
177
251
|
|
|
252
|
+
function ignoreDestroyError () {}
|
|
253
|
+
|
|
178
254
|
function emitDocOp (collection, docId, op) {
|
|
179
255
|
if (!isModelEventsEnabled()) return
|
|
180
256
|
const ops = Array.isArray(op) ? op : [op]
|
package/orm/Query.js
CHANGED
|
@@ -8,6 +8,7 @@ import { docSubscriptions } from './Doc.js'
|
|
|
8
8
|
import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
|
|
9
9
|
import SubscriptionState from './SubscriptionState.js'
|
|
10
10
|
import { getIdFieldsForSegments, injectIdFields, isPlainObject } from './idFields.js'
|
|
11
|
+
import { getSubscriptionGcDelay } from './subscriptionGcDelay.js'
|
|
11
12
|
|
|
12
13
|
const ERROR_ON_EXCESSIVE_UNSUBSCRIBES = false
|
|
13
14
|
export const COLLECTION_NAME = Symbol('query collection name')
|
|
@@ -211,13 +212,17 @@ export class QuerySubscriptions {
|
|
|
211
212
|
this.QueryClass = QueryClass
|
|
212
213
|
this.subCount = new Map()
|
|
213
214
|
this.queries = new Map()
|
|
214
|
-
this.
|
|
215
|
+
this.pendingDestroyTimers = new Map()
|
|
216
|
+
this.fr = new FinalizationRegistry(({ collectionName, params }) => {
|
|
217
|
+
this.scheduleDestroy(collectionName, params, undefined, { force: true })
|
|
218
|
+
})
|
|
215
219
|
}
|
|
216
220
|
|
|
217
221
|
subscribe ($query) {
|
|
218
222
|
const collectionName = $query[COLLECTION_NAME]
|
|
219
223
|
const params = JSON.parse(JSON.stringify($query[PARAMS]))
|
|
220
224
|
const hash = $query[HASH]
|
|
225
|
+
this.cancelDestroy(hash)
|
|
221
226
|
let count = this.subCount.get(hash) || 0
|
|
222
227
|
count += 1
|
|
223
228
|
this.subCount.set(hash, count)
|
|
@@ -252,22 +257,89 @@ export class QuerySubscriptions {
|
|
|
252
257
|
this.subCount.set(hash, count)
|
|
253
258
|
return
|
|
254
259
|
}
|
|
255
|
-
this.subCount.
|
|
260
|
+
this.subCount.set(hash, 0)
|
|
256
261
|
this.fr.unregister($query)
|
|
257
|
-
|
|
258
|
-
if (!query) return
|
|
259
|
-
await query.unsubscribe()
|
|
260
|
-
if (query.subscribed) return // if we subscribed again while waiting for unsubscribe, we don't delete the doc
|
|
261
|
-
this.queries.delete(hash)
|
|
262
|
+
await this.scheduleDestroy($query[COLLECTION_NAME], $query[PARAMS], hash)
|
|
262
263
|
}
|
|
263
264
|
|
|
264
|
-
async destroy (collectionName, params) {
|
|
265
|
+
async destroy (collectionName, params, options = {}) {
|
|
265
266
|
const hash = hashQuery(collectionName, params)
|
|
267
|
+
await this.destroyByHash(hash, {
|
|
268
|
+
collectionName,
|
|
269
|
+
params,
|
|
270
|
+
force: options.force ?? true
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async clear () {
|
|
275
|
+
for (const entry of this.pendingDestroyTimers.values()) {
|
|
276
|
+
clearTimeout(entry.timer)
|
|
277
|
+
}
|
|
278
|
+
this.pendingDestroyTimers.clear()
|
|
279
|
+
const hashes = Array.from(this.queries.keys())
|
|
280
|
+
for (const hash of hashes) {
|
|
281
|
+
const { collectionName, params } = parseQueryHash(hash)
|
|
282
|
+
await this.destroyByHash(hash, {
|
|
283
|
+
collectionName,
|
|
284
|
+
params,
|
|
285
|
+
force: true
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
this.subCount.clear()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async flushPendingDestroys () {
|
|
292
|
+
const entries = Array.from(this.pendingDestroyTimers.entries())
|
|
293
|
+
for (const [hash, entry] of entries) {
|
|
294
|
+
clearTimeout(entry.timer)
|
|
295
|
+
this.pendingDestroyTimers.delete(hash)
|
|
296
|
+
await this.destroyByHash(hash, { force: entry.force })
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async scheduleDestroy (collectionName, params, hash = hashQuery(collectionName, params), options = {}) {
|
|
301
|
+
const delay = getSubscriptionGcDelay()
|
|
302
|
+
if (delay <= 0) {
|
|
303
|
+
await this.destroyByHash(hash, { collectionName, params, force: !!options.force })
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
const existing = this.pendingDestroyTimers.get(hash)
|
|
307
|
+
if (existing) {
|
|
308
|
+
if (options.force) existing.force = true
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
const timer = setTimeout(() => {
|
|
312
|
+
const entry = this.pendingDestroyTimers.get(hash)
|
|
313
|
+
if (!entry) return
|
|
314
|
+
this.pendingDestroyTimers.delete(hash)
|
|
315
|
+
this.destroyByHash(hash, { collectionName, params, force: entry.force }).catch(ignoreDestroyError)
|
|
316
|
+
}, delay)
|
|
317
|
+
this.pendingDestroyTimers.set(hash, {
|
|
318
|
+
timer,
|
|
319
|
+
force: !!options.force
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
cancelDestroy (hash) {
|
|
324
|
+
const entry = this.pendingDestroyTimers.get(hash)
|
|
325
|
+
if (!entry) return
|
|
326
|
+
clearTimeout(entry.timer)
|
|
327
|
+
this.pendingDestroyTimers.delete(hash)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async destroyByHash (hash, options = {}) {
|
|
331
|
+
this.cancelDestroy(hash)
|
|
332
|
+
const count = this.subCount.get(hash) || 0
|
|
333
|
+
if (!options.force && count > 0) return
|
|
266
334
|
const query = this.queries.get(hash)
|
|
267
|
-
if (!query)
|
|
335
|
+
if (!query) {
|
|
336
|
+
this.subCount.delete(hash)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
268
339
|
this.subCount.delete(hash)
|
|
269
340
|
await query.unsubscribe()
|
|
270
341
|
if (query.subscribed) return // if we subscribed again while waiting for unsubscribe, we don't delete the doc
|
|
342
|
+
if ((this.subCount.get(hash) || 0) > 0) return
|
|
271
343
|
this.queries.delete(hash)
|
|
272
344
|
}
|
|
273
345
|
}
|
|
@@ -322,3 +394,5 @@ const ERRORS = {
|
|
|
322
394
|
Params: ${$query[PARAMS]}
|
|
323
395
|
`
|
|
324
396
|
}
|
|
397
|
+
|
|
398
|
+
function ignoreDestroyError () {}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { isCompatEnv } from './compatEnv.js'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_COMPAT_SUBSCRIPTION_GC_DELAY = 300
|
|
4
|
+
const DEFAULT_SUBSCRIPTION_GC_DELAY = 0
|
|
5
|
+
|
|
6
|
+
let subscriptionGcDelay = getDefaultSubscriptionGcDelay()
|
|
7
|
+
|
|
8
|
+
export function getSubscriptionGcDelay () {
|
|
9
|
+
return subscriptionGcDelay
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function setSubscriptionGcDelay (ms) {
|
|
13
|
+
if (ms == null) {
|
|
14
|
+
subscriptionGcDelay = getDefaultSubscriptionGcDelay()
|
|
15
|
+
return subscriptionGcDelay
|
|
16
|
+
}
|
|
17
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
|
|
18
|
+
throw Error('setSubscriptionGcDelay() expects a non-negative finite number')
|
|
19
|
+
}
|
|
20
|
+
subscriptionGcDelay = ms
|
|
21
|
+
return subscriptionGcDelay
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getDefaultSubscriptionGcDelay () {
|
|
25
|
+
return isCompatEnv()
|
|
26
|
+
? DEFAULT_COMPAT_SUBSCRIPTION_GC_DELAY
|
|
27
|
+
: DEFAULT_SUBSCRIPTION_GC_DELAY
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function __resetSubscriptionGcDelayForTests () {
|
|
31
|
+
subscriptionGcDelay = getDefaultSubscriptionGcDelay()
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "teamplay",
|
|
3
|
-
"version": "0.4.0-alpha.
|
|
3
|
+
"version": "0.4.0-alpha.34",
|
|
4
4
|
"description": "Full-stack signals ORM with multiplayer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -81,5 +81,5 @@
|
|
|
81
81
|
]
|
|
82
82
|
},
|
|
83
83
|
"license": "MIT",
|
|
84
|
-
"gitHead": "
|
|
84
|
+
"gitHead": "619024ea6ea3699ab461c279791a59e5a58334ab"
|
|
85
85
|
}
|
package/react/promiseBatcher.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
let active = false
|
|
2
2
|
let promises = []
|
|
3
|
+
let checks = new Map()
|
|
4
|
+
|
|
5
|
+
const READINESS_POLL_INTERVAL_MS = 16
|
|
6
|
+
const READINESS_WARN_AFTER_MS = 1000
|
|
3
7
|
|
|
4
8
|
export function activate () {
|
|
5
9
|
active = true
|
|
@@ -10,9 +14,22 @@ export function add (promise) {
|
|
|
10
14
|
promises.push(promise)
|
|
11
15
|
}
|
|
12
16
|
|
|
17
|
+
export function addCheck (check) {
|
|
18
|
+
if (!check || typeof check.isReady !== 'function') return
|
|
19
|
+
const key = check.key ?? Symbol('batch-check')
|
|
20
|
+
checks.set(key, { ...check, key })
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
export function getPromiseAll () {
|
|
14
|
-
const
|
|
15
|
-
const
|
|
24
|
+
const pendingPromises = promises
|
|
25
|
+
const pendingChecks = Array.from(checks.values())
|
|
26
|
+
const hasPromises = pendingPromises.length > 0
|
|
27
|
+
const hasChecks = pendingChecks.length > 0
|
|
28
|
+
const result = !hasPromises && !hasChecks
|
|
29
|
+
? null
|
|
30
|
+
: hasPromises || !areChecksReady(pendingChecks)
|
|
31
|
+
? waitForBatchReady(pendingPromises, pendingChecks)
|
|
32
|
+
: null
|
|
16
33
|
reset()
|
|
17
34
|
return result
|
|
18
35
|
}
|
|
@@ -24,4 +41,80 @@ export function isActive () {
|
|
|
24
41
|
export function reset () {
|
|
25
42
|
active = false
|
|
26
43
|
promises = []
|
|
44
|
+
checks = new Map()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function waitForBatchReady (pendingPromises, pendingChecks) {
|
|
48
|
+
if (pendingPromises.length > 0) await Promise.all(pendingPromises)
|
|
49
|
+
// Let microtasks flush after subscription promises resolve so tree writes become visible.
|
|
50
|
+
await Promise.resolve()
|
|
51
|
+
await waitForChecksReady(pendingChecks)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function waitForChecksReady (pendingChecks) {
|
|
55
|
+
if (pendingChecks.length === 0) return
|
|
56
|
+
let warned = false
|
|
57
|
+
const startedAt = Date.now()
|
|
58
|
+
while (true) {
|
|
59
|
+
const notReadyChecks = getNotReadyChecks(pendingChecks)
|
|
60
|
+
if (notReadyChecks.length === 0) return
|
|
61
|
+
if (!warned && isDevMode() && Date.now() - startedAt >= READINESS_WARN_AFTER_MS) {
|
|
62
|
+
warned = true
|
|
63
|
+
warnAboutChecksDelay(notReadyChecks)
|
|
64
|
+
}
|
|
65
|
+
await delay(READINESS_POLL_INTERVAL_MS)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function areChecksReady (pendingChecks) {
|
|
70
|
+
if (pendingChecks.length === 0) return true
|
|
71
|
+
return getNotReadyChecks(pendingChecks).length === 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getNotReadyChecks (pendingChecks) {
|
|
75
|
+
const notReady = []
|
|
76
|
+
for (const check of pendingChecks) {
|
|
77
|
+
if (!isCheckReady(check)) notReady.push(check)
|
|
78
|
+
}
|
|
79
|
+
return notReady
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isCheckReady (check) {
|
|
83
|
+
try {
|
|
84
|
+
return !!check.isReady()
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (isThenable(err)) return false
|
|
87
|
+
throw err
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function warnAboutChecksDelay (checks) {
|
|
92
|
+
const details = checks.map(check => {
|
|
93
|
+
let state
|
|
94
|
+
try {
|
|
95
|
+
state = typeof check.getState === 'function' ? check.getState() : undefined
|
|
96
|
+
} catch (err) {
|
|
97
|
+
state = isThenable(err) ? 'suspended' : `state-error: ${err?.message || err}`
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
type: check.type || 'unknown',
|
|
101
|
+
key: String(check.key),
|
|
102
|
+
details: check.details,
|
|
103
|
+
state
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
console.warn('[teamplay] useBatch() is waiting for data materialization checks.', details)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isDevMode () {
|
|
110
|
+
if (typeof process === 'undefined' || !process?.env) return true
|
|
111
|
+
return process.env.NODE_ENV !== 'production'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isThenable (value) {
|
|
115
|
+
return !!value && typeof value.then === 'function'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function delay (ms) {
|
|
119
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
27
120
|
}
|