teamplay 0.3.35 → 0.4.0-alpha.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/orm/Doc.js CHANGED
@@ -3,21 +3,27 @@ import { set as _set, del as _del } from './dataTree.js'
3
3
  import { SEGMENTS } from './Signal.js'
4
4
  import { getConnection, fetchOnly } from './connection.js'
5
5
  import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
6
+ import SubscriptionState from './SubscriptionState.js'
6
7
 
7
8
  const ERROR_ON_EXCESSIVE_UNSUBSCRIBES = false
8
9
 
9
10
  class Doc {
10
- subscribing
11
- unsubscribing
12
- subscribed
13
11
  initialized
14
12
 
15
13
  constructor (collection, docId) {
16
14
  this.collection = collection
17
15
  this.docId = docId
16
+ this.lifecycle = new SubscriptionState({
17
+ onSubscribe: () => this._subscribe(),
18
+ onUnsubscribe: () => this._unsubscribe()
19
+ })
18
20
  this.init()
19
21
  }
20
22
 
23
+ get subscribed () {
24
+ return this.lifecycle.subscribed
25
+ }
26
+
21
27
  init () {
22
28
  if (this.initialized) return
23
29
  this.initialized = true
@@ -25,47 +31,16 @@ class Doc {
25
31
  }
26
32
 
27
33
  async subscribe () {
28
- if (this.subscribed) throw Error('trying to subscribe while already subscribed')
29
- this.subscribed = true
30
- // if we are in the middle of unsubscribing, just wait for it to finish and then resubscribe
31
- if (this.unsubscribing) {
32
- try {
33
- await this.unsubscribing
34
- } catch (err) {
35
- // if error happened during unsubscribing, it means that we are still subscribed
36
- // so we don't need to do anything
37
- return
38
- }
39
- }
40
- if (this.subscribing) {
41
- try {
42
- await this.subscribing
43
- // if we are already subscribing from the previous time, delegate logic to that
44
- // and if it finished successfully, we are done.
45
- return
46
- } catch (err) {
47
- // if error happened during subscribing, we'll just try subscribing again
48
- // so we just ignore the error and proceed with subscribing
49
- this.subscribed = true
50
- }
51
- }
34
+ await this.lifecycle.subscribe()
35
+ this.init()
36
+ }
52
37
 
53
- if (!this.subscribed) return // cancel if we initiated unsubscribe while waiting
54
-
55
- this.subscribing = (async () => {
56
- try {
57
- this.subscribing = this._subscribe()
58
- await this.subscribing
59
- this.init()
60
- } catch (err) {
61
- console.log('subscription error', [this.collection, this.docId], err)
62
- this.subscribed = undefined
63
- throw err
64
- } finally {
65
- this.subscribing = undefined
66
- }
67
- })()
68
- await this.subscribing
38
+ async unsubscribe () {
39
+ await this.lifecycle.unsubscribe()
40
+ if (!this.subscribed) {
41
+ this.initialized = undefined
42
+ this._removeData()
43
+ }
69
44
  }
70
45
 
71
46
  async _subscribe () {
@@ -79,66 +54,26 @@ class Doc {
79
54
  })
80
55
  }
81
56
 
82
- async unsubscribe () {
83
- if (!this.subscribed) {
84
- throw Error('trying to unsubscribe while not subscribed. Doc: ' + [this.collection, this.docId])
85
- }
86
- this.subscribed = undefined
87
- // if we are still handling the subscription, just wait for it to finish and then unsubscribe
88
- if (this.subscribing) {
89
- try {
90
- await this.subscribing
91
- } catch (err) {
92
- // if error happened during subscribing, it means that we are still unsubscribed
93
- // so we don't need to do anything
94
- return
95
- }
96
- }
97
- // if we are already unsubscribing from the previous time, delegate logic to that
98
- if (this.unsubscribing) {
99
- try {
100
- await this.unsubscribing
101
- return
102
- } catch (err) {
103
- // if error happened during unsubscribing, we'll just try unsubscribing again
104
- this.subscribed = undefined
105
- }
106
- }
107
-
108
- if (this.subscribed) return // cancel if we initiated subscribe while waiting
109
-
110
- this.unsubscribing = (async () => {
111
- try {
112
- await this._unsubscribe()
113
- this.initialized = undefined
114
- this._removeData()
115
- } catch (err) {
116
- console.log('error unsubscribing', [this.collection, this.docId], err)
117
- this.subscribed = true
118
- throw err
119
- } finally {
120
- this.unsubscribing = undefined
121
- }
122
- })()
123
- await this.unsubscribing
124
- }
125
-
126
57
  async _unsubscribe () {
127
58
  const doc = getConnection().get(this.collection, this.docId)
128
59
  await new Promise((resolve, reject) => {
129
- doc.destroy(err => {
60
+ // First unsubscribe cleanly, then destroy to remove from connection.collections.
61
+ // We can't call destroy() directly because it has a race condition: if connection.get()
62
+ // is called before destroy completes (e.g. rapid unsub/resub), it resets _wantsDestroy
63
+ // creating a corrupted state ("Cannot read properties of null (reading 'callback')").
64
+ // By unsubscribing first and destroying in the callback, the doc is in a clean state.
65
+ doc.unsubscribe(err => {
130
66
  if (err) return reject(err)
131
- resolve()
67
+ doc.destroy(err => {
68
+ if (err) return reject(err)
69
+ resolve()
70
+ })
132
71
  })
133
72
  })
134
73
  }
135
74
 
136
75
  _initData () {
137
76
  const doc = getConnection().get(this.collection, this.docId)
138
- // TODO: JSON does not have `undefined`, so we'll be receiving `null`.
139
- // Handle this by converting all `null` to `undefined` in the doc's data tree.
140
- // To do this we'll probably need to in the `op` event update the data tree
141
- // and have a clone of the doc in our local data tree.
142
77
  this._refData()
143
78
  doc.on('load', () => this._refData())
144
79
  doc.on('create', () => this._refData())
@@ -186,11 +121,12 @@ class DocSubscriptions {
186
121
  let count = this.subCount.get(hash) || 0
187
122
  count += 1
188
123
  this.subCount.set(hash, count)
189
- if (count > 1) return this.docs.get(hash).subscribing
124
+ if (count > 1) return this.docs.get(hash)._subscribing
190
125
 
191
126
  this.init($doc)
192
127
  const doc = this.docs.get(hash)
193
- return doc.subscribe()
128
+ doc._subscribing = doc.subscribe().then(() => { doc._subscribing = undefined })
129
+ return doc._subscribing
194
130
  }
195
131
 
196
132
  async unsubscribe ($doc) {
@@ -207,17 +143,18 @@ class DocSubscriptions {
207
143
  return
208
144
  }
209
145
  this.fr.unregister($doc)
210
- this.destroy(segments)
146
+ await this.destroy(segments)
211
147
  }
212
148
 
213
149
  async destroy (segments) {
214
150
  const hash = hashDoc(segments)
215
151
  const doc = this.docs.get(hash)
216
152
  if (!doc) return
217
- // Wait until after unsubscribe to delete subCount and docs
218
- if (doc.subscribed) await doc.unsubscribe()
219
- if (doc.subscribed) return // Subscribed again while unsubscribing
220
153
  this.subCount.delete(hash)
154
+ // Always call unsubscribe() - if doc is in SUBSCRIBING state, the state machine
155
+ // will queue a pending unsubscribe to execute after subscribe completes
156
+ await doc.unsubscribe()
157
+ if (doc.subscribed) return // Subscribed again while unsubscribing
221
158
  this.docs.delete(hash)
222
159
  }
223
160
  }
package/orm/Query.js CHANGED
@@ -4,6 +4,7 @@ import getSignal from './getSignal.js'
4
4
  import { getConnection, fetchOnly } from './connection.js'
5
5
  import { docSubscriptions } from './Doc.js'
6
6
  import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
7
+ import SubscriptionState from './SubscriptionState.js'
7
8
 
8
9
  const ERROR_ON_EXCESSIVE_UNSUBSCRIBES = false
9
10
  export const COLLECTION_NAME = Symbol('query collection name')
@@ -13,9 +14,6 @@ export const IS_QUERY = Symbol('is query signal')
13
14
  export const QUERIES = '$queries'
14
15
 
15
16
  export class Query {
16
- subscribing
17
- unsubscribing
18
- subscribed
19
17
  initialized
20
18
  shareQuery
21
19
 
@@ -24,6 +22,14 @@ export class Query {
24
22
  this.params = params
25
23
  this.hash = hashQuery(this.collectionName, this.params)
26
24
  this.docSignals = new Set()
25
+ this.lifecycle = new SubscriptionState({
26
+ onSubscribe: () => this._subscribe(),
27
+ onUnsubscribe: () => this._unsubscribe()
28
+ })
29
+ }
30
+
31
+ get subscribed () {
32
+ return this.lifecycle.subscribed
27
33
  }
28
34
 
29
35
  init () {
@@ -33,47 +39,16 @@ export class Query {
33
39
  }
34
40
 
35
41
  async subscribe () {
36
- if (this.subscribed) throw Error('trying to subscribe while already subscribed')
37
- this.subscribed = true
38
- // if we are in the middle of unsubscribing, just wait for it to finish and then resubscribe
39
- if (this.unsubscribing) {
40
- try {
41
- await this.unsubscribing
42
- } catch (err) {
43
- // if error happened during unsubscribing, it means that we are still subscribed
44
- // so we don't need to do anything
45
- return
46
- }
47
- }
48
- if (this.subscribing) {
49
- try {
50
- await this.subscribing
51
- // if we are already subscribing from the previous time, delegate logic to that
52
- // and if it finished successfully, we are done.
53
- return
54
- } catch (err) {
55
- // if error happened during subscribing, we'll just try subscribing again
56
- // so we just ignore the error and proceed with subscribing
57
- this.subscribed = true
58
- }
59
- }
60
-
61
- if (!this.subscribed) return // cancel if we initiated unsubscribe while waiting
42
+ await this.lifecycle.subscribe()
43
+ this.init()
44
+ }
62
45
 
63
- this.subscribing = (async () => {
64
- try {
65
- this.subscribing = this._subscribe()
66
- await this.subscribing
67
- this.init()
68
- } catch (err) {
69
- console.log('subscription error', [this.collectionName, this.params], err)
70
- this.subscribed = undefined
71
- throw err
72
- } finally {
73
- this.subscribing = undefined
74
- }
75
- })()
76
- await this.subscribing
46
+ async unsubscribe () {
47
+ await this.lifecycle.unsubscribe()
48
+ if (!this.subscribed) {
49
+ this.initialized = undefined
50
+ this._removeData()
51
+ }
77
52
  }
78
53
 
79
54
  async _subscribe () {
@@ -86,50 +61,6 @@ export class Query {
86
61
  })
87
62
  }
88
63
 
89
- async unsubscribe () {
90
- if (!this.subscribed) {
91
- throw Error('trying to unsubscribe while not subscribed. Query: ' + [this.collectionName, this.params])
92
- }
93
- this.subscribed = undefined
94
- // if we are still handling the subscription, just wait for it to finish and then unsubscribe
95
- if (this.subscribing) {
96
- try {
97
- await this.subscribing
98
- } catch (err) {
99
- // if error happened during subscribing, it means that we are still unsubscribed
100
- // so we don't need to do anything
101
- return
102
- }
103
- }
104
- // if we are already unsubscribing from the previous time, delegate logic to that
105
- if (this.unsubscribing) {
106
- try {
107
- await this.unsubscribing
108
- return
109
- } catch (err) {
110
- // if error happened during unsubscribing, we'll just try unsubscribing again
111
- this.subscribed = undefined
112
- }
113
- }
114
-
115
- if (this.subscribed) return // cancel if we initiated subscribe while waiting
116
-
117
- this.unsubscribing = (async () => {
118
- try {
119
- await this._unsubscribe()
120
- this.initialized = undefined
121
- this._removeData()
122
- } catch (err) {
123
- console.log('error unsubscribing', [this.collectionName, this.params], err)
124
- this.subscribed = true
125
- throw err
126
- } finally {
127
- this.unsubscribing = undefined
128
- }
129
- })()
130
- await this.unsubscribing
131
- }
132
-
133
64
  async _unsubscribe () {
134
65
  if (!this.shareQuery) throw Error('this.shareQuery is not defined. This should never happen')
135
66
  await new Promise((resolve, reject) => {
@@ -220,7 +151,7 @@ export class QuerySubscriptions {
220
151
  let count = this.subCount.get(hash) || 0
221
152
  count += 1
222
153
  this.subCount.set(hash, count)
223
- if (count > 1) return this.queries.get(hash).subscribing
154
+ if (count > 1) return this.queries.get(hash)._subscribing
224
155
 
225
156
  this.fr.register($query, { collectionName, params }, $query)
226
157
 
@@ -229,7 +160,8 @@ export class QuerySubscriptions {
229
160
  query = new this.QueryClass(collectionName, params)
230
161
  this.queries.set(hash, query)
231
162
  }
232
- return query.subscribe()
163
+ query._subscribing = query.subscribe().then(() => { query._subscribing = undefined })
164
+ return query._subscribing
233
165
  }
234
166
 
235
167
  async unsubscribe ($query) {
@@ -0,0 +1,138 @@
1
+ // State machine for managing subscribe/unsubscribe lifecycle.
2
+ //
3
+ // States: IDLE, SUBSCRIBING, SUBSCRIBED, UNSUBSCRIBING
4
+ //
5
+ // Valid transitions:
6
+ // IDLE -> SUBSCRIBING (subscribe called)
7
+ // SUBSCRIBING -> SUBSCRIBED (subscribe succeeded)
8
+ // SUBSCRIBING -> IDLE (subscribe failed)
9
+ // SUBSCRIBED -> UNSUBSCRIBING (unsubscribe called)
10
+ // UNSUBSCRIBING -> IDLE (unsubscribe succeeded)
11
+ // UNSUBSCRIBING -> SUBSCRIBED (unsubscribe failed, rollback)
12
+ //
13
+ // Rapid sub/unsub handling:
14
+ // If subscribe() is called during UNSUBSCRIBING, we queue a resubscribe.
15
+ // If unsubscribe() is called during SUBSCRIBING, we queue an unsubscribe.
16
+ // Only the latest intent matters - intermediate intents are collapsed.
17
+
18
+ export const STATE = {
19
+ IDLE: 'IDLE',
20
+ SUBSCRIBING: 'SUBSCRIBING',
21
+ SUBSCRIBED: 'SUBSCRIBED',
22
+ UNSUBSCRIBING: 'UNSUBSCRIBING'
23
+ }
24
+
25
+ export default class SubscriptionState {
26
+ #state = STATE.IDLE
27
+ #pendingAction = undefined // 'subscribe' | 'unsubscribe' | undefined
28
+ #activePromise = undefined
29
+ #onSubscribe // async () => void
30
+ #onUnsubscribe // async () => void
31
+
32
+ constructor ({ onSubscribe, onUnsubscribe }) {
33
+ this.#onSubscribe = onSubscribe
34
+ this.#onUnsubscribe = onUnsubscribe
35
+ }
36
+
37
+ get state () {
38
+ return this.#state
39
+ }
40
+
41
+ get subscribed () {
42
+ return this.#state === STATE.SUBSCRIBED
43
+ }
44
+
45
+ async subscribe () {
46
+ // Already subscribed - nothing to do
47
+ if (this.#state === STATE.SUBSCRIBED) return
48
+
49
+ // Already subscribing - if there was a pending unsubscribe, cancel it
50
+ if (this.#state === STATE.SUBSCRIBING) {
51
+ this.#pendingAction = undefined
52
+ return this.#activePromise
53
+ }
54
+
55
+ // Currently unsubscribing - queue a resubscribe after it finishes
56
+ if (this.#state === STATE.UNSUBSCRIBING) {
57
+ this.#pendingAction = 'subscribe'
58
+ return this.#activePromise
59
+ }
60
+
61
+ // IDLE - start subscribing
62
+ return this.#doSubscribe()
63
+ }
64
+
65
+ async unsubscribe () {
66
+ // Already idle - nothing to do
67
+ if (this.#state === STATE.IDLE) return
68
+
69
+ // Already unsubscribing - if there was a pending subscribe, cancel it
70
+ if (this.#state === STATE.UNSUBSCRIBING) {
71
+ this.#pendingAction = undefined
72
+ return this.#activePromise
73
+ }
74
+
75
+ // Currently subscribing - queue an unsubscribe after it finishes
76
+ if (this.#state === STATE.SUBSCRIBING) {
77
+ this.#pendingAction = 'unsubscribe'
78
+ return this.#activePromise
79
+ }
80
+
81
+ // SUBSCRIBED - start unsubscribing
82
+ return this.#doUnsubscribe()
83
+ }
84
+
85
+ async #doSubscribe () {
86
+ this.#state = STATE.SUBSCRIBING
87
+ this.#pendingAction = undefined
88
+
89
+ this.#activePromise = (async () => {
90
+ try {
91
+ await this.#onSubscribe()
92
+ this.#state = STATE.SUBSCRIBED
93
+ } catch (err) {
94
+ this.#state = STATE.IDLE
95
+ this.#pendingAction = undefined
96
+ throw err
97
+ } finally {
98
+ this.#activePromise = undefined
99
+ }
100
+ await this.#processPending()
101
+ })()
102
+
103
+ return this.#activePromise
104
+ }
105
+
106
+ async #doUnsubscribe () {
107
+ this.#state = STATE.UNSUBSCRIBING
108
+ this.#pendingAction = undefined
109
+
110
+ this.#activePromise = (async () => {
111
+ try {
112
+ await this.#onUnsubscribe()
113
+ this.#state = STATE.IDLE
114
+ } catch (err) {
115
+ this.#state = STATE.SUBSCRIBED
116
+ this.#pendingAction = undefined
117
+ throw err
118
+ } finally {
119
+ this.#activePromise = undefined
120
+ }
121
+ await this.#processPending()
122
+ })()
123
+
124
+ return this.#activePromise
125
+ }
126
+
127
+ async #processPending () {
128
+ const action = this.#pendingAction
129
+ this.#pendingAction = undefined
130
+
131
+ if (action === 'subscribe' && this.#state === STATE.IDLE) {
132
+ return this.#doSubscribe()
133
+ }
134
+ if (action === 'unsubscribe' && this.#state === STATE.SUBSCRIBED) {
135
+ return this.#doUnsubscribe()
136
+ }
137
+ }
138
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamplay",
3
- "version": "0.3.35",
3
+ "version": "0.4.0-alpha.0",
4
4
  "description": "Full-stack signals ORM with multiplayer",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -23,17 +23,20 @@
23
23
  "test": "npm run test-server && npm run test-client",
24
24
  "test-server": "NODE_OPTIONS=\"--expose-gc\" mocha 'test/[!_]*.js'",
25
25
  "test-server-only": "NODE_OPTIONS=\"--expose-gc\" mocha --grep '@only' 'test/[!_]*.js'",
26
- "test-client": "NODE_OPTIONS=\"$NODE_OPTIONS --expose-gc --experimental-vm-modules\" jest"
26
+ "test-client": "NODE_OPTIONS=\"$NODE_OPTIONS --expose-gc --experimental-vm-modules\" jest",
27
+ "coverage-server": "NODE_OPTIONS=\"--expose-gc\" c8 --include 'orm/**' --include 'react/**' --include 'utils/**' mocha 'test/[!_]*.js'",
28
+ "coverage-client": "NODE_OPTIONS=\"$NODE_OPTIONS --expose-gc --experimental-vm-modules\" jest --coverage --coverageDirectory=coverage-client",
29
+ "coverage": "npm run coverage-server && npm run coverage-client"
27
30
  },
28
31
  "dependencies": {
29
32
  "@nx-js/observer-util": "^4.1.3",
30
33
  "@startupjs/sharedb-mingo-memory": "^4.0.0-2",
31
- "@teamplay/backend": "^0.3.35",
32
- "@teamplay/cache": "^0.3.34",
33
- "@teamplay/channel": "^0.3.34",
34
- "@teamplay/debug": "^0.3.34",
35
- "@teamplay/schema": "^0.3.34",
36
- "@teamplay/utils": "^0.3.34",
34
+ "@teamplay/backend": "^0.4.0-alpha.0",
35
+ "@teamplay/cache": "^0.4.0-alpha.0",
36
+ "@teamplay/channel": "^0.4.0-alpha.0",
37
+ "@teamplay/debug": "^0.4.0-alpha.0",
38
+ "@teamplay/schema": "^0.4.0-alpha.0",
39
+ "@teamplay/utils": "^0.4.0-alpha.0",
37
40
  "diff-match-patch": "^1.0.5",
38
41
  "events": "^3.3.0",
39
42
  "json0-ot-diff": "^1.1.2",
@@ -45,6 +48,7 @@
45
48
  "devDependencies": {
46
49
  "@jest/globals": "^29.7.0",
47
50
  "@testing-library/react": "^15.0.7",
51
+ "c8": "^10.1.3",
48
52
  "jest": "^29.7.0",
49
53
  "jest-environment-jsdom": "^29.7.0",
50
54
  "mocha": "^11.0.1",
@@ -74,5 +78,5 @@
74
78
  ]
75
79
  },
76
80
  "license": "MIT",
77
- "gitHead": "9c51c8f0d4ee739896466a7574b8a206c4d5d463"
81
+ "gitHead": "fb723714f5c425d763b1a7b45e75adb990a39707"
78
82
  }