teamplay 0.1.15 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,100 +17,7 @@ Features:
17
17
 
18
18
  ## Installation
19
19
 
20
- ### Client-only mode
21
-
22
- If you just want a client-only mode without any data being synced to the server, then you don't need to setup anything and can jump directly to [Usage](#usage).
23
-
24
- ### Synchronization of data with server
25
-
26
- Enable the connection on client somewhere early in your client app:
27
-
28
- ```js
29
- import connect from 'teamplay/connect'
30
- connect()
31
- ```
32
-
33
- On the server you need to create the teamplay's backend and then create a connection handler for WebSockets:
34
-
35
- ```js
36
- import { createBackend, initConnection } from 'teamplay/server'
37
- const backend = createBackend()
38
- const { upgrade } = initConnection(backend)
39
- server.on('upgrade', upgrade) // Node's 'http' server instance
40
- ```
41
-
42
- - for production use it's recommended to use MongoDB. It's gonna be automatically used if you set the env var `MONGO_URL`
43
- - when deploying to a cluster with multiple instances you also have to set the env var `REDIS_URL` (Redis)
44
-
45
- Without setting `MONGO_URL` the alternative `mingo` mock is used instead which persists data into an SQLite file `local.db` in the root of your project.
46
-
47
- > [!NOTE]
48
- > teamplay's `createBackend()` is a wrapper around creating a [ShareDB's backend](https://share.github.io/sharedb/api/backend).
49
- > You can instead manually create a ShareDB backend yourself and pass it to `initConnection()`.
50
- > `ShareDB` is re-exported from `teamplay/server`, you can get it as `import { ShareDB } from 'teamplay/server'`
51
-
52
- ## `initConnection(backend, options)`
53
-
54
- **`backend`** - ShareDB backend instance
55
-
56
- **`options`**:
57
-
58
- ### `fetchOnly` (default: `true`)
59
-
60
- By default all subscriptions on the server are not reactive. This is strongly recommended.
61
-
62
- If you need the subscriptions to reactively update data whenever it changes (the same way as they work on client-side), pass `{ fetchOnly: false }`.
63
-
64
- ## Usage
65
-
66
- TBD
67
- ...
68
-
69
- ## Examples
70
-
71
- For a simple working react app see [/example](/example)
72
-
73
- ### Simplest example with server synchronization
74
-
75
- On the client we `connect()` to the server, and we have to wrap each React component into `observer()`:
76
-
77
- ```js
78
- // client.js
79
- import { createRoot } from 'react-dom/client'
80
- import connect from 'teamplay/connect'
81
- import { observer, $, sub } from 'teamplay'
82
-
83
- connect()
84
-
85
- const App = observer(({ userId }) => {
86
- const $user = sub($.users[userId])
87
- if (!$user.get()) throw $user.set({ points: 0 })
88
- const { $points } = $user
89
- const increment = () => $points.set($points.get() + 1)
90
- return <button onClick={increment}>Points: {$points.get()}</button>
91
- })
92
-
93
- const container = document.body.appendChild(document.createElement('div'))
94
- createRoot(container).render(<App userId='_1' />)
95
- ```
96
-
97
- On the server we create the ShareDB backend and initialize the WebSocket connections handler:
98
-
99
- ```js
100
- // server.js
101
- import http from 'http'
102
- import { createBackend, initConnection } from 'teamplay/server'
103
-
104
- const server = http.createServer() // you can pass expressApp here if needed
105
- const backend = createBackend()
106
- const { upgrade } = initConnection(backend)
107
-
108
- server.on('upgrade', upgrade)
109
-
110
- server.listen(3000, () => {
111
- console.log('Server started. Open http://localhost:3000 in your browser')
112
- })
113
- ```
20
+ For installation and documentation see [teamplay.dev](https://teamplay.dev)
114
21
 
115
22
  ## License
116
23
 
package/index.js CHANGED
@@ -13,11 +13,12 @@ export { default as signal } from './orm/getSignal.js'
13
13
  export { GLOBAL_ROOT_ID } from './orm/Root.js'
14
14
  export const $ = _getRootSignal({ rootId: GLOBAL_ROOT_ID, rootFunction: universal$ })
15
15
  export default $
16
- export { default as sub } from './react/universalSub.js'
16
+ export { default as sub } from './orm/sub.js'
17
+ export { default as useSub } from './react/useSub.js'
17
18
  export { default as observer } from './react/observer.js'
18
19
  export { connection, setConnection, getConnection, fetchOnly, setFetchOnly, publicOnly, setPublicOnly } from './orm/connection.js'
19
20
  export { GUID_PATTERN, hasMany, hasOne, hasManyFlags, belongsTo, pickFormFields } from '@teamplay/schema'
20
- export { aggregation, aggregationHeader } from '@teamplay/utils/aggregation'
21
+ export { aggregation, aggregationHeader as __aggregationHeader } from '@teamplay/utils/aggregation'
21
22
 
22
23
  export function getRootSignal (options) {
23
24
  return _getRootSignal({
@@ -25,19 +26,3 @@ export function getRootSignal (options) {
25
26
  ...options
26
27
  })
27
28
  }
28
-
29
- // the following are react-specific hook alternatives to $() and sub() functions.
30
- // In future we might want to expose them, but at the current time they are not needed
31
- // and instead just the regular $() and sub() functions are used since they are universal
32
- //
33
- // export function use$ (value) {
34
- // // TODO: maybe replace all non-letter/digit characters with underscores
35
- // const id = useId() // eslint-disable-line react-hooks/rules-of-hooks
36
- // return $(value, id)
37
- // }
38
-
39
- // export function useSub (...args) {
40
- // const promiseOrSignal = sub(...args)
41
- // if (promiseOrSignal.then) throw promiseOrSignal
42
- // return promiseOrSignal
43
- // }
@@ -0,0 +1,68 @@
1
+ import { raw } from '@nx-js/observer-util'
2
+ import { set as _set, del as _del, getRaw } from './dataTree.js'
3
+ import getSignal from './getSignal.js'
4
+ import { QuerySubscriptions, hashQuery, Query, HASH, PARAMS, COLLECTION_NAME, parseQueryHash } from './Query.js'
5
+ import Signal, { SEGMENTS } from './Signal.js'
6
+
7
+ export const IS_AGGREGATION = Symbol('is aggregation signal')
8
+ export const AGGREGATIONS = '$aggregations'
9
+
10
+ class Aggregation extends Query {
11
+ _initData () {
12
+ {
13
+ const extra = raw(this.shareQuery.extra)
14
+ _set([AGGREGATIONS, this.hash], extra)
15
+ }
16
+
17
+ this.shareQuery.on('extra', extra => {
18
+ extra = raw(extra)
19
+ _set([AGGREGATIONS, this.hash], extra)
20
+ })
21
+ }
22
+
23
+ _removeData () {
24
+ _del([AGGREGATIONS, this.hash])
25
+ }
26
+ }
27
+
28
+ export const aggregationSubscriptions = new QuerySubscriptions(Aggregation)
29
+
30
+ export function getAggregationSignal (collectionName, params, options) {
31
+ params = JSON.parse(JSON.stringify(params))
32
+ const hash = hashQuery(collectionName, params)
33
+
34
+ const $aggregation = getSignal(undefined, [AGGREGATIONS, hash], options)
35
+ $aggregation[IS_AGGREGATION] ??= true
36
+ $aggregation[COLLECTION_NAME] ??= collectionName
37
+ $aggregation[PARAMS] ??= params
38
+ $aggregation[HASH] ??= hash
39
+ return $aggregation
40
+ }
41
+
42
+ // example: ['$aggregations', '{"active":true}']
43
+ export function isAggregationSignal ($signal) {
44
+ if (!($signal instanceof Signal)) return
45
+ const segments = $signal[SEGMENTS]
46
+ if (!(segments.length === 2)) return
47
+ if (!(segments[0] === AGGREGATIONS)) return
48
+ return true
49
+ }
50
+
51
+ // example: ['$aggregations', '{"active":true}', 42]
52
+ // AND only if it also has either '_id' or 'id' field inside
53
+ export function getAggregationDocId (segments) {
54
+ if (!(segments.length >= 3)) return
55
+ if (!(segments[0] === AGGREGATIONS)) return
56
+ if (!(typeof segments[2] === 'number')) return
57
+ const doc = getRaw(segments)
58
+ const docId = doc?._id || doc?.id
59
+ return docId
60
+ }
61
+
62
+ export function getAggregationCollectionName (segments) {
63
+ if (!(segments.length >= 2)) return
64
+ if (!(segments[0] === AGGREGATIONS)) return
65
+ const hash = segments[1]
66
+ const { collectionName } = parseQueryHash(hash)
67
+ return collectionName
68
+ }
package/orm/Query.js CHANGED
@@ -1,28 +1,28 @@
1
1
  import { raw } from '@nx-js/observer-util'
2
2
  import { get as _get, set as _set, del as _del } from './dataTree.js'
3
- import { SEGMENTS } from './Signal.js'
4
3
  import getSignal from './getSignal.js'
5
4
  import { getConnection, fetchOnly } from './connection.js'
6
5
  import { docSubscriptions } from './Doc.js'
7
6
  import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
8
7
 
9
8
  const ERROR_ON_EXCESSIVE_UNSUBSCRIBES = false
9
+ export const COLLECTION_NAME = Symbol('query collection name')
10
10
  export const PARAMS = Symbol('query params')
11
11
  export const HASH = Symbol('query hash')
12
12
  export const IS_QUERY = Symbol('is query signal')
13
13
  export const QUERIES = '$queries'
14
14
 
15
- class Query {
15
+ export class Query {
16
16
  subscribing
17
17
  unsubscribing
18
18
  subscribed
19
19
  initialized
20
20
  shareQuery
21
21
 
22
- constructor (collection, params) {
23
- this.collection = collection
22
+ constructor (collectionName, params) {
23
+ this.collectionName = collectionName
24
24
  this.params = params
25
- this.hash = hashQuery([this.collection], this.params)
25
+ this.hash = hashQuery(this.collectionName, this.params)
26
26
  this.docSignals = new Set()
27
27
  }
28
28
 
@@ -66,7 +66,7 @@ class Query {
66
66
  await this.subscribing
67
67
  this.init()
68
68
  } catch (err) {
69
- console.log('subscription error', [this.collection, this.params], err)
69
+ console.log('subscription error', [this.collectionName, this.params], err)
70
70
  this.subscribed = undefined
71
71
  throw err
72
72
  } finally {
@@ -79,7 +79,7 @@ class Query {
79
79
  async _subscribe () {
80
80
  await new Promise((resolve, reject) => {
81
81
  const method = fetchOnly ? 'createFetchQuery' : 'createSubscribeQuery'
82
- this.shareQuery = getConnection()[method](this.collection, this.params, {}, err => {
82
+ this.shareQuery = getConnection()[method](this.collectionName, this.params, {}, err => {
83
83
  if (err) return reject(err)
84
84
  resolve()
85
85
  })
@@ -88,7 +88,7 @@ class Query {
88
88
 
89
89
  async unsubscribe () {
90
90
  if (!this.subscribed) {
91
- throw Error('trying to unsubscribe while not subscribed. Query: ' + [this.collection, this.params])
91
+ throw Error('trying to unsubscribe while not subscribed. Query: ' + [this.collectionName, this.params])
92
92
  }
93
93
  this.subscribed = undefined
94
94
  // if we are still handling the subscription, just wait for it to finish and then unsubscribe
@@ -120,7 +120,7 @@ class Query {
120
120
  this.initialized = undefined
121
121
  this._removeData()
122
122
  } catch (err) {
123
- console.log('error unsubscribing', [this.collection, this.params], err)
123
+ console.log('error unsubscribing', [this.collectionName, this.params], err)
124
124
  this.subscribed = true
125
125
  throw err
126
126
  } finally {
@@ -148,7 +148,7 @@ class Query {
148
148
 
149
149
  const ids = this.shareQuery.results.map(doc => doc.id)
150
150
  for (const docId of ids) {
151
- const $doc = getSignal(undefined, [this.collection, docId])
151
+ const $doc = getSignal(undefined, [this.collectionName, docId])
152
152
  docSubscriptions.init($doc)
153
153
  this.docSignals.add($doc)
154
154
  }
@@ -161,7 +161,7 @@ class Query {
161
161
 
162
162
  const ids = shareDocs.map(doc => doc.id)
163
163
  for (const docId of ids) {
164
- const $doc = getSignal(undefined, [this.collection, docId])
164
+ const $doc = getSignal(undefined, [this.collectionName, docId])
165
165
  docSubscriptions.init($doc)
166
166
  this.docSignals.add($doc)
167
167
  }
@@ -182,7 +182,7 @@ class Query {
182
182
 
183
183
  const docIds = shareDocs.map(doc => doc.id)
184
184
  for (const docId of docIds) {
185
- const $doc = getSignal(undefined, [this.collection, docId])
185
+ const $doc = getSignal(undefined, [this.collectionName, docId])
186
186
  this.docSignals.delete($doc)
187
187
  }
188
188
  const ids = _get([QUERIES, this.hash, 'ids'])
@@ -196,36 +196,35 @@ class Query {
196
196
  }
197
197
  }
198
198
 
199
- class QuerySubscriptions {
200
- constructor () {
199
+ export class QuerySubscriptions {
200
+ constructor (QueryClass = Query) {
201
+ this.QueryClass = QueryClass
201
202
  this.subCount = new Map()
202
203
  this.queries = new Map()
203
- this.fr = new FinalizationRegistry(({ segments, params }) => this.destroy(segments, params))
204
+ this.fr = new FinalizationRegistry(({ collectionName, params }) => this.destroy(collectionName, params))
204
205
  }
205
206
 
206
207
  subscribe ($query) {
207
- const segments = [...$query[SEGMENTS]]
208
+ const collectionName = $query[COLLECTION_NAME]
208
209
  const params = JSON.parse(JSON.stringify($query[PARAMS]))
209
- const hash = hashQuery(segments, params)
210
+ const hash = $query[HASH]
210
211
  let count = this.subCount.get(hash) || 0
211
212
  count += 1
212
213
  this.subCount.set(hash, count)
213
214
  if (count > 1) return this.queries.get(hash).subscribing
214
215
 
215
- this.fr.register($query, { segments, params }, $query)
216
+ this.fr.register($query, { collectionName, params }, $query)
216
217
 
217
218
  let query = this.queries.get(hash)
218
219
  if (!query) {
219
- query = new Query(segments[0], params)
220
+ query = new this.QueryClass(collectionName, params)
220
221
  this.queries.set(hash, query)
221
222
  }
222
223
  return query.subscribe()
223
224
  }
224
225
 
225
226
  async unsubscribe ($query) {
226
- const segments = [...$query[SEGMENTS]]
227
- const params = JSON.parse(JSON.stringify($query[PARAMS]))
228
- const hash = hashQuery(segments, params)
227
+ const hash = $query[HASH]
229
228
  let count = this.subCount.get(hash) || 0
230
229
  count -= 1
231
230
  if (count < 0) {
@@ -244,8 +243,8 @@ class QuerySubscriptions {
244
243
  this.queries.delete(hash)
245
244
  }
246
245
 
247
- async destroy (segments, params) {
248
- const hash = hashQuery(segments, params)
246
+ async destroy (collectionName, params) {
247
+ const hash = hashQuery(collectionName, params)
249
248
  const query = this.queries.get(hash)
250
249
  if (!query) return
251
250
  this.subCount.delete(hash)
@@ -257,20 +256,30 @@ class QuerySubscriptions {
257
256
 
258
257
  export const querySubscriptions = new QuerySubscriptions()
259
258
 
260
- export function hashQuery (segments, params) {
259
+ export function hashQuery (collectionName, params) {
261
260
  // TODO: probably makes sense to use fast-stable-json-stringify for this because of the params
262
- return JSON.stringify({ query: [segments[0], params] })
261
+ return JSON.stringify({ query: [collectionName, params] })
262
+ }
263
+
264
+ export function parseQueryHash (hash) {
265
+ try {
266
+ const { query: [collectionName, params] } = JSON.parse(hash)
267
+ return { collectionName, params }
268
+ } catch (err) {
269
+ return {}
270
+ }
263
271
  }
264
272
 
265
- export function getQuerySignal (segments, params, options) {
273
+ export function getQuerySignal (collectionName, params, options) {
266
274
  params = JSON.parse(JSON.stringify(params))
267
- const hash = hashQuery(segments, params)
275
+ const hash = hashQuery(collectionName, params)
268
276
 
269
- const $query = getSignal(undefined, segments, {
277
+ const $query = getSignal(undefined, [collectionName], {
270
278
  signalHash: hash,
271
279
  ...options
272
280
  })
273
281
  $query[IS_QUERY] ??= true
282
+ $query[COLLECTION_NAME] ??= collectionName
274
283
  $query[PARAMS] ??= params
275
284
  $query[HASH] ??= hash
276
285
  return $query
@@ -278,7 +287,8 @@ export function getQuerySignal (segments, params, options) {
278
287
 
279
288
  const ERRORS = {
280
289
  notSubscribed: $query => `
281
- trying to unsubscribe when not subscribed. Query:
282
- ${[$query[SEGMENTS], $query[PARAMS]]}
290
+ Trying to unsubscribe from Query when not subscribed.
291
+ Collection: ${$query[COLLECTION_NAME]}
292
+ Params: ${$query[PARAMS]}
283
293
  `
284
294
  }
package/orm/Signal.js CHANGED
@@ -14,15 +14,21 @@
14
14
  import uuid from '@teamplay/utils/uuid'
15
15
  import { get as _get, set as _set, del as _del, setPublicDoc as _setPublicDoc, getRaw } from './dataTree.js'
16
16
  import getSignal, { rawSignal } from './getSignal.js'
17
+ import { docSubscriptions } from './Doc.js'
17
18
  import { IS_QUERY, HASH, QUERIES } from './Query.js'
19
+ import { AGGREGATIONS, getAggregationCollectionName, getAggregationDocId } from './Aggregation.js'
18
20
  import { ROOT_FUNCTION, getRoot } from './Root.js'
19
21
  import { publicOnly } from './connection.js'
20
22
 
21
23
  export const SEGMENTS = Symbol('path segments targeting the particular node in the data tree')
22
24
  export const ARRAY_METHOD = Symbol('run array method on the signal')
23
25
  export const GET = Symbol('get the value of the signal - either observed or raw')
26
+ export const GETTERS = Symbol('get the list of this signal\'s getters')
27
+ const DEFAULT_GETTERS = ['path', 'id', 'get', 'peek', 'getId', 'map', 'reduce', 'find']
24
28
 
25
29
  export default class Signal extends Function {
30
+ static [GETTERS] = DEFAULT_GETTERS
31
+
26
32
  constructor (segments) {
27
33
  if (!Array.isArray(segments)) throw Error('Signal constructor expects an array of segments')
28
34
  super()
@@ -218,11 +224,40 @@ export const extremelyLateBindings = {
218
224
  return signal[ROOT_FUNCTION].call(thisArg, signal, ...argumentsList)
219
225
  }
220
226
  const key = signal[SEGMENTS][signal[SEGMENTS].length - 1]
221
- const $parent = getSignal(getRoot(signal), signal[SEGMENTS].slice(0, -1))
222
- const rawParent = rawSignal($parent)
223
- if (!(key in rawParent)) {
224
- throw Error(`Method "${key}" does not exist on signal "${$parent[SEGMENTS].join('.')}"`)
227
+ const segments = signal[SEGMENTS].slice(0, -1)
228
+ if (segments[0] === AGGREGATIONS) {
229
+ const aggregationDocId = getAggregationDocId(segments)
230
+ if (aggregationDocId) {
231
+ if (segments.length === 3 && key === 'set') throw Error(ERRORS.setAggregationDoc(segments, key))
232
+ const collectionName = getAggregationCollectionName(segments)
233
+ const subDocSegments = segments.slice(3)
234
+ const $original = getSignal(getRoot(signal), [collectionName, aggregationDocId, ...subDocSegments])
235
+ const rawOriginal = rawSignal($original)
236
+ if (!(key in rawOriginal)) throw Error(ERRORS.noSignalKey($original, key))
237
+ const fn = rawOriginal[key]
238
+ const getters = rawOriginal.constructor[GETTERS]
239
+ // for getters run the method on the aggregation data itself
240
+ if (getters.includes(key)) {
241
+ const $parent = getSignal(getRoot(signal), segments)
242
+ return Reflect.apply(fn, $parent, argumentsList)
243
+ // for async methods (setters) subscribe to the original doc and run the method on its relative signal
244
+ } else {
245
+ const $doc = getSignal(getRoot(signal), [collectionName, aggregationDocId])
246
+ const promise = docSubscriptions.subscribe($doc)
247
+ if (!promise) return Reflect.apply(fn, $original, argumentsList)
248
+ return new Promise(resolve => {
249
+ promise.then(() => {
250
+ resolve(Reflect.apply(fn, $original, argumentsList))
251
+ })
252
+ })
253
+ }
254
+ } else if (!DEFAULT_GETTERS.includes(key)) {
255
+ throw Error(ERRORS.aggregationSetter(segments, key))
256
+ }
225
257
  }
258
+ const $parent = getSignal(getRoot(signal), segments)
259
+ const rawParent = rawSignal($parent)
260
+ if (!(key in rawParent)) throw Error(ERRORS.noSignalKey($parent, key))
226
261
  return Reflect.apply(rawParent[key], $parent, argumentsList)
227
262
  },
228
263
  get (signal, key, receiver) {
@@ -286,5 +321,25 @@ const ERRORS = {
286
321
  publicOnly: `
287
322
  Can't modify private collections data when 'publicOnly' is enabled.
288
323
  On the server you can only work with public collections.
324
+ `,
325
+ noSignalKey: ($signal, key) => `Method "${key}" does not exist on signal "${$signal[SEGMENTS].join('.')}"`,
326
+ aggregationSetter: (segments, key) => `
327
+ You can not use setters on aggregation signals.
328
+ It's only allowed when the aggregation result is an array of documents
329
+ with either '_id' or 'id' field present in them.
330
+
331
+ Path: ${segments}
332
+ Method: ${key}
333
+ `,
334
+ setAggregationDoc: (segments, key) => `
335
+ Changing a whole document using .set() from an aggregation signal is prohibited.
336
+ This is to prevent accidental overwriting of the whole document with incorrect aggregation results.
337
+ You can only change the particular fields within the document using the aggregation signal.
338
+
339
+ If you want to change the whole document, use the actual document signal explicitly
340
+ (and make sure to subscribe to it).
341
+
342
+ Path: ${segments}
343
+ Method: ${key}
289
344
  `
290
345
  }
package/orm/dataTree.js CHANGED
@@ -2,6 +2,7 @@ import { observable, raw } from '@nx-js/observer-util'
2
2
  import jsonDiff from 'json0-ot-diff'
3
3
  import diffMatchPatch from 'diff-match-patch'
4
4
  import { getConnection } from './connection.js'
5
+ import setDiffDeep from '../utils/setDiffDeep.js'
5
6
 
6
7
  const ALLOW_PARTIAL_DOC_CREATION = false
7
8
 
@@ -62,8 +63,12 @@ export function set (segments, value, tree = dataTree) {
62
63
  }
63
64
  return
64
65
  }
65
- // just set the new value
66
- dataNode[key] = value
66
+ // instead of just setting the new value `dataNode[key] = value` we want
67
+ // to deeply update it to prevent unnecessary reactivity triggers.
68
+ const newValue = setDiffDeep(dataNode[key], value)
69
+ // handle case when the value couldn't be updated in place and is completely new
70
+ // (we just set it to this value)
71
+ if (dataNode[key] !== newValue) dataNode[key] = newValue
67
72
  }
68
73
 
69
74
  export function del (segments, tree = dataTree) {
package/orm/getSignal.js CHANGED
@@ -4,6 +4,7 @@ import { findModel } from './addModel.js'
4
4
  import { LOCAL } from './$.js'
5
5
  import { ROOT, ROOT_ID, GLOBAL_ROOT_ID } from './Root.js'
6
6
  import { QUERIES } from './Query.js'
7
+ import { AGGREGATIONS } from './Aggregation.js'
7
8
 
8
9
  const PROXIES_CACHE = new Cache()
9
10
  const PROXY_TO_SIGNAL = new WeakMap()
@@ -22,7 +23,7 @@ export default function getSignal ($root, segments = [], {
22
23
  if (!($root instanceof Signal)) {
23
24
  if (segments.length === 0 && !rootId) throw Error(ERRORS.rootIdRequired)
24
25
  if (segments.length >= 1 && isPrivateCollection(segments[0])) {
25
- if (segments[0] === QUERIES) {
26
+ if (segments[0] === QUERIES || segments[0] === AGGREGATIONS) {
26
27
  // TODO: this is a hack to temporarily let the queries work.
27
28
  // '$queries' collection is always added to the global (singleton) root signal.
28
29
  // In future it should also be part of the particular root signal.
package/orm/sub.js CHANGED
@@ -1,21 +1,33 @@
1
+ import { isAggregationHeader, isAggregationFunction } from '@teamplay/utils/aggregation'
1
2
  import Signal, { SEGMENTS, isPublicCollectionSignal, isPublicDocumentSignal } from './Signal.js'
2
3
  import { docSubscriptions } from './Doc.js'
3
4
  import { querySubscriptions, getQuerySignal } from './Query.js'
5
+ import { aggregationSubscriptions, getAggregationSignal } from './Aggregation.js'
4
6
 
5
7
  export default function sub ($signal, params) {
6
- if (Array.isArray($signal)) {
7
- const res = $signal.map(args => Array.isArray(args) ? sub(...args) : sub(args))
8
- if (res.some($s => $s.then)) return Promise.all(res)
9
- return res
10
- }
8
+ // TODO: temporarily disable support for multiple subscriptions
9
+ // since this has to be properly cached using useDeferredSignal() in useSub()
10
+ // if (Array.isArray($signal)) {
11
+ // const res = $signal.map(args => Array.isArray(args) ? sub(...args) : sub(args))
12
+ // if (res.some($s => $s.then)) return Promise.all(res)
13
+ // return res
14
+ // }
11
15
  if (isPublicDocumentSignal($signal)) {
12
16
  if (arguments.length > 1) throw Error(ERRORS.subDocArguments(...arguments))
13
17
  return doc$($signal)
14
18
  } else if (isPublicCollectionSignal($signal)) {
15
19
  if (arguments.length !== 2) throw Error(ERRORS.subQueryArguments(...arguments))
16
- return query$($signal, params)
20
+ return query$($signal[SEGMENTS][0], params)
17
21
  } else if (typeof $signal === 'function' && !($signal instanceof Signal)) {
18
22
  return api$($signal, params)
23
+ } else if (isAggregationHeader($signal)) {
24
+ params = {
25
+ $aggregationName: $signal.name,
26
+ $params: sanitizeAggregationParams(params)
27
+ }
28
+ return aggregation$($signal.collection, params)
29
+ } else if (isAggregationFunction($signal)) {
30
+ throw Error(ERRORS.gotAggregationFunction($signal))
19
31
  } else {
20
32
  throw Error('Invalid args passed for sub()')
21
33
  }
@@ -27,18 +39,34 @@ function doc$ ($doc) {
27
39
  return new Promise(resolve => promise.then(() => resolve($doc)))
28
40
  }
29
41
 
30
- function query$ ($collection, params) {
31
- if (typeof params !== 'object') throw Error(ERRORS.queryParamsObject($collection, params))
32
- const $query = getQuerySignal($collection[SEGMENTS], params)
42
+ function query$ (collectionName, params) {
43
+ if (typeof params !== 'object') throw Error(ERRORS.queryParamsObject(collectionName, params))
44
+ const $query = getQuerySignal(collectionName, params)
33
45
  const promise = querySubscriptions.subscribe($query)
34
46
  if (!promise) return $query
35
47
  return new Promise(resolve => promise.then(() => resolve($query)))
36
48
  }
37
49
 
50
+ function aggregation$ (collectionName, params) {
51
+ const $aggregationQuery = getAggregationSignal(collectionName, params)
52
+ const promise = aggregationSubscriptions.subscribe($aggregationQuery)
53
+ if (!promise) return $aggregationQuery
54
+ return new Promise(resolve => promise.then(() => resolve($aggregationQuery)))
55
+ }
56
+
38
57
  function api$ (fn, args) {
39
58
  throw Error('sub() for async functions is not implemented yet')
40
59
  }
41
60
 
61
+ // aggregation params get transferred to the server
62
+ // and while doing so if some value is 'undefined', it actually gets transferred as 'null'
63
+ // which breaks logic of setting default values in the aggregation function.
64
+ // That's why we have to explicitly remove 'undefined' values from the aggregation params.
65
+ // This can be easily done by serializing and deserializing it to JSON.
66
+ function sanitizeAggregationParams (params) {
67
+ return JSON.parse(JSON.stringify(params))
68
+ }
69
+
42
70
  const ERRORS = {
43
71
  subDocArguments: ($signal, ...args) => `
44
72
  sub($doc) accepts only 1 argument - the document signal to subscribe to
@@ -52,13 +80,24 @@ const ERRORS = {
52
80
  Params: ${params}
53
81
  Got args: ${[$signal, params, ...args]}
54
82
  `,
55
- queryParamsObject: ($collection, params) => `
83
+ queryParamsObject: (collectionName, params) => `
56
84
  sub($collection, params):
57
85
  Params must be an object.
58
86
  If you want to subscribe to all documents in a collection, pass an empty object: sub($collection, {}).
59
87
 
60
88
  Got:
61
- $collection: ${$collection[SEGMENTS]}
89
+ collectionName: ${collectionName}
62
90
  params: ${params}
91
+ `,
92
+ gotAggregationFunction: aggregationFn => `
93
+ sub($$aggregation, params):
94
+ Got aggregation function itself instead of the aggregation header.
95
+ Looks like client-side code transformation did not work properly and your
96
+ aggregation() function was not transformed into an __aggregationHeader().
97
+ Make sure you only use aggregation() function inside project's 'model' folder using
98
+ import { aggregation } from 'startupjs'
99
+
100
+ Got:
101
+ ${aggregationFn.toString()}
63
102
  `
64
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamplay",
3
- "version": "0.1.15",
3
+ "version": "0.2.0",
4
4
  "description": "Full-stack signals ORM with multiplayer",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -23,12 +23,12 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@nx-js/observer-util": "^4.1.3",
26
- "@teamplay/backend": "^0.1.15",
27
- "@teamplay/cache": "^0.1.15",
28
- "@teamplay/channel": "^0.1.15",
29
- "@teamplay/debug": "^0.1.15",
30
- "@teamplay/schema": "^0.1.15",
31
- "@teamplay/utils": "^0.1.15",
26
+ "@teamplay/backend": "^0.2.0",
27
+ "@teamplay/cache": "^0.2.0",
28
+ "@teamplay/channel": "^0.2.0",
29
+ "@teamplay/debug": "^0.2.0",
30
+ "@teamplay/schema": "^0.2.0",
31
+ "@teamplay/utils": "^0.2.0",
32
32
  "diff-match-patch": "^1.0.5",
33
33
  "events": "^3.3.0",
34
34
  "json0-ot-diff": "^1.1.2",
@@ -63,5 +63,5 @@
63
63
  ]
64
64
  },
65
65
  "license": "MIT",
66
- "gitHead": "fc145854b61d07a913f27db2b604e4a2bf855fa0"
66
+ "gitHead": "34435bba6654d480cac0e809defc15e1d15bd543"
67
67
  }
@@ -1,3 +1,7 @@
1
+ // NOTE: this is not used currently since using an explicit useSub()
2
+ // hook is easier to understand in a React context.
3
+ // Having the same sub() function working with either await or without it
4
+ // is confusing. It's better to have a separate function for the hook.
1
5
  import { useRef } from 'react'
2
6
  import sub from '../orm/sub.js'
3
7
  import executionContextTracker from './executionContextTracker.js'
@@ -0,0 +1,38 @@
1
+ import { useRef, useDeferredValue } from 'react'
2
+ import sub from '../orm/sub.js'
3
+
4
+ let TEST_THROTTLING = false
5
+
6
+ // version of sub() which works as a react hook and throws promise for Suspense
7
+ export default function useSub (signal, params) {
8
+ signal = useDeferredValue(signal)
9
+ params = useDeferredValue(params ? JSON.stringify(params) : undefined)
10
+ params = params ? JSON.parse(params) : undefined
11
+ const promiseOrSignal = params ? sub(signal, params) : sub(signal)
12
+ // 1. if it's a promise, throw it so that Suspense can catch it and wait for subscription to finish
13
+ if (promiseOrSignal.then) {
14
+ if (TEST_THROTTLING) {
15
+ // simulate slow network
16
+ throw new Promise((resolve, reject) => {
17
+ setTimeout(() => {
18
+ promiseOrSignal.then(resolve, reject)
19
+ }, TEST_THROTTLING)
20
+ })
21
+ }
22
+ throw promiseOrSignal
23
+ }
24
+ // 2. if it's a signal, we save it into ref to make sure it's not garbage collected while component exists
25
+ const $signalRef = useRef() // eslint-disable-line react-hooks/rules-of-hooks
26
+ if ($signalRef.current !== promiseOrSignal) $signalRef.current = promiseOrSignal
27
+ return promiseOrSignal
28
+ }
29
+
30
+ export function setTestThrottling (ms) {
31
+ if (typeof ms !== 'number') throw Error('setTestThrottling() accepts only a number in ms')
32
+ if (ms === 0) throw Error('setTestThrottling(0) is not allowed, use resetTestThrottling() instead')
33
+ if (ms < 0) throw Error('setTestThrottling() accepts only a positive number in ms')
34
+ TEST_THROTTLING = ms
35
+ }
36
+ export function resetTestThrottling () {
37
+ TEST_THROTTLING = false
38
+ }
@@ -0,0 +1,28 @@
1
+ export default function setDiffDeep (existing, updated) {
2
+ // Handle primitive types, null, and type mismatches
3
+ if (existing === null || updated === null ||
4
+ typeof existing !== 'object' || typeof updated !== 'object' ||
5
+ Array.isArray(existing) !== Array.isArray(updated)) {
6
+ return updated
7
+ }
8
+
9
+ // Handle arrays
10
+ if (Array.isArray(updated)) {
11
+ existing.length = updated.length
12
+ for (let i = 0; i < updated.length; i++) {
13
+ existing[i] = setDiffDeep(existing[i], updated[i])
14
+ }
15
+ return existing
16
+ }
17
+
18
+ // Handle objects
19
+ for (const key in existing) {
20
+ if (!(key in updated)) {
21
+ delete existing[key]
22
+ }
23
+ }
24
+ for (const key in updated) {
25
+ existing[key] = setDiffDeep(existing[key], updated[key])
26
+ }
27
+ return existing
28
+ }