teamplay 0.0.1

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 ADDED
@@ -0,0 +1,95 @@
1
+ # TeamPlay
2
+
3
+ > Deep signals ORM for React with real-time collaboration
4
+
5
+ Features:
6
+
7
+ - signals __*__
8
+ - multiplayer __**__
9
+ - ORM
10
+ - auto-sync data from client to DB and vice-versa __***__
11
+ - query DB directly from client __***__
12
+
13
+ > __*__ deep signals -- with support for objects and arrays\
14
+ > __**__ concurrent changes to the same data are auto-merged using [OT](https://en.wikipedia.org/wiki/Operational_transformation)\
15
+ > __***__ similar to Firebase but with your own MongoDB-compatible database
16
+
17
+ ## Installation
18
+
19
+ ### Client-only mode
20
+
21
+ 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).
22
+
23
+ ### Synchronization of data with server
24
+
25
+ Enable the connection on client somewhere early in your client app:
26
+
27
+ ```js
28
+ import connect from 'teamplay/connect'
29
+ connect()
30
+ ```
31
+
32
+ And on the server, manually create a [ShareDB's backend](https://share.github.io/sharedb/api/backend) and create a connection handler for WebSockets:
33
+
34
+ ```js
35
+ import { initConnection } from 'teamplay/server'
36
+ const { upgrade } = initConnection(backend) // ShareDB's Backend instance
37
+ server.on('upgrade', upgrade) // Node's 'http' server instance
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ TBD
43
+
44
+ ## Examples
45
+
46
+ ### Simplest example with server synchronization
47
+
48
+ On the client we `connect()` to the server, and we have to wrap each React component into `observer()`:
49
+
50
+ ```js
51
+ // client.js
52
+ import connect from 'teamplay/connect'
53
+ import { observer, $, sub } from 'teamplay'
54
+ import { createRoot } from 'react-dom/client'
55
+ import { createElement as el } from 'react'
56
+
57
+ connect()
58
+
59
+ const App = observer(({ userId }) => {
60
+ const $user = sub($.users[userId])
61
+ if (!$user.get()) throw $user.set({ points: 0 })
62
+ const { $points } = $user
63
+ const onClick = () => $points.set($points.get() + 1)
64
+ return el('button', { onClick }, 'Points: ' + $points.get())
65
+ })
66
+
67
+ const container = document.body.appendChild(document.createElement('div'))
68
+ createRoot(container).render(
69
+ el(App, { userId: '_1' })
70
+ )
71
+ ```
72
+
73
+ On the server we create the ShareDB backend and initialize the WebSocket connections handler:
74
+
75
+ ```js
76
+ // server.js
77
+ import http from 'http'
78
+ import { ShareDB, initConnection } from 'teamplay/server'
79
+
80
+ const server = http.createServer() // you can pass expressApp here if needed
81
+ const backend = new ShareDB()
82
+ const { upgrade } = initConnection(backend)
83
+
84
+ server.on('upgrade', upgrade)
85
+
86
+ server.listen(3000)
87
+ ```
88
+
89
+ ShareDB is a re-export of [`sharedb`](https://github.com/share/sharedb) library, check its docs for more info.
90
+ - for persistency and queries support pass [`sharedb-mongo`](https://github.com/share/sharedb-mongo) (which uses MongoDB) as `{ db }`
91
+ - when deploying to a cluster with multiple instances you also have to provide `{ pubsub }` like [`sharedb-redis-pubsub`](https://github.com/share/sharedb-redis-pubsub) (which uses Redis)
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,12 @@
1
+ import Socket from '@startupjs/channel'
2
+ import Connection from './sharedbConnection.cjs'
3
+ import { connection, setConnection } from '../orm/connection.js'
4
+
5
+ export default function connect ({
6
+ baseUrl,
7
+ ...options
8
+ } = {}) {
9
+ if (connection) return
10
+ const socket = new Socket({ baseUrl, ...options })
11
+ setConnection(new Connection(socket))
12
+ }
@@ -0,0 +1,3 @@
1
+ const ShareDB = require('sharedb/lib/client')
2
+
3
+ module.exports = ShareDB.Connection
@@ -0,0 +1,12 @@
1
+ // mock of client connection to sharedb to use inside tests.
2
+ // This just creates a sharedb server with in-memory database
3
+ // and creates a server connection to it.
4
+ import ShareBackend from 'sharedb'
5
+ import ShareDbMingo from 'sharedb-mingo-memory'
6
+ import { connection, setConnection } from '../orm/connection.js'
7
+
8
+ export default function connect () {
9
+ if (connection) return
10
+ const backend = new ShareBackend({ db: new ShareDbMingo() })
11
+ setConnection(backend.connect())
12
+ }
package/index.js ADDED
@@ -0,0 +1,44 @@
1
+ // NOTE:
2
+ // $() and sub() are currently set to be universal ones which work in both
3
+ // plain JS and React environments. In React they are tied to the observer() HOC.
4
+ // This is done to simplify the API.
5
+ // In future, we might want to separate the plain JS and React APIs
6
+ import { getRootSignal as _getRootSignal, GLOBAL_ROOT_ID } from './orm/Root.js'
7
+ import universal$ from './react/universal$.js'
8
+
9
+ export { default as Signal, SEGMENTS } from './orm/Signal.js'
10
+ export { __DEBUG_SIGNALS_CACHE__, rawSignal, getSignalClass } from './orm/getSignal.js'
11
+ export { default as addModel } from './orm/addModel.js'
12
+ export { default as signal } from './orm/getSignal.js'
13
+ export { GLOBAL_ROOT_ID } from './orm/Root.js'
14
+ export const $ = _getRootSignal({ rootId: GLOBAL_ROOT_ID, rootFunction: universal$ })
15
+ export default $
16
+ export { default as sub } from './react/universalSub.js'
17
+ export { default as observer } from './react/observer.js'
18
+ export { connection, setConnection, getConnection, fetchOnly, setFetchOnly } from './orm/connection.js'
19
+ export * from './schema/associations.js'
20
+ export { default as GUID_PATTERN } from './schema/GUID_PATTERN.js'
21
+ export { default as pickFormFields } from './schema/pickFormFields.js'
22
+
23
+ export function getRootSignal (options) {
24
+ return _getRootSignal({
25
+ rootFunction: universal$,
26
+ ...options
27
+ })
28
+ }
29
+
30
+ // the following are react-specific hook alternatives to $() and sub() functions.
31
+ // In future we might want to expose them, but at the current time they are not needed
32
+ // and instead just the regular $() and sub() functions are used since they are universal
33
+ //
34
+ // export function use$ (value) {
35
+ // // TODO: maybe replace all non-letter/digit characters with underscores
36
+ // const id = useId() // eslint-disable-line react-hooks/rules-of-hooks
37
+ // return $(value, id)
38
+ // }
39
+
40
+ // export function useSub (...args) {
41
+ // const promiseOrSignal = sub(...args)
42
+ // if (promiseOrSignal.then) throw promiseOrSignal
43
+ // return promiseOrSignal
44
+ // }
package/orm/$.js ADDED
@@ -0,0 +1,38 @@
1
+ // this is just the $() function implementation.
2
+ // The actual $ exported from this package is a Proxy targeting the dataTree root,
3
+ // and this function is an implementation of the `apply` handler for that Proxy.
4
+ import getSignal from './getSignal.js'
5
+ import Signal from './Signal.js'
6
+ import { LOCAL, valueSubscriptions } from './Value.js'
7
+ import { reactionSubscriptions } from './Reaction.js'
8
+
9
+ export { LOCAL } from './Value.js'
10
+
11
+ let counter = 0
12
+
13
+ function newIncrementalId () {
14
+ const id = `_${counter}`
15
+ counter += 1
16
+ return id
17
+ }
18
+
19
+ export default function $ ($root, value, id) {
20
+ if (!($root instanceof Signal)) throw Error('First argument of $() should be a Root Signal')
21
+ if (typeof value === 'function') {
22
+ return reaction$($root, value, id)
23
+ } else {
24
+ return value$($root, value, id)
25
+ }
26
+ }
27
+
28
+ function value$ ($root, value, id = newIncrementalId()) {
29
+ const $value = getSignal($root, [LOCAL, id])
30
+ valueSubscriptions.init($value, value)
31
+ return $value
32
+ }
33
+
34
+ function reaction$ ($root, fn, id = newIncrementalId()) {
35
+ const $value = getSignal($root, [LOCAL, id])
36
+ reactionSubscriptions.init($value, fn)
37
+ return $value
38
+ }
package/orm/Cache.js ADDED
@@ -0,0 +1,29 @@
1
+ export default class Cache {
2
+ constructor () {
3
+ this.cache = new Map()
4
+ this.fr = new FinalizationRegistry(([key]) => this.delete(key))
5
+ }
6
+
7
+ get (key) {
8
+ return this.cache.get(key)?.deref()
9
+ }
10
+
11
+ /**
12
+ * @param {string} key
13
+ * @param {*} value
14
+ * @param {Array} inputs - extra inputs to register with the finalization registry
15
+ * to hold strong references to them until the value is garbage collected
16
+ */
17
+ set (key, value, inputs = []) {
18
+ this.cache.set(key, new WeakRef(value))
19
+ this.fr.register(value, [key, ...inputs])
20
+ }
21
+
22
+ delete (key) {
23
+ this.cache.delete(key)
24
+ }
25
+
26
+ get size () {
27
+ return this.cache.size
28
+ }
29
+ }
package/orm/Doc.js ADDED
@@ -0,0 +1,232 @@
1
+ import { isObservable, observable } from '@nx-js/observer-util'
2
+ import { set as _set, del as _del } from './dataTree.js'
3
+ import { SEGMENTS } from './Signal.js'
4
+ import { getConnection, fetchOnly } from './connection.js'
5
+
6
+ const ERROR_ON_EXCESSIVE_UNSUBSCRIBES = false
7
+
8
+ class Doc {
9
+ subscribing
10
+ unsubscribing
11
+ subscribed
12
+ initialized
13
+
14
+ constructor (collection, docId) {
15
+ this.collection = collection
16
+ this.docId = docId
17
+ this.init()
18
+ }
19
+
20
+ init () {
21
+ if (this.initialized) return
22
+ this.initialized = true
23
+ this._initData()
24
+ }
25
+
26
+ async subscribe () {
27
+ if (this.subscribed) throw Error('trying to subscribe while already subscribed')
28
+ this.subscribed = true
29
+ // if we are in the middle of unsubscribing, just wait for it to finish and then resubscribe
30
+ if (this.unsubscribing) {
31
+ try {
32
+ await this.unsubscribing
33
+ } catch (err) {
34
+ // if error happened during unsubscribing, it means that we are still subscribed
35
+ // so we don't need to do anything
36
+ return
37
+ }
38
+ }
39
+ if (this.subscribing) {
40
+ try {
41
+ await this.subscribing
42
+ // if we are already subscribing from the previous time, delegate logic to that
43
+ // and if it finished successfully, we are done.
44
+ return
45
+ } catch (err) {
46
+ // if error happened during subscribing, we'll just try subscribing again
47
+ // so we just ignore the error and proceed with subscribing
48
+ this.subscribed = true
49
+ }
50
+ }
51
+
52
+ if (!this.subscribed) return // cancel if we initiated unsubscribe while waiting
53
+
54
+ this.subscribing = (async () => {
55
+ try {
56
+ this.subscribing = this._subscribe()
57
+ await this.subscribing
58
+ this.init()
59
+ } catch (err) {
60
+ console.log('subscription error', [this.collection, this.docId], err)
61
+ this.subscribed = undefined
62
+ throw err
63
+ } finally {
64
+ this.subscribing = undefined
65
+ }
66
+ })()
67
+ await this.subscribing
68
+ }
69
+
70
+ async _subscribe () {
71
+ const doc = getConnection().get(this.collection, this.docId)
72
+ await new Promise((resolve, reject) => {
73
+ const method = fetchOnly ? 'fetch' : 'subscribe'
74
+ doc[method](err => {
75
+ if (err) return reject(err)
76
+ resolve()
77
+ })
78
+ })
79
+ }
80
+
81
+ async unsubscribe () {
82
+ if (!this.subscribed) throw Error('trying to unsubscribe while not subscribed')
83
+ this.subscribed = undefined
84
+ // if we are still handling the subscription, just wait for it to finish and then unsubscribe
85
+ if (this.subscribing) {
86
+ try {
87
+ await this.subscribing
88
+ } catch (err) {
89
+ // if error happened during subscribing, it means that we are still unsubscribed
90
+ // so we don't need to do anything
91
+ return
92
+ }
93
+ }
94
+ // if we are already unsubscribing from the previous time, delegate logic to that
95
+ if (this.unsubscribing) {
96
+ try {
97
+ await this.unsubscribing
98
+ return
99
+ } catch (err) {
100
+ // if error happened during unsubscribing, we'll just try unsubscribing again
101
+ this.subscribed = undefined
102
+ }
103
+ }
104
+
105
+ if (this.subscribed) return // cancel if we initiated subscribe while waiting
106
+
107
+ this.unsubscribing = (async () => {
108
+ try {
109
+ await this._unsubscribe()
110
+ this.initialized = undefined
111
+ this._removeData()
112
+ } catch (err) {
113
+ console.log('error unsubscribing', [this.collection, this.docId], err)
114
+ this.subscribed = true
115
+ throw err
116
+ } finally {
117
+ this.unsubscribing = undefined
118
+ }
119
+ })()
120
+ await this.unsubscribing
121
+ }
122
+
123
+ async _unsubscribe () {
124
+ const doc = getConnection().get(this.collection, this.docId)
125
+ await new Promise((resolve, reject) => {
126
+ doc.destroy(err => {
127
+ if (err) return reject(err)
128
+ resolve()
129
+ })
130
+ })
131
+ }
132
+
133
+ _initData () {
134
+ const doc = getConnection().get(this.collection, this.docId)
135
+ // TODO: JSON does not have `undefined`, so we'll be receiving `null`.
136
+ // Handle this by converting all `null` to `undefined` in the doc's data tree.
137
+ // To do this we'll probably need to in the `op` event update the data tree
138
+ // and have a clone of the doc in our local data tree.
139
+ this._refData()
140
+ doc.on('load', () => this._refData())
141
+ doc.on('create', () => this._refData())
142
+ doc.on('del', () => _del([this.collection, this.docId]))
143
+ }
144
+
145
+ _refData () {
146
+ const doc = getConnection().get(this.collection, this.docId)
147
+ if (isObservable(doc.data)) return
148
+ if (doc.data == null) return
149
+ _set([this.collection, this.docId], doc.data)
150
+ doc.data = observable(doc.data)
151
+ }
152
+
153
+ _removeData () {
154
+ _del([this.collection, this.docId])
155
+ }
156
+ }
157
+
158
+ class DocSubscriptions {
159
+ constructor () {
160
+ this.subCount = new Map()
161
+ this.docs = new Map()
162
+ this.initialized = new Map()
163
+ this.fr = new FinalizationRegistry(segments => this.destroy(segments))
164
+ }
165
+
166
+ init ($doc) {
167
+ const segments = [...$doc[SEGMENTS]]
168
+ const hash = hashDoc(segments)
169
+ if (this.initialized.has(hash)) return
170
+ this.initialized.set(hash, true)
171
+
172
+ this.fr.register($doc, segments, $doc)
173
+
174
+ let doc = this.docs.get(hash)
175
+ if (!doc) {
176
+ doc = new Doc(...segments)
177
+ this.docs.set(hash, doc)
178
+ }
179
+ doc.init()
180
+ }
181
+
182
+ subscribe ($doc) {
183
+ const segments = [...$doc[SEGMENTS]]
184
+ const hash = hashDoc(segments)
185
+ let count = this.subCount.get(hash) || 0
186
+ count += 1
187
+ this.subCount.set(hash, count)
188
+ if (count > 1) return this.docs.get(hash).subscribing
189
+
190
+ this.init($doc)
191
+ const doc = this.docs.get(hash)
192
+ return doc.subscribe()
193
+ }
194
+
195
+ async unsubscribe ($doc) {
196
+ const segments = [...$doc[SEGMENTS]]
197
+ const hash = hashDoc(segments)
198
+ let count = this.subCount.get(hash) || 0
199
+ count -= 1
200
+ if (count < 0) {
201
+ if (ERROR_ON_EXCESSIVE_UNSUBSCRIBES) throw ERRORS.notSubscribed($doc)
202
+ return
203
+ }
204
+ if (count > 0) {
205
+ this.subCount.set(hash, count)
206
+ return
207
+ }
208
+ this.fr.unregister($doc)
209
+ this.destroy(segments)
210
+ }
211
+
212
+ async destroy (segments) {
213
+ const hash = hashDoc(segments)
214
+ const doc = this.docs.get(hash)
215
+ if (!doc) return
216
+ this.subCount.delete(hash)
217
+ this.initialized.delete(hash)
218
+ await doc.unsubscribe()
219
+ if (doc.subscribed) return // if we subscribed again while waiting for unsubscribe, we don't delete the doc
220
+ this.docs.delete(hash)
221
+ }
222
+ }
223
+
224
+ export const docSubscriptions = new DocSubscriptions()
225
+
226
+ function hashDoc (segments) {
227
+ return JSON.stringify(segments)
228
+ }
229
+
230
+ const ERRORS = {
231
+ notSubscribed: $doc => Error('trying to unsubscribe when not subscribed. Doc: ' + $doc.path())
232
+ }