@prsm/realtime 1.0.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/package.json +49 -0
- package/src/adapters/postgres.js +139 -0
- package/src/adapters/sqlite.js +177 -0
- package/src/client/client.js +441 -0
- package/src/client/connection.js +178 -0
- package/src/client/ids.js +26 -0
- package/src/client/index.js +5 -0
- package/src/client/queue.js +44 -0
- package/src/client/subscriptions/channels.js +79 -0
- package/src/client/subscriptions/collections.js +96 -0
- package/src/client/subscriptions/presence.js +98 -0
- package/src/client/subscriptions/records.js +123 -0
- package/src/client/subscriptions/rooms.js +69 -0
- package/src/devtools/client/dist/assets/index-CGm1NqOQ.css +1 -0
- package/src/devtools/client/dist/assets/index-w2FI7RvC.js +168 -0
- package/src/devtools/client/dist/index.html +16 -0
- package/src/devtools/client/index.html +15 -0
- package/src/devtools/client/package.json +17 -0
- package/src/devtools/client/src/App.vue +173 -0
- package/src/devtools/client/src/components/ConnectionPicker.vue +38 -0
- package/src/devtools/client/src/components/JsonView.vue +18 -0
- package/src/devtools/client/src/composables/useApi.js +71 -0
- package/src/devtools/client/src/composables/useHighlight.js +57 -0
- package/src/devtools/client/src/main.js +5 -0
- package/src/devtools/client/src/style.css +440 -0
- package/src/devtools/client/src/views/ChannelsView.vue +27 -0
- package/src/devtools/client/src/views/CollectionsView.vue +61 -0
- package/src/devtools/client/src/views/MetadataView.vue +108 -0
- package/src/devtools/client/src/views/RecordsView.vue +30 -0
- package/src/devtools/client/src/views/RoomsView.vue +39 -0
- package/src/devtools/client/vite.config.js +17 -0
- package/src/devtools/demo/server.js +144 -0
- package/src/devtools/index.js +186 -0
- package/src/index.js +9 -0
- package/src/server/connection.js +116 -0
- package/src/server/context.js +22 -0
- package/src/server/managers/broadcast.js +94 -0
- package/src/server/managers/channels.js +118 -0
- package/src/server/managers/collections.js +127 -0
- package/src/server/managers/commands.js +55 -0
- package/src/server/managers/connections.js +111 -0
- package/src/server/managers/instance.js +125 -0
- package/src/server/managers/persistence.js +371 -0
- package/src/server/managers/presence.js +217 -0
- package/src/server/managers/pubsub.js +242 -0
- package/src/server/managers/record-subscriptions.js +123 -0
- package/src/server/managers/records.js +110 -0
- package/src/server/managers/redis.js +61 -0
- package/src/server/managers/rooms.js +129 -0
- package/src/server/message-stream.js +20 -0
- package/src/server/server.js +878 -0
- package/src/server/utils/constants.js +4 -0
- package/src/server/utils/ids.js +5 -0
- package/src/server/utils/pattern-conversion.js +14 -0
- package/src/shared/errors.js +7 -0
- package/src/shared/index.js +5 -0
- package/src/shared/logger.js +53 -0
- package/src/shared/merge.js +17 -0
- package/src/shared/message.js +17 -0
- package/src/shared/status.js +7 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { serverLogger } from "../../shared/index.js"
|
|
2
|
+
import { PUB_SUB_CHANNEL_PREFIX, RECORD_PUB_SUB_CHANNEL } from "../utils/constants.js"
|
|
3
|
+
|
|
4
|
+
export class PubSubManager {
|
|
5
|
+
constructor({ subClient, pubClient, instanceId, connectionManager, recordManager, recordSubscriptions, getChannelSubscriptions, emitError, collectionManager }) {
|
|
6
|
+
this.subClient = subClient
|
|
7
|
+
this.pubClient = pubClient
|
|
8
|
+
this.instanceId = instanceId
|
|
9
|
+
this.connectionManager = connectionManager
|
|
10
|
+
this.recordManager = recordManager
|
|
11
|
+
this.recordSubscriptions = recordSubscriptions
|
|
12
|
+
this.getChannelSubscriptions = getChannelSubscriptions
|
|
13
|
+
this.emitError = emitError
|
|
14
|
+
this.collectionManager = collectionManager || null
|
|
15
|
+
|
|
16
|
+
this.collectionUpdateTimeouts = new Map()
|
|
17
|
+
this.collectionMaxDelayTimeouts = new Map()
|
|
18
|
+
this.pendingCollectionUpdates = new Map()
|
|
19
|
+
this.COLLECTION_UPDATE_DEBOUNCE_MS = 50
|
|
20
|
+
this.COLLECTION_MAX_DELAY_MS = 200
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
subscribeToInstanceChannel() {
|
|
24
|
+
const channel = `${PUB_SUB_CHANNEL_PREFIX}${this.instanceId}`
|
|
25
|
+
this._subscriptionPromise = new Promise((resolve, reject) => {
|
|
26
|
+
this.subClient.subscribe(channel, RECORD_PUB_SUB_CHANNEL, "mesh:collection:record-change")
|
|
27
|
+
this.subClient.psubscribe("mesh:presence:updates:*", (err) => {
|
|
28
|
+
if (err) {
|
|
29
|
+
this.emitError(new Error(`Failed to subscribe to channels/patterns: ${JSON.stringify({ cause: err })}`))
|
|
30
|
+
reject(err)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
resolve()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
this._setupMessageHandlers()
|
|
37
|
+
return this._subscriptionPromise
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_setupMessageHandlers() {
|
|
41
|
+
this.subClient.on("message", async (channel, message) => {
|
|
42
|
+
if (channel.startsWith(PUB_SUB_CHANNEL_PREFIX)) {
|
|
43
|
+
this._handleInstancePubSubMessage(channel, message)
|
|
44
|
+
} else if (channel === RECORD_PUB_SUB_CHANNEL) {
|
|
45
|
+
this._handleRecordUpdatePubSubMessage(message)
|
|
46
|
+
} else if (channel === "mesh:collection:record-change") {
|
|
47
|
+
this._handleCollectionRecordChange(message)
|
|
48
|
+
} else {
|
|
49
|
+
const subscribers = this.getChannelSubscriptions(channel)
|
|
50
|
+
if (subscribers) {
|
|
51
|
+
for (const connection of subscribers) {
|
|
52
|
+
if (!connection.isDead) {
|
|
53
|
+
connection.send({ command: "mesh/subscription-message", payload: { channel, message } })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
this.subClient.on("pmessage", async (pattern, channel, message) => {
|
|
61
|
+
if (pattern === "mesh:presence:updates:*") {
|
|
62
|
+
const subscribers = this.getChannelSubscriptions(channel)
|
|
63
|
+
if (subscribers) {
|
|
64
|
+
try {
|
|
65
|
+
const payload = JSON.parse(message)
|
|
66
|
+
subscribers.forEach((connection) => {
|
|
67
|
+
if (!connection.isDead) {
|
|
68
|
+
connection.send({ command: "mesh/presence-update", payload })
|
|
69
|
+
} else {
|
|
70
|
+
subscribers.delete(connection)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
} catch (e) {
|
|
74
|
+
this.emitError(new Error(`Failed to parse presence update: ${message}`))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_handleInstancePubSubMessage(_channel, message) {
|
|
82
|
+
try {
|
|
83
|
+
const parsedMessage = JSON.parse(message)
|
|
84
|
+
if (!parsedMessage || !Array.isArray(parsedMessage.targetConnectionIds) || !parsedMessage.command || typeof parsedMessage.command.command !== "string") {
|
|
85
|
+
throw new Error("Invalid message format")
|
|
86
|
+
}
|
|
87
|
+
const { targetConnectionIds, command } = parsedMessage
|
|
88
|
+
targetConnectionIds.forEach((connectionId) => {
|
|
89
|
+
const connection = this.connectionManager.getLocalConnection(connectionId)
|
|
90
|
+
if (connection && !connection.isDead) connection.send(command)
|
|
91
|
+
})
|
|
92
|
+
} catch (err) {
|
|
93
|
+
this.emitError(new Error(`Failed to parse message: ${message}`))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
_handleRecordUpdatePubSubMessage(message) {
|
|
98
|
+
try {
|
|
99
|
+
const parsedMessage = JSON.parse(message)
|
|
100
|
+
const { recordId, newValue, patch, version, deleted } = parsedMessage
|
|
101
|
+
if (!recordId || typeof version !== "number") throw new Error("Invalid record update message format")
|
|
102
|
+
const subscribers = this.recordSubscriptions.get(recordId)
|
|
103
|
+
if (!subscribers) return
|
|
104
|
+
subscribers.forEach((mode, connectionId) => {
|
|
105
|
+
const connection = this.connectionManager.getLocalConnection(connectionId)
|
|
106
|
+
if (connection && !connection.isDead) {
|
|
107
|
+
if (deleted) {
|
|
108
|
+
connection.send({ command: "mesh/record-deleted", payload: { recordId, version } })
|
|
109
|
+
} else if (mode === "patch" && patch) {
|
|
110
|
+
connection.send({ command: "mesh/record-update", payload: { recordId, patch, version } })
|
|
111
|
+
} else if (mode === "full" && newValue !== undefined) {
|
|
112
|
+
connection.send({ command: "mesh/record-update", payload: { recordId, full: newValue, version } })
|
|
113
|
+
}
|
|
114
|
+
} else if (!connection || connection.isDead) {
|
|
115
|
+
subscribers.delete(connectionId)
|
|
116
|
+
if (subscribers.size === 0) this.recordSubscriptions.delete(recordId)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
if (deleted) this.recordSubscriptions.delete(recordId)
|
|
120
|
+
} catch (err) {
|
|
121
|
+
this.emitError(new Error(`Failed to parse record update message: ${message}`))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async _handleCollectionRecordChange(changedRecordId) {
|
|
126
|
+
if (!this.collectionManager) return
|
|
127
|
+
const collectionSubsMap = this.collectionManager.getCollectionSubscriptions()
|
|
128
|
+
const affectedCollections = new Set()
|
|
129
|
+
for (const [collectionId] of collectionSubsMap.entries()) {
|
|
130
|
+
affectedCollections.add(collectionId)
|
|
131
|
+
}
|
|
132
|
+
for (const collectionId of affectedCollections) {
|
|
133
|
+
const existingTimeout = this.collectionUpdateTimeouts.get(collectionId)
|
|
134
|
+
if (existingTimeout) clearTimeout(existingTimeout)
|
|
135
|
+
if (!this.pendingCollectionUpdates.has(collectionId)) {
|
|
136
|
+
this.pendingCollectionUpdates.set(collectionId, new Set())
|
|
137
|
+
}
|
|
138
|
+
this.pendingCollectionUpdates.get(collectionId).add(changedRecordId)
|
|
139
|
+
const debounceTimeout = setTimeout(async () => {
|
|
140
|
+
await this._processCollectionUpdates(collectionId)
|
|
141
|
+
}, this.COLLECTION_UPDATE_DEBOUNCE_MS)
|
|
142
|
+
this.collectionUpdateTimeouts.set(collectionId, debounceTimeout)
|
|
143
|
+
if (!this.collectionMaxDelayTimeouts.has(collectionId)) {
|
|
144
|
+
const maxDelayTimeout = setTimeout(async () => {
|
|
145
|
+
await this._processCollectionUpdates(collectionId)
|
|
146
|
+
}, this.COLLECTION_MAX_DELAY_MS)
|
|
147
|
+
this.collectionMaxDelayTimeouts.set(collectionId, maxDelayTimeout)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async _processCollectionUpdates(collectionId) {
|
|
153
|
+
const changedRecordIds = this.pendingCollectionUpdates.get(collectionId)
|
|
154
|
+
if (!changedRecordIds || changedRecordIds.size === 0) return
|
|
155
|
+
const debounceTimeout = this.collectionUpdateTimeouts.get(collectionId)
|
|
156
|
+
const maxDelayTimeout = this.collectionMaxDelayTimeouts.get(collectionId)
|
|
157
|
+
if (debounceTimeout) { clearTimeout(debounceTimeout); this.collectionUpdateTimeouts.delete(collectionId) }
|
|
158
|
+
if (maxDelayTimeout) { clearTimeout(maxDelayTimeout); this.collectionMaxDelayTimeouts.delete(collectionId) }
|
|
159
|
+
this.pendingCollectionUpdates.delete(collectionId)
|
|
160
|
+
if (!this.collectionManager) return
|
|
161
|
+
const subscribers = this.collectionManager.getCollectionSubscriptions().get(collectionId)
|
|
162
|
+
if (!subscribers || subscribers.size === 0) return
|
|
163
|
+
|
|
164
|
+
for (const [connectionId, { version: currentCollVersion }] of subscribers.entries()) {
|
|
165
|
+
try {
|
|
166
|
+
const connection = this.connectionManager.getLocalConnection(connectionId)
|
|
167
|
+
if (!connection || connection.isDead) continue
|
|
168
|
+
|
|
169
|
+
const newRecords = await this.collectionManager.resolveCollection(collectionId, connection)
|
|
170
|
+
const newRecordIds = newRecords.map((record) => record.id)
|
|
171
|
+
const previousRecordIdsKey = `mesh:collection:${collectionId}:${connectionId}`
|
|
172
|
+
const previousRecordIdsStr = await this.pubClient.get(previousRecordIdsKey)
|
|
173
|
+
const previousRecordIds = previousRecordIdsStr ? JSON.parse(previousRecordIdsStr) : []
|
|
174
|
+
|
|
175
|
+
const addedIds = newRecordIds.filter((id) => !previousRecordIds.includes(id))
|
|
176
|
+
const added = newRecords.filter((record) => addedIds.includes(record.id))
|
|
177
|
+
const removedIds = previousRecordIds.filter((id) => !newRecordIds.includes(id))
|
|
178
|
+
|
|
179
|
+
const removed = []
|
|
180
|
+
for (const removedId of removedIds) {
|
|
181
|
+
try {
|
|
182
|
+
const record = await this.recordManager.getRecord(removedId)
|
|
183
|
+
removed.push(record || { id: removedId })
|
|
184
|
+
} catch { removed.push({ id: removedId }) }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const deletedRecords = []
|
|
188
|
+
for (const recordId of changedRecordIds) {
|
|
189
|
+
if (previousRecordIds.includes(recordId) && !newRecordIds.includes(recordId)) {
|
|
190
|
+
deletedRecords.push(recordId)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const changeAffectsMembership = added.length > 0 || removed.length > 0
|
|
195
|
+
const deletionAffectsExistingMember = deletedRecords.length > 0
|
|
196
|
+
|
|
197
|
+
if (changeAffectsMembership || deletionAffectsExistingMember) {
|
|
198
|
+
const newCollectionVersion = currentCollVersion + 1
|
|
199
|
+
this.collectionManager.updateSubscriptionVersion(collectionId, connectionId, newCollectionVersion)
|
|
200
|
+
await this.pubClient.set(previousRecordIdsKey, JSON.stringify(newRecordIds))
|
|
201
|
+
connection.send({
|
|
202
|
+
command: "mesh/collection-diff",
|
|
203
|
+
payload: { collectionId, added, removed, version: newCollectionVersion },
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const recordId of changedRecordIds) {
|
|
208
|
+
if (newRecordIds.includes(recordId)) {
|
|
209
|
+
try {
|
|
210
|
+
const { record, version } = await this.recordManager.getRecordAndVersion(recordId)
|
|
211
|
+
if (record) {
|
|
212
|
+
connection.send({ command: "mesh/record-update", payload: { recordId, version, full: record } })
|
|
213
|
+
}
|
|
214
|
+
} catch (recordError) {
|
|
215
|
+
serverLogger.info(`Record ${recordId} not found during collection update (likely deleted).`)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch (connError) {
|
|
220
|
+
this.emitError(new Error(`Error processing collection ${collectionId} for connection ${connectionId}: ${connError}`))
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
getSubscriptionPromise() { return this._subscriptionPromise }
|
|
226
|
+
getPubSubChannel(instanceId) { return `${PUB_SUB_CHANNEL_PREFIX}${instanceId}` }
|
|
227
|
+
|
|
228
|
+
async cleanup() {
|
|
229
|
+
for (const timeout of this.collectionUpdateTimeouts.values()) clearTimeout(timeout)
|
|
230
|
+
this.collectionUpdateTimeouts.clear()
|
|
231
|
+
for (const timeout of this.collectionMaxDelayTimeouts.values()) clearTimeout(timeout)
|
|
232
|
+
this.collectionMaxDelayTimeouts.clear()
|
|
233
|
+
this.pendingCollectionUpdates.clear()
|
|
234
|
+
if (this.subClient && this.subClient.status !== "end") {
|
|
235
|
+
const channel = `${PUB_SUB_CHANNEL_PREFIX}${this.instanceId}`
|
|
236
|
+
await Promise.all([
|
|
237
|
+
new Promise((resolve) => { this.subClient.unsubscribe(channel, RECORD_PUB_SUB_CHANNEL, "mesh:collection:record-change", () => resolve()) }),
|
|
238
|
+
new Promise((resolve) => { this.subClient.punsubscribe("mesh:presence:updates:*", () => resolve()) }),
|
|
239
|
+
])
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { RECORD_PUB_SUB_CHANNEL } from "../utils/constants.js"
|
|
2
|
+
|
|
3
|
+
export class RecordSubscriptionManager {
|
|
4
|
+
constructor({ pubClient, recordManager, emitError, persistenceManager }) {
|
|
5
|
+
this.pubClient = pubClient
|
|
6
|
+
this.recordManager = recordManager
|
|
7
|
+
this.persistenceManager = persistenceManager || null
|
|
8
|
+
this.exposedRecords = []
|
|
9
|
+
this.exposedWritableRecords = []
|
|
10
|
+
this.recordGuards = new Map()
|
|
11
|
+
this.writableRecordGuards = new Map()
|
|
12
|
+
this.recordSubscriptions = new Map()
|
|
13
|
+
this.emitError = emitError
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setPersistenceManager(persistenceManager) {
|
|
17
|
+
this.persistenceManager = persistenceManager
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
exposeRecord(recordPattern, guard) {
|
|
21
|
+
this.exposedRecords.push(recordPattern)
|
|
22
|
+
if (guard) this.recordGuards.set(recordPattern, guard)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
exposeWritableRecord(recordPattern, guard) {
|
|
26
|
+
this.exposedWritableRecords.push(recordPattern)
|
|
27
|
+
if (guard) this.writableRecordGuards.set(recordPattern, guard)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async isRecordExposed(recordId, connection) {
|
|
31
|
+
const readPattern = this.exposedRecords.find((pattern) =>
|
|
32
|
+
typeof pattern === "string" ? pattern === recordId : pattern.test(recordId)
|
|
33
|
+
)
|
|
34
|
+
let canRead = false
|
|
35
|
+
if (readPattern) {
|
|
36
|
+
const guard = this.recordGuards.get(readPattern)
|
|
37
|
+
if (guard) {
|
|
38
|
+
try { canRead = await Promise.resolve(guard(connection, recordId)) }
|
|
39
|
+
catch { canRead = false }
|
|
40
|
+
} else {
|
|
41
|
+
canRead = true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (canRead) return true
|
|
45
|
+
const writePattern = this.exposedWritableRecords.find((pattern) =>
|
|
46
|
+
typeof pattern === "string" ? pattern === recordId : pattern.test(recordId)
|
|
47
|
+
)
|
|
48
|
+
if (writePattern) return true
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async isRecordWritable(recordId, connection) {
|
|
53
|
+
const matchedPattern = this.exposedWritableRecords.find((pattern) =>
|
|
54
|
+
typeof pattern === "string" ? pattern === recordId : pattern.test(recordId)
|
|
55
|
+
)
|
|
56
|
+
if (!matchedPattern) return false
|
|
57
|
+
const guard = this.writableRecordGuards.get(matchedPattern)
|
|
58
|
+
if (guard) {
|
|
59
|
+
try { return await Promise.resolve(guard(connection, recordId)) }
|
|
60
|
+
catch { return false }
|
|
61
|
+
}
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
addSubscription(recordId, connectionId, mode) {
|
|
66
|
+
if (!this.recordSubscriptions.has(recordId)) {
|
|
67
|
+
this.recordSubscriptions.set(recordId, new Map())
|
|
68
|
+
}
|
|
69
|
+
this.recordSubscriptions.get(recordId).set(connectionId, mode)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
removeSubscription(recordId, connectionId) {
|
|
73
|
+
const recordSubs = this.recordSubscriptions.get(recordId)
|
|
74
|
+
if (recordSubs?.has(connectionId)) {
|
|
75
|
+
recordSubs.delete(connectionId)
|
|
76
|
+
if (recordSubs.size === 0) this.recordSubscriptions.delete(recordId)
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getSubscribers(recordId) {
|
|
83
|
+
return this.recordSubscriptions.get(recordId)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async writeRecord(recordId, newValue, options) {
|
|
87
|
+
const updateResult = await this.recordManager.publishUpdate(recordId, newValue, options?.strategy || "replace")
|
|
88
|
+
if (!updateResult) return
|
|
89
|
+
const { patch, version, finalValue } = updateResult
|
|
90
|
+
if (this.persistenceManager) {
|
|
91
|
+
this.persistenceManager.handleRecordUpdate(recordId, finalValue, version)
|
|
92
|
+
}
|
|
93
|
+
const messagePayload = { recordId, newValue: finalValue, patch, version }
|
|
94
|
+
try {
|
|
95
|
+
await this.pubClient.publish(RECORD_PUB_SUB_CHANNEL, JSON.stringify(messagePayload))
|
|
96
|
+
} catch (err) {
|
|
97
|
+
this.emitError(new Error(`Failed to publish record update for "${recordId}": ${err}`))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
cleanupConnection(connection) {
|
|
102
|
+
const connectionId = connection.id
|
|
103
|
+
this.recordSubscriptions.forEach((subscribers, recordId) => {
|
|
104
|
+
if (subscribers.has(connectionId)) {
|
|
105
|
+
subscribers.delete(connectionId)
|
|
106
|
+
if (subscribers.size === 0) this.recordSubscriptions.delete(recordId)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async publishRecordDeletion(recordId, version) {
|
|
112
|
+
const messagePayload = { recordId, deleted: true, version }
|
|
113
|
+
try {
|
|
114
|
+
await this.pubClient.publish(RECORD_PUB_SUB_CHANNEL, JSON.stringify(messagePayload))
|
|
115
|
+
} catch (err) {
|
|
116
|
+
this.emitError(new Error(`Failed to publish record deletion for "${recordId}": ${err}`))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getRecordSubscriptions() {
|
|
121
|
+
return this.recordSubscriptions
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import jsonpatch from "fast-json-patch"
|
|
2
|
+
import { deepMerge, isObject, serverLogger } from "../../shared/index.js"
|
|
3
|
+
import { RECORD_KEY_PREFIX, RECORD_VERSION_KEY_PREFIX } from "../utils/constants.js"
|
|
4
|
+
|
|
5
|
+
export class RecordManager {
|
|
6
|
+
constructor({ redis, server }) {
|
|
7
|
+
this.redis = redis
|
|
8
|
+
this.server = server
|
|
9
|
+
this.recordUpdateCallbacks = []
|
|
10
|
+
this.recordRemovedCallbacks = []
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
getServer() { return this.server }
|
|
14
|
+
getRedis() { return this.redis }
|
|
15
|
+
|
|
16
|
+
recordKey(recordId) { return `${RECORD_KEY_PREFIX}${recordId}` }
|
|
17
|
+
recordVersionKey(recordId) { return `${RECORD_VERSION_KEY_PREFIX}${recordId}` }
|
|
18
|
+
|
|
19
|
+
async getRecord(recordId) {
|
|
20
|
+
const data = await this.redis.get(this.recordKey(recordId))
|
|
21
|
+
return data ? JSON.parse(data) : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async getVersion(recordId) {
|
|
25
|
+
const version = await this.redis.get(this.recordVersionKey(recordId))
|
|
26
|
+
return version ? parseInt(version, 10) : 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async getRecordAndVersion(recordId) {
|
|
30
|
+
const pipeline = this.redis.pipeline()
|
|
31
|
+
pipeline.get(this.recordKey(recordId))
|
|
32
|
+
pipeline.get(this.recordVersionKey(recordId))
|
|
33
|
+
const results = await pipeline.exec()
|
|
34
|
+
const recordData = results?.[0]?.[1]
|
|
35
|
+
const versionData = results?.[1]?.[1]
|
|
36
|
+
const record = recordData ? JSON.parse(recordData) : null
|
|
37
|
+
const version = versionData ? parseInt(versionData, 10) : 0
|
|
38
|
+
return { record, version }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async publishUpdate(recordId, newValue, strategy = "replace") {
|
|
42
|
+
const recordKey = this.recordKey(recordId)
|
|
43
|
+
const versionKey = this.recordVersionKey(recordId)
|
|
44
|
+
const { record: oldValue, version: oldVersion } = await this.getRecordAndVersion(recordId)
|
|
45
|
+
|
|
46
|
+
let finalValue
|
|
47
|
+
if (strategy === "merge") {
|
|
48
|
+
finalValue = isObject(oldValue) && isObject(newValue) ? { ...oldValue, ...newValue } : newValue
|
|
49
|
+
} else if (strategy === "deepMerge") {
|
|
50
|
+
finalValue = isObject(oldValue) && isObject(newValue) ? deepMerge(oldValue, newValue) : newValue
|
|
51
|
+
} else {
|
|
52
|
+
finalValue = newValue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const patch = jsonpatch.compare(oldValue ?? {}, finalValue ?? {})
|
|
56
|
+
if (patch.length === 0) return null
|
|
57
|
+
|
|
58
|
+
const newVersion = oldVersion + 1
|
|
59
|
+
const pipeline = this.redis.pipeline()
|
|
60
|
+
pipeline.set(recordKey, JSON.stringify(finalValue))
|
|
61
|
+
pipeline.set(versionKey, newVersion.toString())
|
|
62
|
+
await pipeline.exec()
|
|
63
|
+
|
|
64
|
+
if (this.recordUpdateCallbacks.length > 0) {
|
|
65
|
+
Promise.all(
|
|
66
|
+
this.recordUpdateCallbacks.map(async (callback) => {
|
|
67
|
+
try { await callback({ recordId, value: finalValue }) }
|
|
68
|
+
catch (error) { serverLogger.error(`Error in record update callback for ${recordId}:`, error) }
|
|
69
|
+
})
|
|
70
|
+
).catch((error) => {
|
|
71
|
+
serverLogger.error(`Error in record update callbacks for ${recordId}:`, error)
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { patch, version: newVersion, finalValue }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async deleteRecord(recordId) {
|
|
79
|
+
const { record, version } = await this.getRecordAndVersion(recordId)
|
|
80
|
+
if (!record) return null
|
|
81
|
+
|
|
82
|
+
const pipeline = this.redis.pipeline()
|
|
83
|
+
pipeline.del(this.recordKey(recordId))
|
|
84
|
+
pipeline.del(this.recordVersionKey(recordId))
|
|
85
|
+
await pipeline.exec()
|
|
86
|
+
|
|
87
|
+
if (this.recordRemovedCallbacks.length > 0) {
|
|
88
|
+
Promise.all(
|
|
89
|
+
this.recordRemovedCallbacks.map(async (callback) => {
|
|
90
|
+
try { await callback({ recordId, value: record }) }
|
|
91
|
+
catch (error) { serverLogger.error(`Error in record removed callback for ${recordId}:`, error) }
|
|
92
|
+
})
|
|
93
|
+
).catch((error) => {
|
|
94
|
+
serverLogger.error(`Error in record removed callbacks for ${recordId}:`, error)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { version }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
onRecordUpdate(callback) {
|
|
102
|
+
this.recordUpdateCallbacks.push(callback)
|
|
103
|
+
return () => { this.recordUpdateCallbacks = this.recordUpdateCallbacks.filter((cb) => cb !== callback) }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
onRecordRemoved(callback) {
|
|
107
|
+
this.recordRemovedCallbacks.push(callback)
|
|
108
|
+
return () => { this.recordRemovedCallbacks = this.recordRemovedCallbacks.filter((cb) => cb !== callback) }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Redis } from "ioredis"
|
|
2
|
+
|
|
3
|
+
export class RedisManager {
|
|
4
|
+
_redis = null
|
|
5
|
+
_pubClient = null
|
|
6
|
+
_subClient = null
|
|
7
|
+
_isShuttingDown = false
|
|
8
|
+
|
|
9
|
+
initialize(options, onError) {
|
|
10
|
+
this._onRedisConnect = null
|
|
11
|
+
this._onRedisDisconnect = null
|
|
12
|
+
|
|
13
|
+
this._redis = new Redis({
|
|
14
|
+
retryStrategy: (times) => {
|
|
15
|
+
if (this._isShuttingDown) return null
|
|
16
|
+
if (times > 10) return null
|
|
17
|
+
return Math.min(1000 * Math.pow(2, times), 30000)
|
|
18
|
+
},
|
|
19
|
+
...options,
|
|
20
|
+
})
|
|
21
|
+
this._redis.on("error", (err) => onError(new Error(`Redis error: ${err}`)))
|
|
22
|
+
this._redis.on("connect", () => { if (this._onRedisConnect) this._onRedisConnect() })
|
|
23
|
+
this._redis.on("close", () => { if (!this._isShuttingDown && this._onRedisDisconnect) this._onRedisDisconnect() })
|
|
24
|
+
this._pubClient = this._redis.duplicate()
|
|
25
|
+
this._subClient = this._redis.duplicate()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get redis() {
|
|
29
|
+
if (!this._redis) throw new Error("Redis not initialized")
|
|
30
|
+
return this._redis
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get pubClient() {
|
|
34
|
+
if (!this._pubClient) throw new Error("Redis pub client not initialized")
|
|
35
|
+
return this._pubClient
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get subClient() {
|
|
39
|
+
if (!this._subClient) throw new Error("Redis sub client not initialized")
|
|
40
|
+
return this._subClient
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
disconnect() {
|
|
44
|
+
this._isShuttingDown = true
|
|
45
|
+
if (this._pubClient) { this._pubClient.disconnect(); this._pubClient = null }
|
|
46
|
+
if (this._subClient) { this._subClient.disconnect(); this._subClient = null }
|
|
47
|
+
if (this._redis) { this._redis.disconnect(); this._redis = null }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get isShuttingDown() { return this._isShuttingDown }
|
|
51
|
+
set isShuttingDown(value) { this._isShuttingDown = value }
|
|
52
|
+
|
|
53
|
+
async enableKeyspaceNotifications() {
|
|
54
|
+
const result = await this.redis.config("GET", "notify-keyspace-events")
|
|
55
|
+
const currentConfig = Array.isArray(result) && result.length > 1 ? result[1] : ""
|
|
56
|
+
let newConfig = currentConfig || ""
|
|
57
|
+
if (!newConfig.includes("E")) newConfig += "E"
|
|
58
|
+
if (!newConfig.includes("x")) newConfig += "x"
|
|
59
|
+
await this.redis.config("SET", "notify-keyspace-events", newConfig)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { deepMerge, isObject, serverLogger } from "../../shared/index.js"
|
|
2
|
+
|
|
3
|
+
export class RoomManager {
|
|
4
|
+
constructor({ redis }) {
|
|
5
|
+
this.redis = redis
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
roomKey(roomName) { return `mesh:room:${roomName}` }
|
|
9
|
+
connectionsRoomKey(connectionId) { return `mesh:connection:${connectionId}:rooms` }
|
|
10
|
+
roomMetadataKey(roomName) { return `mesh:roommeta:${roomName}` }
|
|
11
|
+
|
|
12
|
+
async getRoomConnectionIds(roomName) {
|
|
13
|
+
return this.redis.smembers(this.roomKey(roomName))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async connectionIsInRoom(roomName, connection) {
|
|
17
|
+
const connectionId = typeof connection === "string" ? connection : connection.id
|
|
18
|
+
return !!(await this.redis.sismember(this.roomKey(roomName), connectionId))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async addToRoom(roomName, connection) {
|
|
22
|
+
const connectionId = typeof connection === "string" ? connection : connection.id
|
|
23
|
+
await this.redis.sadd(this.roomKey(roomName), connectionId)
|
|
24
|
+
await this.redis.sadd(this.connectionsRoomKey(connectionId), roomName)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async getRoomsForConnection(connection) {
|
|
28
|
+
const connectionId = typeof connection === "string" ? connection : connection.id
|
|
29
|
+
return await this.redis.smembers(this.connectionsRoomKey(connectionId))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async getAllRooms() {
|
|
33
|
+
const keys = await this.redis.keys("mesh:room:*")
|
|
34
|
+
return keys.map((key) => key.replace("mesh:room:", ""))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async removeFromRoom(roomName, connection) {
|
|
38
|
+
const connectionId = typeof connection === "string" ? connection : connection.id
|
|
39
|
+
const pipeline = this.redis.pipeline()
|
|
40
|
+
pipeline.srem(this.roomKey(roomName), connectionId)
|
|
41
|
+
pipeline.srem(this.connectionsRoomKey(connectionId), roomName)
|
|
42
|
+
await pipeline.exec()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async removeFromAllRooms(connection) {
|
|
46
|
+
const connectionId = typeof connection === "string" ? connection : connection.id
|
|
47
|
+
const rooms = await this.redis.smembers(this.connectionsRoomKey(connectionId))
|
|
48
|
+
const pipeline = this.redis.pipeline()
|
|
49
|
+
for (const room of rooms) {
|
|
50
|
+
pipeline.srem(this.roomKey(room), connectionId)
|
|
51
|
+
}
|
|
52
|
+
pipeline.del(this.connectionsRoomKey(connectionId))
|
|
53
|
+
await pipeline.exec()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async clearRoom(roomName) {
|
|
57
|
+
const connectionIds = await this.getRoomConnectionIds(roomName)
|
|
58
|
+
const pipeline = this.redis.pipeline()
|
|
59
|
+
for (const connectionId of connectionIds) {
|
|
60
|
+
pipeline.srem(this.connectionsRoomKey(connectionId), roomName)
|
|
61
|
+
}
|
|
62
|
+
pipeline.del(this.roomKey(roomName))
|
|
63
|
+
await pipeline.exec()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async deleteRoom(roomName) {
|
|
67
|
+
const connectionIds = await this.getRoomConnectionIds(roomName)
|
|
68
|
+
const pipeline = this.redis.pipeline()
|
|
69
|
+
for (const connectionId of connectionIds) {
|
|
70
|
+
pipeline.srem(this.connectionsRoomKey(connectionId), roomName)
|
|
71
|
+
}
|
|
72
|
+
pipeline.del(this.roomKey(roomName))
|
|
73
|
+
pipeline.del(this.roomMetadataKey(roomName))
|
|
74
|
+
await pipeline.exec()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async cleanupConnection(connection) {
|
|
78
|
+
const rooms = await this.redis.smembers(this.connectionsRoomKey(connection.id))
|
|
79
|
+
const pipeline = this.redis.pipeline()
|
|
80
|
+
for (const room of rooms) {
|
|
81
|
+
pipeline.srem(this.roomKey(room), connection.id)
|
|
82
|
+
}
|
|
83
|
+
pipeline.del(this.connectionsRoomKey(connection.id))
|
|
84
|
+
await pipeline.exec()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async setMetadata(roomName, metadata, options) {
|
|
88
|
+
let finalMetadata
|
|
89
|
+
const strategy = options?.strategy || "replace"
|
|
90
|
+
if (strategy === "replace") {
|
|
91
|
+
finalMetadata = metadata
|
|
92
|
+
} else {
|
|
93
|
+
const existingMetadata = await this.getMetadata(roomName)
|
|
94
|
+
if (strategy === "merge") {
|
|
95
|
+
finalMetadata = isObject(existingMetadata) && isObject(metadata)
|
|
96
|
+
? { ...existingMetadata, ...metadata }
|
|
97
|
+
: metadata
|
|
98
|
+
} else if (strategy === "deepMerge") {
|
|
99
|
+
finalMetadata = isObject(existingMetadata) && isObject(metadata)
|
|
100
|
+
? deepMerge(existingMetadata, metadata)
|
|
101
|
+
: metadata
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await this.redis.hset(this.roomMetadataKey(roomName), "data", JSON.stringify(finalMetadata))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async getMetadata(roomName) {
|
|
108
|
+
const data = await this.redis.hget(this.roomMetadataKey(roomName), "data")
|
|
109
|
+
return data ? JSON.parse(data) : null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async getAllMetadata() {
|
|
113
|
+
const keys = await this.redis.keys("mesh:roommeta:*")
|
|
114
|
+
const result = []
|
|
115
|
+
if (keys.length === 0) return result
|
|
116
|
+
const pipeline = this.redis.pipeline()
|
|
117
|
+
keys.forEach((key) => pipeline.hget(key, "data"))
|
|
118
|
+
const results = await pipeline.exec()
|
|
119
|
+
keys.forEach((key, index) => {
|
|
120
|
+
const roomName = key.replace("mesh:roommeta:", "")
|
|
121
|
+
const data = results?.[index]?.[1]
|
|
122
|
+
if (data) {
|
|
123
|
+
try { result.push({ id: roomName, metadata: JSON.parse(data) }) }
|
|
124
|
+
catch (e) { serverLogger.error(`Failed to parse metadata for room ${roomName}:`, e) }
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
return result
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events"
|
|
2
|
+
|
|
3
|
+
export class MessageStream extends EventEmitter {
|
|
4
|
+
constructor() {
|
|
5
|
+
super()
|
|
6
|
+
this.setMaxListeners(100)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
publishMessage(channel, message, instanceId) {
|
|
10
|
+
this.emit("message", { channel, message, instanceId, timestamp: Date.now() })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
subscribeToMessages(callback) {
|
|
14
|
+
this.on("message", callback)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
unsubscribeFromMessages(callback) {
|
|
18
|
+
this.off("message", callback)
|
|
19
|
+
}
|
|
20
|
+
}
|