teamplay 0.4.0-alpha.1 → 0.4.0-alpha.100

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.
Files changed (51) hide show
  1. package/README.md +73 -0
  2. package/index.d.ts +15 -0
  3. package/index.js +55 -1
  4. package/orm/Aggregation.js +48 -13
  5. package/orm/Compat/README.md +312 -28
  6. package/orm/Compat/SignalCompat.js +748 -144
  7. package/orm/Compat/eventsCompat.js +4 -3
  8. package/orm/Compat/hooksCompat.js +137 -29
  9. package/orm/Compat/modelEvents.js +113 -36
  10. package/orm/Compat/queryReadiness.js +191 -0
  11. package/orm/Compat/refFallback.js +62 -0
  12. package/orm/Compat/refRegistry.js +44 -10
  13. package/orm/Compat/silentContext.js +51 -0
  14. package/orm/Compat/startStopCompat.js +207 -0
  15. package/orm/Doc.js +805 -61
  16. package/orm/Query.js +944 -119
  17. package/orm/Reaction.js +12 -9
  18. package/orm/Root.js +47 -0
  19. package/orm/Signal.js +2 -5
  20. package/orm/SignalBase.js +173 -67
  21. package/orm/Value.js +7 -5
  22. package/orm/associations.js +97 -0
  23. package/orm/batchScheduler.js +62 -0
  24. package/orm/compatEnv.js +4 -0
  25. package/orm/connection.js +17 -2
  26. package/orm/dataTree.js +394 -99
  27. package/orm/disposeRootContext.js +68 -0
  28. package/orm/getSignal.js +50 -21
  29. package/orm/idFields.js +33 -2
  30. package/orm/index.d.ts +6 -0
  31. package/orm/index.js +5 -0
  32. package/orm/missingDoc.js +3 -0
  33. package/orm/privateData.js +181 -0
  34. package/orm/rootContext.js +313 -0
  35. package/orm/rootScope.js +51 -0
  36. package/orm/sub.js +15 -6
  37. package/orm/subscriptionGcDelay.js +32 -0
  38. package/package.json +10 -8
  39. package/react/compatComponentRegistry.js +20 -0
  40. package/react/convertToObserver.js +19 -2
  41. package/react/helpers.js +49 -1
  42. package/react/promiseBatcher.js +115 -0
  43. package/react/renderAttemptDestroyer.js +47 -0
  44. package/react/trapRender.js +26 -15
  45. package/react/useApi.js +63 -0
  46. package/react/useSub.js +54 -3
  47. package/react/useSuspendMemo.js +96 -0
  48. package/react/wrapIntoSuspense.js +1 -1
  49. package/server.js +2 -2
  50. package/utils/setDiffDeep.js +32 -12
  51. package/orm/Compat/REF.md +0 -315
package/README.md CHANGED
@@ -19,6 +19,79 @@ Features:
19
19
 
