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 +1 -204
- package/index.js +1 -1
- package/orm/Aggregation.js +68 -0
- package/orm/Query.js +41 -31
- package/orm/Signal.js +59 -4
- package/orm/dataTree.js +7 -2
- package/orm/getSignal.js +2 -1
- package/orm/sub.js +50 -11
- package/package.json +8 -8
- package/react/useSub.js +29 -4
- package/utils/setDiffDeep.js +28 -0
package/README.md
CHANGED
|
@@ -17,210 +17,7 @@ Features:
|
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
19
19
|
|
|
20
|
-
|
|
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 (
|
|
23
|
-
this.
|
|
22
|
+
constructor (collectionName, params) {
|
|
23
|
+
this.collectionName = collectionName
|
|
24
24
|
this.params = params
|
|
25
|
-
this.hash = hashQuery(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(({
|
|
204
|
+
this.fr = new FinalizationRegistry(({ collectionName, params }) => this.destroy(collectionName, params))
|
|
204
205
|
}
|
|
205
206
|
|
|
206
207
|
subscribe ($query) {
|
|
207
|
-
const
|
|
208
|
+
const collectionName = $query[COLLECTION_NAME]
|
|
208
209
|
const params = JSON.parse(JSON.stringify($query[PARAMS]))
|
|
209
|
-
const hash =
|
|
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, {
|
|
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
|
|
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
|
|
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 (
|
|
248
|
-
const hash = hashQuery(
|
|
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 (
|
|
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: [
|
|
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 (
|
|
273
|
+
export function getQuerySignal (collectionName, params, options) {
|
|
266
274
|
params = JSON.parse(JSON.stringify(params))
|
|
267
|
-
const hash = hashQuery(
|
|
275
|
+
const hash = hashQuery(collectionName, params)
|
|
268
276
|
|
|
269
|
-
const $query = getSignal(undefined,
|
|
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
|
-
|
|
282
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
66
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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$ (
|
|
31
|
-
if (typeof params !== 'object') throw Error(ERRORS.queryParamsObject(
|
|
32
|
-
const $query = getQuerySignal(
|
|
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: (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
27
|
-
"@teamplay/cache": "^0.
|
|
28
|
-
"@teamplay/channel": "^0.
|
|
29
|
-
"@teamplay/debug": "^0.
|
|
30
|
-
"@teamplay/schema": "^0.
|
|
31
|
-
"@teamplay/utils": "^0.
|
|
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": "
|
|
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 (
|
|
6
|
-
|
|
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)
|
|
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
|
+
}
|