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 +95 -0
- package/connect/index.js +12 -0
- package/connect/sharedbConnection.cjs +3 -0
- package/connect/test.js +12 -0
- package/index.js +44 -0
- package/orm/$.js +38 -0
- package/orm/Cache.js +29 -0
- package/orm/Doc.js +232 -0
- package/orm/Query.js +278 -0
- package/orm/Reaction.js +44 -0
- package/orm/Root.js +41 -0
- package/orm/Signal.js +211 -0
- package/orm/Value.js +27 -0
- package/orm/addModel.js +31 -0
- package/orm/connection.js +26 -0
- package/orm/dataTree.js +196 -0
- package/orm/getSignal.js +100 -0
- package/orm/sub.js +32 -0
- package/package.json +60 -0
- package/react/convertToObserver.js +93 -0
- package/react/executionContextTracker.js +32 -0
- package/react/helpers.js +35 -0
- package/react/observer.js +9 -0
- package/react/trapRender.js +45 -0
- package/react/universal$.js +9 -0
- package/react/universalSub.js +9 -0
- package/react/wrapIntoSuspense.js +63 -0
- package/schema/GUID_PATTERN.js +1 -0
- package/schema/associations.js +51 -0
- package/schema/pickFormFields.js +104 -0
- package/server.js +12 -0
- package/utils/uuid.cjs +2 -0
package/orm/Query.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { raw } from '@nx-js/observer-util'
|
|
2
|
+
import { get as _get, set as _set, del as _del } from './dataTree.js'
|
|
3
|
+
import { SEGMENTS } from './Signal.js'
|
|
4
|
+
import getSignal from './getSignal.js'
|
|
5
|
+
import { getConnection, fetchOnly } from './connection.js'
|
|
6
|
+
import { docSubscriptions } from './Doc.js'
|
|
7
|
+
|
|
8
|
+
const ERROR_ON_EXCESSIVE_UNSUBSCRIBES = false
|
|
9
|
+
export const PARAMS = Symbol('query params')
|
|
10
|
+
export const HASH = Symbol('query hash')
|
|
11
|
+
export const IS_QUERY = Symbol('is query signal')
|
|
12
|
+
export const QUERIES = '$queries'
|
|
13
|
+
|
|
14
|
+
class Query {
|
|
15
|
+
subscribing
|
|
16
|
+
unsubscribing
|
|
17
|
+
subscribed
|
|
18
|
+
initialized
|
|
19
|
+
shareQuery
|
|
20
|
+
|
|
21
|
+
constructor (collection, params) {
|
|
22
|
+
this.collection = collection
|
|
23
|
+
this.params = params
|
|
24
|
+
this.hash = hashQuery([this.collection], this.params)
|
|
25
|
+
this.docSignals = new Set()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
init () {
|
|
29
|
+
if (this.initialized) return
|
|
30
|
+
this.initialized = true
|
|
31
|
+
this._initData()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async subscribe () {
|
|
35
|
+
if (this.subscribed) throw Error('trying to subscribe while already subscribed')
|
|
36
|
+
this.subscribed = true
|
|
37
|
+
// if we are in the middle of unsubscribing, just wait for it to finish and then resubscribe
|
|
38
|
+
if (this.unsubscribing) {
|
|
39
|
+
try {
|
|
40
|
+
await this.unsubscribing
|
|
41
|
+
} catch (err) {
|
|
42
|
+
// if error happened during unsubscribing, it means that we are still subscribed
|
|
43
|
+
// so we don't need to do anything
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (this.subscribing) {
|
|
48
|
+
try {
|
|
49
|
+
await this.subscribing
|
|
50
|
+
// if we are already subscribing from the previous time, delegate logic to that
|
|
51
|
+
// and if it finished successfully, we are done.
|
|
52
|
+
return
|
|
53
|
+
} catch (err) {
|
|
54
|
+
// if error happened during subscribing, we'll just try subscribing again
|
|
55
|
+
// so we just ignore the error and proceed with subscribing
|
|
56
|
+
this.subscribed = true
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!this.subscribed) return // cancel if we initiated unsubscribe while waiting
|
|
61
|
+
|
|
62
|
+
this.subscribing = (async () => {
|
|
63
|
+
try {
|
|
64
|
+
this.subscribing = this._subscribe()
|
|
65
|
+
await this.subscribing
|
|
66
|
+
this.init()
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.log('subscription error', [this.collection, this.params], err)
|
|
69
|
+
this.subscribed = undefined
|
|
70
|
+
throw err
|
|
71
|
+
} finally {
|
|
72
|
+
this.subscribing = undefined
|
|
73
|
+
}
|
|
74
|
+
})()
|
|
75
|
+
await this.subscribing
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async _subscribe () {
|
|
79
|
+
await new Promise((resolve, reject) => {
|
|
80
|
+
const method = fetchOnly ? 'createFetchQuery' : 'createSubscribeQuery'
|
|
81
|
+
this.shareQuery = getConnection()[method](this.collection, this.params, {}, err => {
|
|
82
|
+
if (err) return reject(err)
|
|
83
|
+
resolve()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async unsubscribe () {
|
|
89
|
+
if (!this.subscribed) throw Error('trying to unsubscribe while not subscribed')
|
|
90
|
+
this.subscribed = undefined
|
|
91
|
+
// if we are still handling the subscription, just wait for it to finish and then unsubscribe
|
|
92
|
+
if (this.subscribing) {
|
|
93
|
+
try {
|
|
94
|
+
await this.subscribing
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// if error happened during subscribing, it means that we are still unsubscribed
|
|
97
|
+
// so we don't need to do anything
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// if we are already unsubscribing from the previous time, delegate logic to that
|
|
102
|
+
if (this.unsubscribing) {
|
|
103
|
+
try {
|
|
104
|
+
await this.unsubscribing
|
|
105
|
+
return
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// if error happened during unsubscribing, we'll just try unsubscribing again
|
|
108
|
+
this.subscribed = undefined
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (this.subscribed) return // cancel if we initiated subscribe while waiting
|
|
113
|
+
|
|
114
|
+
this.unsubscribing = (async () => {
|
|
115
|
+
try {
|
|
116
|
+
await this._unsubscribe()
|
|
117
|
+
this.initialized = undefined
|
|
118
|
+
this._removeData()
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.log('error unsubscribing', [this.collection, this.params], err)
|
|
121
|
+
this.subscribed = true
|
|
122
|
+
throw err
|
|
123
|
+
} finally {
|
|
124
|
+
this.unsubscribing = undefined
|
|
125
|
+
}
|
|
126
|
+
})()
|
|
127
|
+
await this.unsubscribing
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async _unsubscribe () {
|
|
131
|
+
if (!this.shareQuery) throw Error('this.shareQuery is not defined. This should never happen')
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
this.shareQuery.destroy(err => {
|
|
134
|
+
if (err) return reject(err)
|
|
135
|
+
resolve()
|
|
136
|
+
})
|
|
137
|
+
this.shareQuery = undefined
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_initData () {
|
|
142
|
+
{ // reference the fetched docs
|
|
143
|
+
const docs = this.shareQuery.results.map(doc => raw(doc.data))
|
|
144
|
+
_set([QUERIES, this.hash, 'docs'], docs)
|
|
145
|
+
|
|
146
|
+
const ids = this.shareQuery.results.map(doc => doc.id)
|
|
147
|
+
for (const docId of ids) {
|
|
148
|
+
const $doc = getSignal(undefined, [this.collection, docId])
|
|
149
|
+
docSubscriptions.init($doc)
|
|
150
|
+
this.docSignals.add($doc)
|
|
151
|
+
}
|
|
152
|
+
_set([QUERIES, this.hash, 'ids'], ids)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
this.shareQuery.on('insert', (shareDocs, index) => {
|
|
156
|
+
const newDocs = shareDocs.map(doc => raw(doc.data))
|
|
157
|
+
_get([QUERIES, this.hash, 'docs']).splice(index, 0, ...newDocs)
|
|
158
|
+
|
|
159
|
+
const ids = shareDocs.map(doc => doc.id)
|
|
160
|
+
for (const docId of ids) {
|
|
161
|
+
const $doc = getSignal(undefined, [this.collection, docId])
|
|
162
|
+
docSubscriptions.init($doc)
|
|
163
|
+
this.docSignals.add($doc)
|
|
164
|
+
}
|
|
165
|
+
_get([QUERIES, this.hash, 'ids']).splice(index, 0, ...ids)
|
|
166
|
+
})
|
|
167
|
+
this.shareQuery.on('move', (shareDocs, from, to) => {
|
|
168
|
+
const docs = _get([QUERIES, this.hash, 'docs'])
|
|
169
|
+
docs.splice(from, shareDocs.length)
|
|
170
|
+
docs.splice(to, 0, ...shareDocs.map(doc => raw(doc.data)))
|
|
171
|
+
|
|
172
|
+
const ids = _get([QUERIES, this.hash, 'ids'])
|
|
173
|
+
ids.splice(from, shareDocs.length)
|
|
174
|
+
ids.splice(to, 0, ...shareDocs.map(doc => doc.id))
|
|
175
|
+
})
|
|
176
|
+
this.shareQuery.on('remove', (shareDocs, index) => {
|
|
177
|
+
const docs = _get([QUERIES, this.hash, 'docs'])
|
|
178
|
+
docs.splice(index, shareDocs.length)
|
|
179
|
+
|
|
180
|
+
const docIds = shareDocs.map(doc => doc.id)
|
|
181
|
+
for (const docId of docIds) {
|
|
182
|
+
const $doc = getSignal(undefined, [this.collection, docId])
|
|
183
|
+
this.docSignals.delete($doc)
|
|
184
|
+
}
|
|
185
|
+
const ids = _get([QUERIES, this.hash, 'ids'])
|
|
186
|
+
ids.splice(index, docIds.length)
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
_removeData () {
|
|
191
|
+
this.docSignals.clear()
|
|
192
|
+
_del([QUERIES, this.hash])
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
class QuerySubscriptions {
|
|
197
|
+
constructor () {
|
|
198
|
+
this.subCount = new Map()
|
|
199
|
+
this.queries = new Map()
|
|
200
|
+
this.fr = new FinalizationRegistry(({ segments, params }) => this.destroy(segments, params))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
subscribe ($query) {
|
|
204
|
+
const segments = [...$query[SEGMENTS]]
|
|
205
|
+
const params = JSON.parse(JSON.stringify($query[PARAMS]))
|
|
206
|
+
const hash = hashQuery(segments, params)
|
|
207
|
+
let count = this.subCount.get(hash) || 0
|
|
208
|
+
count += 1
|
|
209
|
+
this.subCount.set(hash, count)
|
|
210
|
+
if (count > 1) return this.queries.get(hash).subscribing
|
|
211
|
+
|
|
212
|
+
this.fr.register($query, { segments, params }, $query)
|
|
213
|
+
|
|
214
|
+
let query = this.queries.get(hash)
|
|
215
|
+
if (!query) {
|
|
216
|
+
query = new Query(segments[0], params)
|
|
217
|
+
this.queries.set(hash, query)
|
|
218
|
+
}
|
|
219
|
+
return query.subscribe()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async unsubscribe ($query) {
|
|
223
|
+
const segments = [...$query[SEGMENTS]]
|
|
224
|
+
const params = JSON.parse(JSON.stringify($query[PARAMS]))
|
|
225
|
+
const hash = hashQuery(segments, params)
|
|
226
|
+
let count = this.subCount.get(hash) || 0
|
|
227
|
+
count -= 1
|
|
228
|
+
if (count < 0) {
|
|
229
|
+
if (ERROR_ON_EXCESSIVE_UNSUBSCRIBES) throw ERRORS.notSubscribed($query)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
if (count > 0) {
|
|
233
|
+
this.subCount.set(hash, count)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
this.subCount.delete(hash)
|
|
237
|
+
this.fr.unregister($query)
|
|
238
|
+
const query = this.queries.get(hash)
|
|
239
|
+
await query.unsubscribe()
|
|
240
|
+
if (query.subscribed) return // if we subscribed again while waiting for unsubscribe, we don't delete the doc
|
|
241
|
+
this.queries.delete(hash)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async destroy (segments, params) {
|
|
245
|
+
const hash = hashQuery(segments, params)
|
|
246
|
+
const query = this.queries.get(hash)
|
|
247
|
+
if (!query) return
|
|
248
|
+
this.subCount.delete(hash)
|
|
249
|
+
await query.unsubscribe()
|
|
250
|
+
if (query.subscribed) return // if we subscribed again while waiting for unsubscribe, we don't delete the doc
|
|
251
|
+
this.queries.delete(hash)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const querySubscriptions = new QuerySubscriptions()
|
|
256
|
+
|
|
257
|
+
export function hashQuery (segments, params) {
|
|
258
|
+
// TODO: probably makes sense to use fast-stable-json-stringify for this because of the params
|
|
259
|
+
return JSON.stringify({ query: [segments[0], params] })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function getQuerySignal (segments, params, options) {
|
|
263
|
+
params = JSON.parse(JSON.stringify(params))
|
|
264
|
+
const hash = hashQuery(segments, params)
|
|
265
|
+
|
|
266
|
+
const $query = getSignal(undefined, segments, {
|
|
267
|
+
signalHash: hash,
|
|
268
|
+
...options
|
|
269
|
+
})
|
|
270
|
+
$query[IS_QUERY] ??= true
|
|
271
|
+
$query[PARAMS] ??= params
|
|
272
|
+
$query[HASH] ??= hash
|
|
273
|
+
return $query
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const ERRORS = {
|
|
277
|
+
notSubscribed: $doc => Error('trying to unsubscribe when not subscribed. Doc: ' + $doc.path())
|
|
278
|
+
}
|
package/orm/Reaction.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { observe, unobserve } from '@nx-js/observer-util'
|
|
2
|
+
import { SEGMENTS } from './Signal.js'
|
|
3
|
+
import { set as _set, del as _del } from './dataTree.js'
|
|
4
|
+
import { LOCAL } from './Value.js'
|
|
5
|
+
|
|
6
|
+
// this is `let` to be able to directly change it if needed in tests or in the app
|
|
7
|
+
export let DELETION_DELAY = 0 // eslint-disable-line prefer-const
|
|
8
|
+
|
|
9
|
+
class ReactionSubscriptions {
|
|
10
|
+
constructor () {
|
|
11
|
+
this.initialized = new Map()
|
|
12
|
+
this.fr = new FinalizationRegistry(([id, reaction]) => this.destroy(id, reaction))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
init ($value, fn) {
|
|
16
|
+
const id = $value[SEGMENTS][1]
|
|
17
|
+
if (this.initialized.has(id)) return
|
|
18
|
+
|
|
19
|
+
this.initialized.set(id, true)
|
|
20
|
+
const reactionScheduler = reaction => runReaction(id, reaction)
|
|
21
|
+
const reaction = observe(fn, { lazy: true, scheduler: reactionScheduler })
|
|
22
|
+
this.fr.register($value, [id, reaction])
|
|
23
|
+
runReaction(id, reaction)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
destroy (id, reaction) {
|
|
27
|
+
this.initialized.delete(id)
|
|
28
|
+
unobserve(reaction)
|
|
29
|
+
// don't delete data right away to prevent dependent reactions which are also going to be GC'ed
|
|
30
|
+
// from triggering unnecessarily
|
|
31
|
+
setTimeout(() => _del([LOCAL, id]), DELETION_DELAY)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const reactionSubscriptions = new ReactionSubscriptions()
|
|
36
|
+
|
|
37
|
+
function runReaction (id, reaction) {
|
|
38
|
+
const newValue = reaction()
|
|
39
|
+
_set([LOCAL, id], newValue)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function setDeletionDelay (delayInMs) {
|
|
43
|
+
DELETION_DELAY = delayInMs
|
|
44
|
+
}
|
package/orm/Root.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import getSignal from './getSignal.js'
|
|
2
|
+
|
|
3
|
+
export const ROOT_FUNCTION = Symbol('root function')
|
|
4
|
+
// TODO: in future make a connection spawnable instead of a singleton
|
|
5
|
+
// export const CONNECTION = Symbol('sharedb connection, used by sub() function')
|
|
6
|
+
export const ROOT = Symbol('root signal')
|
|
7
|
+
export const ROOT_ID = Symbol('root signal id. Used for caching')
|
|
8
|
+
|
|
9
|
+
export const GLOBAL_ROOT_ID = '__global__'
|
|
10
|
+
|
|
11
|
+
// TODO: create a separate local root for private collections
|
|
12
|
+
export function getRootSignal ({
|
|
13
|
+
rootFunction,
|
|
14
|
+
// connection,
|
|
15
|
+
rootId = '_' + createRandomString(8),
|
|
16
|
+
...options
|
|
17
|
+
}) {
|
|
18
|
+
const $root = getSignal(undefined, [], {
|
|
19
|
+
rootId,
|
|
20
|
+
...options
|
|
21
|
+
})
|
|
22
|
+
$root[ROOT_FUNCTION] ??= rootFunction
|
|
23
|
+
// $root[CONNECTION] ??= connection
|
|
24
|
+
$root[ROOT_ID] ??= rootId
|
|
25
|
+
return $root
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getRoot (signal) {
|
|
29
|
+
if (signal[ROOT]) return signal[ROOT]
|
|
30
|
+
else if (signal[ROOT_ID]) return signal
|
|
31
|
+
else return undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createRandomString (length) {
|
|
35
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
36
|
+
let result = ''
|
|
37
|
+
for (let i = 0; i < length; i++) {
|
|
38
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
|
39
|
+
}
|
|
40
|
+
return result
|
|
41
|
+
}
|
package/orm/Signal.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implementation of the BaseSignal class which is used as a base class for all signals
|
|
3
|
+
* and can be extended to create custom models for a particular path pattern of the data tree.
|
|
4
|
+
*
|
|
5
|
+
* All signals in the app should be created using getSignal() function which automatically
|
|
6
|
+
* determines the correct model for the given path pattern and wraps the signal object in a Proxy.
|
|
7
|
+
*
|
|
8
|
+
* Proxy is used for the following reasons:
|
|
9
|
+
* 1. To allow accessing child signals using dot syntax
|
|
10
|
+
* 2. To be able to call the top-level signal as a `$()` function
|
|
11
|
+
* 3. If extremely late bindings are enabled, to prevent name collisions when accessing fields
|
|
12
|
+
* in the raw data tree which have the same name as signal's methods
|
|
13
|
+
*/
|
|
14
|
+
import { get as _get, set as _set, del as _del, setPublicDoc as _setPublicDoc } from './dataTree.js'
|
|
15
|
+
import getSignal, { rawSignal } from './getSignal.js'
|
|
16
|
+
import { IS_QUERY, HASH, QUERIES } from './Query.js'
|
|
17
|
+
import { ROOT_FUNCTION, getRoot } from './Root.js'
|
|
18
|
+
import uuid from '../utils/uuid.cjs'
|
|
19
|
+
|
|
20
|
+
export const SEGMENTS = Symbol('path segments targeting the particular node in the data tree')
|
|
21
|
+
|
|
22
|
+
export default class Signal extends Function {
|
|
23
|
+
constructor (segments) {
|
|
24
|
+
if (!Array.isArray(segments)) throw Error('Signal constructor expects an array of segments')
|
|
25
|
+
super()
|
|
26
|
+
this[SEGMENTS] = segments
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
path () {
|
|
30
|
+
if (arguments.length > 0) throw Error('Signal.path() does not accept any arguments')
|
|
31
|
+
return this[SEGMENTS].join('.')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
id () {
|
|
35
|
+
return uuid()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get () {
|
|
39
|
+
if (arguments.length > 0) throw Error('Signal.get() does not accept any arguments')
|
|
40
|
+
if (this[IS_QUERY]) {
|
|
41
|
+
const hash = this[HASH]
|
|
42
|
+
return _get([QUERIES, hash, 'docs'])
|
|
43
|
+
}
|
|
44
|
+
return _get(this[SEGMENTS])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
* [Symbol.iterator] () {
|
|
48
|
+
if (this[IS_QUERY]) {
|
|
49
|
+
const ids = _get([QUERIES, this[HASH], 'ids'])
|
|
50
|
+
for (const id of ids) yield getSignal(getRoot(this), [this[SEGMENTS][0], id])
|
|
51
|
+
} else {
|
|
52
|
+
const items = _get(this[SEGMENTS])
|
|
53
|
+
if (!Array.isArray(items)) return
|
|
54
|
+
for (let i = 0; i < items.length; i++) yield getSignal(getRoot(this), [...this[SEGMENTS], i])
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
map (...args) {
|
|
59
|
+
if (this[IS_QUERY]) {
|
|
60
|
+
const collection = this[SEGMENTS][0]
|
|
61
|
+
const hash = this[HASH]
|
|
62
|
+
const ids = _get([QUERIES, hash, 'ids'])
|
|
63
|
+
return ids.map(id => getSignal(getRoot(this), [collection, id])).map(...args)
|
|
64
|
+
}
|
|
65
|
+
const items = _get(this[SEGMENTS])
|
|
66
|
+
if (!Array.isArray(items)) return []
|
|
67
|
+
return Array(items.length)
|
|
68
|
+
.fill()
|
|
69
|
+
.map((_, index) => getSignal(getRoot(this), [...this[SEGMENTS], index]))
|
|
70
|
+
.map(...args)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async set (value) {
|
|
74
|
+
if (arguments.length > 1) throw Error('Signal.set() expects a single argument')
|
|
75
|
+
if (this[SEGMENTS].length === 0) throw Error('Can\'t set the root signal data')
|
|
76
|
+
if (isPublicCollection(this[SEGMENTS][0])) {
|
|
77
|
+
await _setPublicDoc(this[SEGMENTS], value)
|
|
78
|
+
} else {
|
|
79
|
+
_set(this[SEGMENTS], value)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// TODO: make it use an actual increment json0 operation on public collections
|
|
84
|
+
async increment (value) {
|
|
85
|
+
if (arguments.length > 1) throw Error('Signal.increment() expects a single argument')
|
|
86
|
+
if (value === undefined) value = 1
|
|
87
|
+
if (typeof value !== 'number') throw Error('Signal.increment() expects a number argument')
|
|
88
|
+
let currentValue = this.get()
|
|
89
|
+
if (currentValue === undefined) currentValue = 0
|
|
90
|
+
if (typeof currentValue !== 'number') throw Error('Signal.increment() tried to increment a non-number value')
|
|
91
|
+
await this.set(currentValue + value)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async add (value) {
|
|
95
|
+
if (arguments.length > 1) throw Error('Signal.add() expects a single argument')
|
|
96
|
+
let id
|
|
97
|
+
if (value.id) {
|
|
98
|
+
value = JSON.parse(JSON.stringify(value))
|
|
99
|
+
id = value.id
|
|
100
|
+
delete value.id
|
|
101
|
+
}
|
|
102
|
+
id ??= uuid()
|
|
103
|
+
await this[id].set(value)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async del () {
|
|
107
|
+
if (arguments.length > 0) throw Error('Signal.del() does not accept any arguments')
|
|
108
|
+
if (this[SEGMENTS].length === 0) throw Error('Can\'t delete the root signal data')
|
|
109
|
+
if (isPublicCollection(this[SEGMENTS][0])) {
|
|
110
|
+
if (this[SEGMENTS].length === 1) throw Error('Can\'t delete the whole collection')
|
|
111
|
+
await _setPublicDoc(this[SEGMENTS], undefined, true)
|
|
112
|
+
} else {
|
|
113
|
+
_del(this[SEGMENTS])
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// clone () {}
|
|
118
|
+
// async assign () {}
|
|
119
|
+
// async push () {}
|
|
120
|
+
// async pop () {}
|
|
121
|
+
// async unshift () {}
|
|
122
|
+
// async shift () {}
|
|
123
|
+
// async splice () {}
|
|
124
|
+
// async move () {}
|
|
125
|
+
// async del () {}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// dot syntax returns a child signal only if no such method or property exists
|
|
129
|
+
export const regularBindings = {
|
|
130
|
+
apply (signal, thisArg, argumentsList) {
|
|
131
|
+
if (signal[SEGMENTS].length === 0) {
|
|
132
|
+
if (!signal[ROOT_FUNCTION]) throw Error(ERRORS.noRootFunction)
|
|
133
|
+
return signal[ROOT_FUNCTION].call(thisArg, signal, ...argumentsList)
|
|
134
|
+
}
|
|
135
|
+
throw Error('Signal can\'t be called as a function since extremely late bindings are disabled')
|
|
136
|
+
},
|
|
137
|
+
get (signal, key, receiver) {
|
|
138
|
+
if (key in signal) return Reflect.get(signal, key, receiver)
|
|
139
|
+
return Reflect.apply(extremelyLateBindings.get, this, arguments)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const QUERY_METHODS = ['map', 'get']
|
|
144
|
+
|
|
145
|
+
// dot syntax always returns a child signal even if such method or property exists.
|
|
146
|
+
// The method is only called when the signal is explicitly called as a function,
|
|
147
|
+
// in which case we get the original method from the raw (non-proxied) parent signal
|
|
148
|
+
export const extremelyLateBindings = {
|
|
149
|
+
apply (signal, thisArg, argumentsList) {
|
|
150
|
+
if (signal[SEGMENTS].length === 0) {
|
|
151
|
+
if (!signal[ROOT_FUNCTION]) throw Error(ERRORS.noRootFunction)
|
|
152
|
+
return signal[ROOT_FUNCTION].call(thisArg, signal, ...argumentsList)
|
|
153
|
+
}
|
|
154
|
+
const key = signal[SEGMENTS][signal[SEGMENTS].length - 1]
|
|
155
|
+
const $parent = getSignal(getRoot(signal), signal[SEGMENTS].slice(0, -1))
|
|
156
|
+
const rawParent = rawSignal($parent)
|
|
157
|
+
if (!(key in rawParent)) {
|
|
158
|
+
throw Error(`Method "${key}" does not exist on signal "${$parent[SEGMENTS].join('.')}"`)
|
|
159
|
+
}
|
|
160
|
+
return Reflect.apply(rawParent[key], $parent, argumentsList)
|
|
161
|
+
},
|
|
162
|
+
get (signal, key, receiver) {
|
|
163
|
+
if (typeof key === 'symbol') return Reflect.get(signal, key, receiver)
|
|
164
|
+
if (key === 'then') return undefined // handle checks for whether the symbol is a Promise
|
|
165
|
+
key = transformAlias(signal[SEGMENTS], key)
|
|
166
|
+
if (signal[IS_QUERY]) {
|
|
167
|
+
if (key === 'ids') return getSignal(getRoot(signal), [QUERIES, signal[HASH], 'ids'])
|
|
168
|
+
if (QUERY_METHODS.includes(key)) return Reflect.get(signal, key, receiver)
|
|
169
|
+
}
|
|
170
|
+
return getSignal(getRoot(signal), [...signal[SEGMENTS], key])
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const transformAlias = (({
|
|
175
|
+
collectionsMapping = {
|
|
176
|
+
session: '_session',
|
|
177
|
+
page: '_page',
|
|
178
|
+
render: '$render',
|
|
179
|
+
system: '$system'
|
|
180
|
+
},
|
|
181
|
+
regex$ = /^\$/
|
|
182
|
+
} = {}) => (segments, key) => {
|
|
183
|
+
if (regex$.test(key)) key = key.slice(1)
|
|
184
|
+
if (segments.length === 0) key = collectionsMapping[key] || key
|
|
185
|
+
return key
|
|
186
|
+
})()
|
|
187
|
+
|
|
188
|
+
export function isPublicCollectionSignal ($signal) {
|
|
189
|
+
return $signal instanceof Signal && $signal[SEGMENTS].length === 1 && isPublicCollection($signal[SEGMENTS][0])
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function isPublicDocumentSignal ($signal) {
|
|
193
|
+
return $signal instanceof Signal && $signal[SEGMENTS].length === 2 && isPublicCollection($signal[SEGMENTS][0])
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function isPublicCollection (collectionName) {
|
|
197
|
+
if (!collectionName) return false
|
|
198
|
+
return !isPrivateCollection(collectionName)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function isPrivateCollection (collectionName) {
|
|
202
|
+
if (!collectionName) return false
|
|
203
|
+
return /^[_$]/.test(collectionName)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const ERRORS = {
|
|
207
|
+
noRootFunction: `
|
|
208
|
+
Root signal does not have a root function set.
|
|
209
|
+
You must use getRootSignal({ rootId, rootFunction }) to create a root signal.
|
|
210
|
+
`
|
|
211
|
+
}
|
package/orm/Value.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { SEGMENTS } from './Signal.js'
|
|
2
|
+
import { set as _set, del as _del } from './dataTree.js'
|
|
3
|
+
|
|
4
|
+
export const LOCAL = '$local'
|
|
5
|
+
|
|
6
|
+
class ValueSubscriptions {
|
|
7
|
+
constructor () {
|
|
8
|
+
this.initialized = new Map()
|
|
9
|
+
this.fr = new FinalizationRegistry(id => this.destroy(id))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
init ($value, value) {
|
|
13
|
+
const id = $value[SEGMENTS][1]
|
|
14
|
+
if (this.initialized.has(id)) return
|
|
15
|
+
|
|
16
|
+
_set([LOCAL, id], value)
|
|
17
|
+
this.initialized.set(id, true)
|
|
18
|
+
this.fr.register($value, id)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
destroy (id) {
|
|
22
|
+
this.initialized.delete(id)
|
|
23
|
+
_del([LOCAL, id])
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const valueSubscriptions = new ValueSubscriptions()
|
package/orm/addModel.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const MODELS = {}
|
|
2
|
+
|
|
3
|
+
export default function addModel (pattern, Model) {
|
|
4
|
+
if (typeof pattern !== 'string') throw Error('Model pattern must be a string, e.g. "users.*"')
|
|
5
|
+
if (/\s/.test(pattern)) throw Error('Model pattern can not have spaces')
|
|
6
|
+
if (typeof Model !== 'function') throw Error('Model must be a class')
|
|
7
|
+
pattern = pattern.replace(/\[[^\]]+\]/g, '*') // replace `[id]` with `*`
|
|
8
|
+
if (pattern !== '' && pattern.split('.').some(segment => segment === '')) {
|
|
9
|
+
throw Error('Model pattern can not have empty segments')
|
|
10
|
+
}
|
|
11
|
+
if (MODELS[pattern]) throw Error(`Model for pattern "${pattern}" already exists`)
|
|
12
|
+
MODELS[pattern] = Model
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function findModel (segments) {
|
|
16
|
+
// if segments is an empty array, treat it as a top-level signal.
|
|
17
|
+
// Top-level signal class is the one that has an empty string as a pattern.
|
|
18
|
+
if (segments.length === 0) segments = ['']
|
|
19
|
+
for (const pattern in MODELS) {
|
|
20
|
+
const patternSegments = pattern.split('.')
|
|
21
|
+
if (segments.length !== patternSegments.length) continue
|
|
22
|
+
let match = true
|
|
23
|
+
for (let i = 0; i < segments.length; i++) {
|
|
24
|
+
if (patternSegments[i] !== '*' && patternSegments[i] !== segments[i]) {
|
|
25
|
+
match = false
|
|
26
|
+
break
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (match) return MODELS[pattern]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export let connection
|
|
2
|
+
export let fetchOnly
|
|
3
|
+
|
|
4
|
+
export function setConnection (_connection) {
|
|
5
|
+
connection = _connection
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getConnection () {
|
|
9
|
+
if (!connection) throw Error(ERRORS.notSet)
|
|
10
|
+
return connection
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function setFetchOnly (_fetchOnly) {
|
|
14
|
+
fetchOnly = _fetchOnly
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ERRORS = {
|
|
18
|
+
notSet: `
|
|
19
|
+
Connection is not set.
|
|
20
|
+
You must set the initialized ShareDB connection before using subscriptions.
|
|
21
|
+
You've probably forgotten to call connect() in your app:
|
|
22
|
+
|
|
23
|
+
import connect from '@startupjs/signals-orm/connect'
|
|
24
|
+
connect({ baseUrl: 'http://localhost:3000' })
|
|
25
|
+
`
|
|
26
|
+
}
|