20
20
  For installation and documentation see [teamplay.dev](https://teamplay.dev)
21
21
 
22
+ ## ORM Compat Helpers
23
+
24
+ For legacy Racer-style model mixins (for example versioning libraries which call
25
+ `getAssociations()`), use ORM compat helpers from the `teamplay/orm` subpath:
26
+
27
+ ```js
28
+ import BaseModel, { hasMany, hasOne, belongsTo } from 'teamplay/orm'
29
+ ```
30
+
31
+ These helpers attach class-level associations and expose them through
32
+ `$doc.getAssociations()` on model signals.
33
+
34
+ ## React Suspense Gates
35
+
36
+ If you need to throw a thenable from render, prefer `useSuspendMemo()` or
37
+ `useSuspendMemoByKey()` over `useMemo()`.
38
+
39
+ Why:
40
+
41
+ - React may restart a suspended initial render.
42
+ - `useMemo()` is not a semantic "run this suspend gate once" primitive.
43
+ - Side-effectful async work like `join()` may accidentally start again on retry.
44
+
45
+ ### `useSuspendMemo(factory, deps)`
46
+
47
+ Use it when the suspend gate is local to one observer component instance.
48
+
49
+ ```js
50
+ import { observer, useSuspendMemo } from 'teamplay'
51
+
52
+ const Component = observer(({ $stage, userId, stageUserStore }) => {
53
+ useSuspendMemo(() => {
54
+ if (!stageUserStore?.startedAt) {
55
+ throw $stage.join(userId)
56
+ }
57
+ }, [$stage.getId()])
58
+
59
+ return <span>Ready</span>
60
+ })
61
+ ```
62
+
63
+ This keeps the same pending thenable for the same hook slot while the component
64
+ instance is alive.
65
+
66
+ ### `useSuspendMemoByKey(key, factory, deps)`
67
+
68
+ Use it when the async operation must be deduped by business meaning, not just
69
+ by component instance.
70
+
71
+ ```js
72
+ import { observer, useSuspendMemoByKey } from 'teamplay'
73
+
74
+ const Component = observer(({ $stage, stageId, userId, stageUserStore }) => {
75
+ useSuspendMemoByKey(
76
+ `stage.join:${stageId}:${userId}`,
77
+ () => {
78
+ if (!stageUserStore?.startedAt) {
79
+ throw $stage.join(userId)
80
+ }
81
+ },
82
+ [stageId, userId, !!stageUserStore?.startedAt]
83
+ )
84
+
85
+ return <span>Ready</span>
86
+ })
87
+ ```
88
+
89
+ This is the right choice when:
90
+
91
+ - the component may remount while the promise is still pending;
92
+ - two different components may trigger the same async operation;
93
+ - the operation should behave like a single in-flight business task.
94
+
22
95
  ## License
23
96
 
24
97
  MIT
package/index.d.ts CHANGED
@@ -42,6 +42,8 @@ export {
42
42
  setUseDeferredValue as __setUseDeferredValue,
43
43
  setDefaultDefer as __setDefaultDefer
44
44
  } from './react/useSub.js'
45
+ export function useSuspendMemo<T = any> (factory: () => T, deps?: any[]): T
46
+ export function useSuspendMemoByKey<T = any> (key: any, factory: () => T, deps?: any[]): T
45
47
  export function useValue (defaultValue?: any): [any, any]
46
48
  export function useValue$ (defaultValue?: any): any
47
49
  export function useModel (path?: any): any
@@ -73,6 +75,8 @@ export function useBatchQueryDoc (collection: string, query: any, options?: any)
73
75
  export function useBatchQueryDoc$ (collection: string, query: any, options?: any): any
74
76
  export function useAsyncQueryDoc (collection: string, query: any, options?: any): [any, any]
75
77
  export function useAsyncQueryDoc$ (collection: string, query: any, options?: any): any
78
+ export function useLocalDoc (collection: string, id: any): [any, any]
79
+ export function useLocalDoc$ (collection: string, id: any): any
76
80
  export function emit (eventName: string, ...args: any[]): void
77
81
  export function useOn (
78
82
  eventName: 'change' | 'all',
@@ -82,7 +86,18 @@ export function useOn (
82
86
  ): void
83
87
  export function useOn (eventName: string, handler: (...args: any[]) => void, deps?: any[]): void
84
88
  export function useEmit (): (eventName: string, ...args: any[]) => void
89
+ export function batch<T = any> (fn?: () => T): T | undefined
90
+ export function batchModel<T = any> (fn?: () => T): T | undefined
91
+ export function clone<T = any> (value: T): T
92
+ export function initLocalCollection (name: string): any
93
+ export function useApi (api: (...args: any[]) => any, args?: any[], options?: { debounce?: number }): [any, boolean, any]
94
+ type EffectCleanup = (() => void) | undefined
95
+ export function useDidUpdate (fn: () => EffectCleanup, deps?: any[]): void
96
+ export function useOnce (condition: any, fn: () => EffectCleanup): void
97
+ export function useSyncEffect (fn: () => EffectCleanup, deps?: any[]): void
85
98
  export { connection, setConnection, getConnection, fetchOnly, setFetchOnly, publicOnly, setPublicOnly } from './orm/connection.js'
99
+ export function getSubscriptionGcDelay (): number
100
+ export function setSubscriptionGcDelay (ms?: number | null): number
86
101
  export { useId, useNow, useScheduleUpdate, useTriggerUpdate } from './react/helpers.js'
87
102
  export { GUID_PATTERN, hasMany, hasOne, hasManyFlags, belongsTo, pickFormFields } from '@teamplay/schema'
88
103
  export { aggregation, aggregationHeader as __aggregationHeader } from '@teamplay/utils/aggregation'
package/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // In future, we might want to separate the plain JS and React APIs
6
6
  import { getRootSignal as _getRootSignal, GLOBAL_ROOT_ID } from './orm/Root.js'
7
7
  import universal$ from './react/universal$.js'
8
+ import useApi from './react/useApi.js'
8
9
 
9
10
  export { default as Signal, SEGMENTS } from './orm/Signal.js'
10
11
  export { __DEBUG_SIGNALS_CACHE__, rawSignal, getSignalClass } from './orm/getSignal.js'
@@ -22,6 +23,10 @@ export {
22
23
  setUseDeferredValue as __setUseDeferredValue,
23
24
  setDefaultDefer as __setDefaultDefer
24
25
  } from './react/useSub.js'
26
+ export {
27
+ default as useSuspendMemo,
28
+ useSuspendMemoByKey
29
+ } from './react/useSuspendMemo.js'
25
30
  export { default as observer } from './react/observer.js'
26
31
  export {
27
32
  useValue,
@@ -29,6 +34,8 @@ export {
29
34
  useModel,
30
35
  useLocal,
31
36
  useLocal$,
37
+ useLocalDoc,
38
+ useLocalDoc$,
32
39
  useSession,
33
40
  useSession$,
34
41
  usePage,
@@ -57,12 +64,59 @@ export {
57
64
  useAsyncQueryDoc$
58
65
  } from './orm/Compat/hooksCompat.js'
59
66
  export { emit, useOn, useEmit } from './orm/Compat/eventsCompat.js'
60
- export { connection, setConnection, getConnection, fetchOnly, setFetchOnly, publicOnly, setPublicOnly } from './orm/connection.js'
67
+ export {
68
+ useDidUpdate,
69
+ useOnce,
70
+ useSyncEffect
71
+ } from './react/helpers.js'
72
+ export {
73
+ connection,
74
+ setConnection,
75
+ getConnection,
76
+ getDefaultFetchOnly,
77
+ setDefaultFetchOnly,
78
+ publicOnly,
79
+ setPublicOnly
80
+ } from './orm/connection.js'
81
+ export { getSubscriptionGcDelay, setSubscriptionGcDelay } from './orm/subscriptionGcDelay.js'
61
82
  export { useId, useNow, useScheduleUpdate, useTriggerUpdate } from './react/helpers.js'
62
83
  export { GUID_PATTERN, hasMany, hasOne, hasManyFlags, belongsTo, pickFormFields } from '@teamplay/schema'
63
84
  export { aggregation, aggregationHeader as __aggregationHeader } from '@teamplay/utils/aggregation'
64
85
  export { accessControl } from '@teamplay/utils/accessControl'
65
86
 
87
+ export function batch (fn) {
88
+ return $.batch(fn)
89
+ }
90
+
91
+ export function batchModel (fn) {
92
+ return $.batch(fn)
93
+ }
94
+
95
+ export function clone (value) {
96
+ if (typeof globalThis.structuredClone === 'function') {
97
+ try {
98
+ return globalThis.structuredClone(value)
99
+ } catch {}
100
+ }
101
+ if (value == null) return value
102
+ return JSON.parse(JSON.stringify(value))
103
+ }
104
+
105
+ export function initLocalCollection (name) {
106
+ if (typeof name !== 'string') throw Error('initLocalCollection() expects a collection name')
107
+ if (!name) return
108
+ const segments = name.split('.').filter(Boolean)
109
+ if (!segments.length) return
110
+ let $cursor = $
111
+ for (const segment of segments) {
112
+ $cursor = $cursor[segment]
113
+ }
114
+ if ($cursor.get() == null) $cursor.set({})
115
+ return $cursor
116
+ }
117
+
118
+ export { useApi }
119
+
66
120
  export function getRootSignal (options) {
67
121
  return _getRootSignal({
68
122
  rootFunction: universal$,
@@ -1,34 +1,54 @@
1
1
  import { raw } from '@nx-js/observer-util'
2
- import { set as _set, del as _del, getRaw } from './dataTree.js'
2
+ import { getRaw } from './dataTree.js'
3
3
  import getSignal from './getSignal.js'
4
- import { QuerySubscriptions, hashQuery, Query, HASH, PARAMS, COLLECTION_NAME, parseQueryHash } from './Query.js'
4
+ import {
5
+ QuerySubscriptions,
6
+ hashQuery,
7
+ Query,
8
+ HASH,
9
+ PARAMS,
10
+ COLLECTION_NAME,
11
+ parseQueryHash
12
+ } from './Query.js'
5
13
  import Signal, { SEGMENTS } from './Signal.js'
6
14
  import { getIdFieldsForSegments, isPlainObject } from './idFields.js'
15
+ import { delPrivateData, getPrivateData, setPrivateData } from './privateData.js'
7
16
 
8
17
  export const IS_AGGREGATION = Symbol('is aggregation signal')
9
18
  export const AGGREGATIONS = '$aggregations'
10
19
 
11
20
  class Aggregation extends Query {
12
21
  _initData () {
13
- {
14
- const extra = raw(this.shareQuery.extra)
15
- injectAggregationIds(extra, this.collectionName)
16
- _set([AGGREGATIONS, this.hash], extra)
17
- }
22
+ this._syncAllRootsData()
18
23
 
19
24
  this.shareQuery.on('extra', extra => {
20
25
  extra = raw(extra)
21
26
  injectAggregationIds(extra, this.collectionName)
22
- _set([AGGREGATIONS, this.hash], extra)
27
+ this._forEachRoot(rootId => {
28
+ setPrivateData(rootId, [AGGREGATIONS, this.hash], extra)
29
+ })
23
30
  })
24
31
  }
25
32
 
33
+ _syncRootData (rootId) {
34
+ if (!this.shareQuery) return
35
+ const extra = raw(this.shareQuery.extra)
36
+ injectAggregationIds(extra, this.collectionName)
37
+ setPrivateData(rootId, [AGGREGATIONS, this.hash], extra)
38
+ }
39
+
40
+ _removeRootData (rootId) {
41
+ delPrivateData(rootId, [AGGREGATIONS, this.hash])
42
+ }
43
+
26
44
  _removeData () {
27
- _del([AGGREGATIONS, this.hash])
45
+ this._forEachRoot(rootId => this._removeRootData(rootId))
46
+ this.rootIds.clear()
28
47
  }
29
48
  }
30
49
 
31
50
  export const aggregationSubscriptions = new QuerySubscriptions(Aggregation)
51
+ aggregationSubscriptions.runtimeKind = 'aggregation'
32
52
 
33
53
  function injectAggregationIds (extra, collectionName) {
34
54
  if (!Array.isArray(extra)) return
@@ -44,13 +64,14 @@ function injectAggregationIds (extra, collectionName) {
44
64
 
45
65
  export function getAggregationSignal (collectionName, params, options) {
46
66
  params = JSON.parse(JSON.stringify(params))
47
- const hash = hashQuery(collectionName, params)
67
+ const transportHash = hashQuery(collectionName, params)
68
+ const { root, signalOptions } = parseAggregationSignalOptions(options)
48
69
 
49
- const $aggregation = getSignal(undefined, [AGGREGATIONS, hash], options)
70
+ const $aggregation = getSignal(root, [AGGREGATIONS, transportHash], signalOptions)
50
71
  $aggregation[IS_AGGREGATION] ??= true
51
72
  $aggregation[COLLECTION_NAME] ??= collectionName
52
73
  $aggregation[PARAMS] ??= params
53
- $aggregation[HASH] ??= hash
74
+ $aggregation[HASH] ??= transportHash
54
75
  return $aggregation
55
76
  }
56
77
 
@@ -65,10 +86,13 @@ export function isAggregationSignal ($signal) {
65
86
 
66
87
  // example: ['$aggregations', '{"active":true}', 42]
67
88
  // AND only if it also has either '_id' or 'id' field inside
68
- export function getAggregationDocId (segments, method = getRaw) {
89
+ export function getAggregationDocId (segments, rootId, method) {
69
90
  if (!(segments.length >= 3)) return
70
91
  if (!(segments[0] === AGGREGATIONS)) return
71
92
  if (!(typeof segments[2] === 'number')) return
93
+ if (typeof method !== 'function') {
94
+ method = path => rootId == null ? getRaw(path) : getPrivateData(rootId, path)
95
+ }
72
96
  const docId = method([...segments.slice(0, 3), '_id']) || method([...segments.slice(0, 3), 'id'])
73
97
  return docId
74
98
  }
@@ -80,3 +104,14 @@ export function getAggregationCollectionName (segments) {
80
104
  const { collectionName } = parseQueryHash(hash)
81
105
  return collectionName
82
106
  }
107
+
108
+ function parseAggregationSignalOptions (options) {
109
+ if (!options || typeof options !== 'object') {
110
+ return {
111
+ root: undefined,
112
+ signalOptions: {}
113
+ }
114
+ }
115
+ const { root, ...signalOptions } = options
116
+ return { root, signalOptions }
117
+ }