teamplay 0.1.16 → 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,210 +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
- ### Introduction to teamplay ORM
67
-
68
- teamplay is a powerful and easy-to-use ORM (Object-Relational Mapping) that allows you to work with your data in a natural, dot-notation style. It's designed to make data management in your app seamless and intuitive.
69
-
70
- #### The Big Idea: Deep Signals
71
-
72
- At the heart of teamplay is the concept of "deep signals." Think of your entire data structure as a big tree. With teamplay, you can navigate this tree using simple dot notation, just like you would access properties in a JavaScript object.
73
-
74
- For example, to access a user's name, you might write:
75
-
76
- ```javascript
77
- $.users[userId].name
78
- ```
79
-
80
- This creates a "signal" pointing to that specific piece of data. Signals are smart pointers that know how to get and set data, and they automatically update your app when the data changes.
81
-
82
- #### Public and Private Collections
83
-
84
- In teamplay, data is organized into collections. There are two types:
85
-
86
- 1. **Public Collections**: These are shared across all users of your app. They typically start with a lowercase letter (e.g., `users`, `posts`).
87
-
88
- 2. **Private Collections**: These are specific to each user or session. They start with an underscore or dollar sign (e.g., `_session`, `$page`).
89
-
90
- ### Basic Operations on Signals
91
-
92
- Every signal in teamplay comes with a set of useful methods:
93
-
94
- - `.get()`: Retrieves the current value of the signal.
95
- - `.set(value)`: Updates the value of the signal.
96
- - `.del()`: Deletes the value (or removes an item from an array).
97
-
98
- Example:
99
-
100
- ```javascript
101
- // Get a user's name
102
- const name = $.users[userId].name.get()
103
-
104
- // Update a user's name
105
- $.users[userId].name.set('Alice')
106
-
107
- // Delete a user's profile picture
108
- $.users[userId].profilePicture.del()
109
- ```
110
-
111
- ### The `$()` Function: Creating Local Signals
112
-
113
- The `$()` function is a powerful tool for creating local, reactive values:
114
-
115
- 1. Creating a simple value:
116
-
117
- ```javascript
118
- const $count = $(0)
119
- console.log($count.get()) // Outputs: 0
120
- $count.set(5)
121
- console.log($count.get()) // Outputs: 5
122
- ```
123
-
124
- 2. Creating a computed value (similar to a calculated spreadsheet cell):
125
-
126
- ```javascript
127
- const $firstName = $('John')
128
- const $lastName = $('Doe')
129
- const $fullName = $(() => $firstName.get() + ' ' + $lastName.get())
130
-
131
- console.log($fullName.get()) // Outputs: "John Doe"
132
- $firstName.set('Jane')
133
- console.log($fullName.get()) // Outputs: "Jane Doe"
134
- ```
135
-
136
- ### The `sub()` Function: Subscribing to Data
137
-
138
- The `sub()` function is used to subscribe to data from the server:
139
-
140
- 1. Subscribing to a single document:
141
-
142
- ```javascript
143
- const $user = await sub($.users[userId])
144
- console.log($user.name.get())
145
- ```
146
-
147
- 2. Subscribing to a query (multiple documents):
148
-
149
- ```javascript
150
- const $activeUsers = await sub($.users, { status: 'active' })
151
- ```
152
-
153
- #### Working with Query Signals
154
-
155
- Query signals are special. They behave like a collection signal, but they're also iterable:
156
-
157
- ```javascript
158
- // Iterate over active users
159
- for (const $user of $activeUsers) {
160
- console.log($user.name.get())
161
- }
162
-
163
- // Or use array methods
164
- const names = $activeUsers.map($user => $user.name.get())
165
- ```
166
-
167
- Each `$user` in the loop is a scoped signal for that specific user document.
168
-
169
- ### Reactivity: Keeping Your App in Sync
170
-
171
- teamplay's reactivity system ensures that whenever data changes, any part of your app using that data updates automatically. This happens behind the scenes, so you don't have to manually track and update data dependencies.
172
-
173
- For example, if you're displaying a user's name in your app and that name changes in the database, teamplay will automatically update your app's UI to reflect the new name.
174
-
175
- This reactivity works for both public and private collections, local signals created with `$()`, and subscribed data from `sub()`.
176
-
177
- By using these tools and concepts, you can build powerful, real-time applications with ease using teamplay!
178
-
179
- ## Examples
180
-
181
- For a simple working react app see [/example](/example)
182
-
183
- ### Simplest example with server synchronization
184
-
185
- On the client we `connect()` to the server, and we have to wrap each React component into `observer()`:
186
-
187
- ```js
188
- // client.js
189
- import { createRoot } from 'react-dom/client'
190
- import connect from 'teamplay/connect'
191
- import { observer, $, sub } from 'teamplay'
192
-
193
- connect()
194
-
195
- const App = observer(({ userId }) => {
196
- const $user = sub($.users[userId])
197
- if (!$user.get()) throw $user.set({ points: 0 })
198
- const { $points } = $user
199
- const increment = () => $points.set($points.get() + 1)
200
- return <button onClick={increment}>Points: {$points.get()}</button>
201
- })
202
-
203
- const container = document.body.appendChild(document.createElement('div'))
204
- createRoot(container).render(<App userId='_1' />)
205
- ```
206
-
207
- On the server we create the ShareDB backend and initialize the WebSocket connections handler:
208
-
209
- ```js
210
- // server.js
211
- import http from 'http'
212
- import { createBackend, initConnection } from 'teamplay/server'
213
-
214
- const server = http.createServer() // you can pass expressApp here if needed
215
- const backend = createBackend()
216
- const { upgrade } = initConnection(backend)
217
-
218
- server.on('upgrade', upgrade)
219
-
220
- server.listen(3000, () => {
221
- console.log('Server started. Open http://localhost:3000 in your browser')
222
- })
223
- ```
20
+ For installation and documentation see [teamplay.dev](https://teamplay.dev)
224
21
 
225
22
  ## License
226
23
 
package/index.js CHANGED
@@ -18,7 +18,7 @@ export { default as useSub } from './react/useSub.js'
18
18
  export { default as observer } from './react/observer.js'
19
19
  export { connection, setConnection, getConnection, fetchOnly, setFetchOnly, publicOnly, setPublicOnly } from './orm/connection.js'
20
20
  export { GUID_PATTERN, hasMany, hasOne, hasManyFlags, belongsTo, pickFormFields } from '@teamplay/schema'
21
- export { aggregation, aggregationHeader } from '@teamplay/utils/aggregation'
21
+ export { aggregation, aggregationHeader as __aggregationHeader } from '@teamplay/utils/aggregation'
22
22
 
23
23
  export function getRootSignal (options) {
24
24
  return _getRootSignal({
@@ -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.16",
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.16",
27
- "@teamplay/cache": "^0.1.16",
28
- "@teamplay/channel": "^0.1.16",
29
- "@teamplay/debug": "^0.1.16",
30
- "@teamplay/schema": "^0.1.16",
31
- "@teamplay/utils": "^0.1.16",
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": "f6a404f330ec3b88d498fa08fbcdc65cb80e8bb4"
66
+ "gitHead": "34435bba6654d480cac0e809defc15e1d15bd543"
67
67
  }
package/react/useSub.js CHANGED
@@ -1,13 +1,38 @@
1
- import { useRef } from 'react'
1
+ import { useRef, useDeferredValue } from 'react'
2
2
  import sub from '../orm/sub.js'
3
3
 
4
+ let TEST_THROTTLING = false
5
+
4
6
  // version of sub() which works as a react hook and throws promise for Suspense
5
- export default function useSub (...args) {
6
- const promiseOrSignal = sub(...args)
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)
7
12
  // 1. if it's a promise, throw it so that Suspense can catch it and wait for subscription to finish
8
- if (promiseOrSignal.then) throw promiseOrSignal
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
+ }
9
24
  // 2. if it's a signal, we save it into ref to make sure it's not garbage collected while component exists
10
25
  const $signalRef = useRef() // eslint-disable-line react-hooks/rules-of-hooks
11
26
  if ($signalRef.current !== promiseOrSignal) $signalRef.current = promiseOrSignal
12
27
  return promiseOrSignal
13
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
+ }