@quenty/datastore 13.40.0 → 13.42.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/package.json +12 -12
  3. package/src/Server/DataStore.HookErrors.spec.lua +92 -0
  4. package/src/Server/DataStore.LoadErrors.spec.lua +133 -0
  5. package/src/Server/DataStore.Reentrance.spec.lua +119 -0
  6. package/src/Server/DataStore.SessionLock.spec.lua +478 -0
  7. package/src/Server/DataStore.SessionMessaging.spec.lua +71 -0
  8. package/src/Server/DataStore.TwoServerLock.spec.lua +190 -0
  9. package/src/Server/DataStore.lua +89 -18
  10. package/src/Server/DataStore.spec.lua +237 -0
  11. package/src/Server/DataStoreLockHelper.spec.lua +239 -0
  12. package/src/Server/DataStoreMessageHelper.spec.lua +134 -0
  13. package/src/Server/DataStoreNonRetryableLoadError.lua +61 -0
  14. package/src/Server/DataStoreTestUtils.lua +206 -0
  15. package/src/Server/GameDataStoreService.lua +26 -0
  16. package/src/Server/GameDataStoreService.spec.lua +215 -0
  17. package/src/Server/Mocks/DataStoreMock.lua +405 -0
  18. package/src/Server/Mocks/DataStoreMock.spec.lua +384 -0
  19. package/src/Server/Modules/DataStoreSnapshotUtils.spec.lua +91 -0
  20. package/src/Server/Modules/DataStoreStage.lua +8 -2
  21. package/src/Server/Modules/DataStoreStage.spec.lua +539 -0
  22. package/src/Server/Modules/DataStoreWriter.spec.lua +399 -0
  23. package/src/Server/PlayerDataStoreManager.RemovalCallbacks.spec.lua +99 -0
  24. package/src/Server/PlayerDataStoreManager.lua +36 -4
  25. package/src/Server/PlayerDataStoreManager.spec.lua +202 -0
  26. package/src/Server/PlayerDataStoreService.lua +18 -0
  27. package/src/Server/PlayerDataStoreService.spec.lua +218 -0
  28. package/src/Server/PrivateServerDataStoreService.lua +23 -1
  29. package/src/Server/PrivateServerDataStoreService.spec.lua +242 -0
  30. package/src/Server/Utility/DataStorePromises.lua +18 -5
  31. package/src/Server/Utility/DataStorePromises.spec.lua +363 -0
  32. package/src/Shared/Utility/DataStoreStringUtils.spec.lua +1 -1
  33. package/src/jest.config.lua +3 -0
@@ -0,0 +1,478 @@
1
+ --!nonstrict
2
+ --[[
3
+ Deep characterization of the session-lock state machine (DataStoreLockHelper) and the
4
+ cross-server scenarios it governs: validate/invalidate a lock, steal a crashed session's lock,
5
+ block a live session, and prevent data duplication when a session is stolen. Two servers are
6
+ modeled as two DataStore objects sharing one DataStoreMock, with distinct SessionIds. The
7
+ blocking cases assume RunService:IsStudio() == false (true in the cloud test runner); in Studio,
8
+ ALWAYS_STEAL_LOCKS_IN_STUDIO makes every foreign lock stealable.
9
+
10
+ @class DataStoreSessionLock.spec.lua
11
+ ]]
12
+ local require = require(script.Parent.loader).load(script)
13
+
14
+ local DataStoreTestUtils = require("DataStoreTestUtils")
15
+ local Jest = require("Jest")
16
+ local PromiseTestUtils = require("PromiseTestUtils")
17
+
18
+ local describe = Jest.Globals.describe
19
+ local expect = Jest.Globals.expect
20
+ local it = Jest.Globals.it
21
+
22
+ local function foreignSession(sessionId: string?)
23
+ return {
24
+ SessionId = sessionId or "foreign-session-id",
25
+ PlaceId = 999999,
26
+ JobId = "foreign-job-id",
27
+ }
28
+ end
29
+
30
+ -- Builds a stored profile locked by an arbitrary session.
31
+ local function lockedBy(session, lastUpdateTime: number?, data: { [string]: any }?)
32
+ local profile = {}
33
+ if data then
34
+ for key, value in data do
35
+ profile[key] = value
36
+ end
37
+ end
38
+ profile.lock = {
39
+ LastUpdateTime = lastUpdateTime,
40
+ ActiveSession = session,
41
+ }
42
+ return profile
43
+ end
44
+
45
+ describe("DataStoreLockHelper.AcquireLock", function()
46
+ it("acquires an unlocked (nil) profile", function()
47
+ local controller = DataStoreTestUtils.setup()
48
+ local helper = controller.newLockHelper()
49
+ local result = helper:AcquireLock(nil, false)
50
+ expect(result.isValid).toEqual(true)
51
+ expect(result.stolenLockFromSession).toEqual(nil)
52
+ controller:destroy()
53
+ end)
54
+
55
+ it("acquires a profile that has no lock, preserving its data", function()
56
+ local controller = DataStoreTestUtils.setup()
57
+ local helper = controller.newLockHelper()
58
+ local result = helper:AcquireLock({ coins = 5 }, false)
59
+ expect(result.isValid).toEqual(true)
60
+ expect(result.unlockedProfile.coins).toEqual(5)
61
+ expect(type(result.lockedProfile.lock)).toEqual("table")
62
+ controller:destroy()
63
+ end)
64
+
65
+ it("re-acquires a profile locked by our own session", function()
66
+ local controller = DataStoreTestUtils.setup()
67
+ local helper, dataStore = controller.newLockHelper()
68
+ local ownProfile = helper:ToLockedProfile({ coins = 3 })
69
+ local result = helper:AcquireLock(ownProfile, false)
70
+ expect(result.isValid).toEqual(true)
71
+ expect(result.stolenLockFromSession).toEqual(nil)
72
+ expect(result.lockedProfile.lock.ActiveSession.SessionId).toEqual(dataStore:GetSessionId())
73
+ controller:destroy()
74
+ end)
75
+
76
+ it("is blocked by a fresh lock held by another session", function()
77
+ local controller = DataStoreTestUtils.setup()
78
+ local helper = controller.newLockHelper()
79
+ local result = helper:AcquireLock(lockedBy(foreignSession(), os.time()), false)
80
+ expect(result.isValid).toEqual(false)
81
+ expect(result.blockingSession.SessionId).toEqual("foreign-session-id")
82
+ controller:destroy()
83
+ end)
84
+
85
+ it("steals a foreign lock when canStealLock is true", function()
86
+ local controller = DataStoreTestUtils.setup()
87
+ local helper = controller.newLockHelper()
88
+ local result = helper:AcquireLock(lockedBy(foreignSession(), os.time()), true)
89
+ expect(result.isValid).toEqual(true)
90
+ expect(result.stolenLockFromSession.SessionId).toEqual("foreign-session-id")
91
+ controller:destroy()
92
+ end)
93
+
94
+ it("steals a stale foreign lock (crashed session) without stealing explicitly", function()
95
+ local controller = DataStoreTestUtils.setup()
96
+ local helper = controller.newLockHelper()
97
+ -- Older than GetAutoSaveTimeSeconds() * 2.1 (300 * 2.1 = 630s).
98
+ local result = helper:AcquireLock(lockedBy(foreignSession(), os.time() - 700, { coins = 9 }), false)
99
+ expect(result.isValid).toEqual(true)
100
+ expect(result.stolenLockFromSession.SessionId).toEqual("foreign-session-id")
101
+ -- The crashed session's data survives the steal.
102
+ expect(result.unlockedProfile.coins).toEqual(9)
103
+ controller:destroy()
104
+ end)
105
+
106
+ it("does NOT steal a foreign lock that is only slightly old", function()
107
+ local controller = DataStoreTestUtils.setup()
108
+ local helper = controller.newLockHelper()
109
+ local result = helper:AcquireLock(lockedBy(foreignSession(), os.time() - 100, {}), false)
110
+ expect(result.isValid).toEqual(false)
111
+ controller:destroy()
112
+ end)
113
+
114
+ it("is blocked by a foreign lock that has no LastUpdateTime (cannot judge staleness)", function()
115
+ local controller = DataStoreTestUtils.setup()
116
+ local helper = controller.newLockHelper()
117
+ local result = helper:AcquireLock(lockedBy(foreignSession(), nil, {}), false)
118
+ expect(result.isValid).toEqual(false)
119
+ controller:destroy()
120
+ end)
121
+
122
+ it("acquires when the lock envelope has no ActiveSession", function()
123
+ local controller = DataStoreTestUtils.setup()
124
+ local helper = controller.newLockHelper()
125
+ local result = helper:AcquireLock({ coins = 1, lock = { LastUpdateTime = os.time() } }, false)
126
+ expect(result.isValid).toEqual(true)
127
+ controller:destroy()
128
+ end)
129
+
130
+ it("acquires when the lock field is malformed (not a table)", function()
131
+ local controller = DataStoreTestUtils.setup()
132
+ local helper = controller.newLockHelper()
133
+ local result = helper:AcquireLock({ coins = 1, lock = "not a table" }, false)
134
+ expect(result.isValid).toEqual(true)
135
+ controller:destroy()
136
+ end)
137
+
138
+ it("passes through non-table data (locking not applicable)", function()
139
+ local controller = DataStoreTestUtils.setup()
140
+ local helper = controller.newLockHelper()
141
+ local result = helper:AcquireLock("a raw string", false)
142
+ expect(result.isValid).toEqual(true)
143
+ expect(result.unlockedProfile).toEqual("a raw string")
144
+ controller:destroy()
145
+ end)
146
+ end)
147
+
148
+ describe("DataStoreLockHelper.ToUnlockedProfile (save-side thief detection)", function()
149
+ it("validates a nil profile", function()
150
+ local controller = DataStoreTestUtils.setup()
151
+ local helper = controller.newLockHelper()
152
+ local result = helper:ToUnlockedProfile(nil)
153
+ expect(result.isValid).toEqual(true)
154
+ expect(result.unlockedProfile).toEqual({})
155
+ controller:destroy()
156
+ end)
157
+
158
+ it("validates a profile locked by our own session and strips the lock", function()
159
+ local controller = DataStoreTestUtils.setup()
160
+ local helper = controller.newLockHelper()
161
+ local ownProfile = helper:ToLockedProfile({ coins = 5 })
162
+ local result = helper:ToUnlockedProfile(ownProfile)
163
+ expect(result.isValid).toEqual(true)
164
+ expect(result.unlockedProfile.coins).toEqual(5)
165
+ expect(result.unlockedProfile.lock).toEqual(nil)
166
+ controller:destroy()
167
+ end)
168
+
169
+ it("invalidates a profile whose lock was stolen by another session", function()
170
+ local controller = DataStoreTestUtils.setup()
171
+ local helper = controller.newLockHelper()
172
+ local result = helper:ToUnlockedProfile(lockedBy(foreignSession(), os.time(), { coins = 5 }))
173
+ expect(result.isValid).toEqual(false)
174
+ expect(result.thiefSession.SessionId).toEqual("foreign-session-id")
175
+ controller:destroy()
176
+ end)
177
+
178
+ it("validates a profile that has no lock", function()
179
+ local controller = DataStoreTestUtils.setup()
180
+ local helper = controller.newLockHelper()
181
+ local result = helper:ToUnlockedProfile({ coins = 5 })
182
+ expect(result.isValid).toEqual(true)
183
+ controller:destroy()
184
+ end)
185
+
186
+ it("passes through non-table data", function()
187
+ local controller = DataStoreTestUtils.setup()
188
+ local helper = controller.newLockHelper()
189
+ local result = helper:ToUnlockedProfile("raw")
190
+ expect(result.isValid).toEqual(true)
191
+ expect(result.unlockedProfile).toEqual("raw")
192
+ controller:destroy()
193
+ end)
194
+ end)
195
+
196
+ describe("DataStoreLockHelper.ToLockedProfile / ToRawUnlockedProfile", function()
197
+ it("adds our lock and preserves user data", function()
198
+ local controller = DataStoreTestUtils.setup()
199
+ local helper, dataStore = controller.newLockHelper()
200
+ local locked = helper:ToLockedProfile({ coins = 5 })
201
+ expect(locked.coins).toEqual(5)
202
+ expect(locked.lock.ActiveSession.SessionId).toEqual(dataStore:GetSessionId())
203
+ expect(type(locked.lock.LastUpdateTime)).toEqual("number")
204
+ controller:destroy()
205
+ end)
206
+
207
+ it("releases the lock (doCloseSession) and preserves user data", function()
208
+ local controller = DataStoreTestUtils.setup()
209
+ local helper = controller.newLockHelper()
210
+ local released = helper:ToLockedProfile({ coins = 5 }, true)
211
+ expect(released.coins).toEqual(5)
212
+ expect(released.lock).toEqual(nil)
213
+ controller:destroy()
214
+ end)
215
+
216
+ it("does not mutate the original profile", function()
217
+ local controller = DataStoreTestUtils.setup()
218
+ local helper = controller.newLockHelper()
219
+ local original: { coins: number, lock: any? } = { coins = 5 }
220
+ helper:ToLockedProfile(original)
221
+ expect(original.lock).toEqual(nil)
222
+ controller:destroy()
223
+ end)
224
+
225
+ it("strips the lock via ToRawUnlockedProfile without mutating the original", function()
226
+ local controller = DataStoreTestUtils.setup()
227
+ local helper = controller.newLockHelper()
228
+ local original = { coins = 5, lock = { LastUpdateTime = os.time() } }
229
+ local raw = helper:ToRawUnlockedProfile(original)
230
+ expect(raw.lock).toEqual(nil)
231
+ expect(raw.coins).toEqual(5)
232
+ expect(type(original.lock)).toEqual("table")
233
+ controller:destroy()
234
+ end)
235
+
236
+ it("locks nil to an envelope, and closes nil to an empty profile", function()
237
+ local controller = DataStoreTestUtils.setup()
238
+ local helper = controller.newLockHelper()
239
+ expect(type(helper:ToLockedProfile(nil).lock)).toEqual("table")
240
+ expect(helper:ToLockedProfile(nil, true)).toEqual({})
241
+ controller:destroy()
242
+ end)
243
+
244
+ it("round-trips user data through lock then unlock with no corruption", function()
245
+ local controller = DataStoreTestUtils.setup()
246
+ local helper = controller.newLockHelper()
247
+ local data = { coins = 5, nested = { a = 1, b = { 2, 3 } } }
248
+ local roundTripped = helper:ToRawUnlockedProfile(helper:ToLockedProfile(data))
249
+ expect(roundTripped).toEqual(data)
250
+ controller:destroy()
251
+ end)
252
+ end)
253
+
254
+ describe("DataStoreLockHelper.PromiseCloseSession", function()
255
+ it("is pending until the session is closed", function()
256
+ local controller = DataStoreTestUtils.setup()
257
+ local helper = controller.newLockHelper()
258
+ expect(helper:PromiseCloseSession():IsPending()).toEqual(true)
259
+ controller:destroy()
260
+ end)
261
+
262
+ it("resolves once ToLockedProfile closes the session", function()
263
+ local controller = DataStoreTestUtils.setup()
264
+ local helper = controller.newLockHelper()
265
+ local promise = helper:PromiseCloseSession()
266
+ helper:ToLockedProfile({ coins = 5 }, true)
267
+ expect(PromiseTestUtils.awaitSettled(promise, 5)).toEqual(true)
268
+ expect((promise:Yield())).toEqual(true)
269
+ controller:destroy()
270
+ end)
271
+ end)
272
+
273
+ describe("session lock cross-server scenarios (full DataStore)", function()
274
+ it("blocks a new session's load while another session holds a fresh lock", function()
275
+ local controller = DataStoreTestUtils.setup()
276
+
277
+ -- A fresh, live foreign lock is present in the datastore.
278
+ controller.mock:SetRaw("player_1", lockedBy(foreignSession(), os.time(), { coins = 1 }))
279
+
280
+ local dataStore = controller.newDataStore()
281
+ dataStore:SetSessionLockingEnabled(true)
282
+ dataStore:SetUserIdList({ 1 })
283
+
284
+ -- The load is legitimately blocked (retrying to acquire), so it must NOT settle quickly.
285
+ local promise = dataStore:PromiseLoadSuccessful()
286
+ expect(PromiseTestUtils.awaitSettled(promise, 3)).toEqual(false)
287
+
288
+ controller:destroy()
289
+ end)
290
+
291
+ it("acquires the lock once the holding session releases it (retry resolves genuine contention)", function()
292
+ -- GUARD for the load-hang fix: a blocked load must still RESOLVE via retry when the holder
293
+ -- releases -- only genuine op FAILURES should fail fast, not lock contention (a successful op
294
+ -- that returns a locked profile).
295
+ local controller = DataStoreTestUtils.setup()
296
+
297
+ -- Session A acquires and holds the lock.
298
+ local sessionA = controller.newDataStore()
299
+ sessionA:SetSessionLockingEnabled(true)
300
+ sessionA:SetUserIdList({ 1 })
301
+ local loadA = sessionA:PromiseLoadSuccessful()
302
+ if not PromiseTestUtils.awaitSettled(loadA, 10) then
303
+ expect("A load hung").toEqual("A load settled")
304
+ controller:destroy()
305
+ return
306
+ end
307
+ expect((loadA:Yield())).toEqual(true)
308
+
309
+ -- Session B starts loading; A's fresh lock blocks it, so B is retrying (not yet settled). Use a
310
+ -- tiny retry backoff so the test exercises the retry quickly instead of the ~6.5s production one.
311
+ local sessionB = controller.newDataStore()
312
+ sessionB:SetSessionLockingEnabled(true)
313
+ sessionB:SetUserIdList({ 1 })
314
+ sessionB:SetLoadRetryOptions({ exponential = 1, initialWaitTime = 0.1, maxAttempts = 100, printWarning = false })
315
+ local loadB = sessionB:PromiseLoadSuccessful()
316
+ expect(PromiseTestUtils.awaitSettled(loadB, 0.5)).toEqual(false)
317
+
318
+ -- A releases the lock; B's next retry attempt should acquire it and the load resolves.
319
+ local closeA = sessionA:SaveAndCloseSession()
320
+ if not PromiseTestUtils.awaitSettled(closeA, 10) then
321
+ expect("A close hung").toEqual("A close settled")
322
+ controller:destroy()
323
+ return
324
+ end
325
+
326
+ if not PromiseTestUtils.awaitSettled(loadB, 20) then
327
+ expect("B never acquired the released lock").toEqual("B acquired the released lock")
328
+ controller:destroy()
329
+ return
330
+ end
331
+ expect((loadB:Yield())).toEqual(true)
332
+
333
+ controller:destroy()
334
+ end)
335
+
336
+ it("prevents data duplication: a stolen session's save is cancelled and the owner's data wins", function()
337
+ local controller = DataStoreTestUtils.setup()
338
+
339
+ local sessionA = controller.newDataStore()
340
+ sessionA:SetSessionLockingEnabled(true)
341
+ sessionA:SetUserIdList({ 1 })
342
+
343
+ local loadA = sessionA:PromiseLoadSuccessful()
344
+ if not PromiseTestUtils.awaitSettled(loadA, 10) then
345
+ expect("hung").toEqual("settled")
346
+ controller:destroy()
347
+ return
348
+ end
349
+ expect((loadA:Yield())).toEqual(true)
350
+
351
+ -- Another live session steals the lock and writes its own value directly into the datastore.
352
+ controller.mock:SetRaw("player_1", lockedBy(foreignSession("winner-session"), os.time(), { coins = 20 }))
353
+
354
+ -- Session A tries to save its own (now-orphaned) change.
355
+ sessionA:Store("coins", 10)
356
+ local saveA = sessionA:Save()
357
+ if not PromiseTestUtils.awaitSettled(saveA, 10) then
358
+ expect("hung").toEqual("settled")
359
+ controller:destroy()
360
+ return
361
+ end
362
+
363
+ -- A's write was cancelled, so the datastore still holds the winner's data -- not A's, and not
364
+ -- a merged/duplicated mix.
365
+ local raw = controller.mock:GetRaw("player_1")
366
+ expect(raw.coins).toEqual(20)
367
+ expect(raw.lock.ActiveSession.SessionId).toEqual("winner-session")
368
+
369
+ controller:destroy()
370
+ end)
371
+ end)
372
+
373
+ describe("session lock edge cases and failure modes", function()
374
+ it("acquires the lock only once across repeated loads (no double-acquire)", function()
375
+ local controller = DataStoreTestUtils.setup()
376
+
377
+ local dataStore = controller.newDataStore()
378
+ dataStore:SetSessionLockingEnabled(true)
379
+ dataStore:SetUserIdList({ 1 })
380
+
381
+ local first = dataStore:PromiseLoadSuccessful()
382
+ local second = dataStore:PromiseLoadSuccessful()
383
+ if not PromiseTestUtils.awaitSettled(first, 10) or not PromiseTestUtils.awaitSettled(second, 10) then
384
+ expect("hung").toEqual("settled")
385
+ controller:destroy()
386
+ return
387
+ end
388
+ expect((first:Yield())).toEqual(true)
389
+
390
+ -- The load is cached (_firstLoadPromise), so exactly one UpdateAsync acquires the lock -- a
391
+ -- second acquire would risk two "owners" of the session.
392
+ expect(controller.mock:GetCallCount("UpdateAsync")).toEqual(1)
393
+
394
+ controller:destroy()
395
+ end)
396
+
397
+ it("keeps stored data consistent under two concurrent saves", function()
398
+ local controller = DataStoreTestUtils.setup()
399
+
400
+ local dataStore = controller.newDataStore()
401
+ dataStore:SetSessionLockingEnabled(true)
402
+ dataStore:SetUserIdList({ 1 })
403
+
404
+ local load = dataStore:PromiseLoadSuccessful()
405
+ if not PromiseTestUtils.awaitSettled(load, 10) then
406
+ expect("hung").toEqual("settled")
407
+ controller:destroy()
408
+ return
409
+ end
410
+
411
+ dataStore:Store("coins", 1)
412
+ local saveOne = dataStore:Save()
413
+ dataStore:Store("coins", 2)
414
+ local saveTwo = dataStore:Save()
415
+
416
+ if not PromiseTestUtils.awaitSettled(saveOne, 10) or not PromiseTestUtils.awaitSettled(saveTwo, 10) then
417
+ expect("hung").toEqual("settled")
418
+ controller:destroy()
419
+ return
420
+ end
421
+
422
+ -- No corruption/duplication: the stored value is a valid number still owned by us, and the
423
+ -- last staged value won.
424
+ local raw = controller.mock:GetRaw("player_1")
425
+ expect(type(raw.coins)).toEqual("number")
426
+ expect(raw.coins).toEqual(2)
427
+ expect(raw.lock.ActiveSession.SessionId).toEqual(dataStore:GetSessionId())
428
+
429
+ controller:destroy()
430
+ end)
431
+ end)
432
+
433
+ describe("why session locking exists (unlocked stores can duplicate)", function()
434
+ it("allows item duplication under a read-before-store race, without session locking", function()
435
+ -- The classic dupe that session locking prevents: two servers load the same profile, one
436
+ -- trades an item away and saves, but the other loaded BEFORE that save (or is a crashed
437
+ -- server's stale session) and writes its stale view back -- restoring the traded-away item.
438
+ -- This is EXPECTED behavior for unlocked stores; it is the whole motivation for locking.
439
+ local controller = DataStoreTestUtils.setup()
440
+ controller.mock:SetRaw("player_1", { items = { "rare_sword" } })
441
+
442
+ -- Two servers, NO session locking, both load the same starting state (B reads before A stores).
443
+ local serverA = controller.newDataStore()
444
+ local serverB = controller.newDataStore()
445
+
446
+ local aLoad = serverA:Load("items")
447
+ local bLoad = serverB:Load("items")
448
+ if not PromiseTestUtils.awaitSettled(aLoad, 5) or not PromiseTestUtils.awaitSettled(bLoad, 5) then
449
+ expect("load hung").toEqual("load settled")
450
+ controller:destroy()
451
+ return
452
+ end
453
+ expect((aLoad:Wait())).toEqual({ "rare_sword" })
454
+ expect((bLoad:Wait())).toEqual({ "rare_sword" })
455
+
456
+ -- Server A: the player trades the sword away, and A saves it gone.
457
+ serverA:Store("items", {})
458
+ if not PromiseTestUtils.awaitSettled(serverA:Save(), 5) then
459
+ expect("A save hung").toEqual("A save settled")
460
+ controller:destroy()
461
+ return
462
+ end
463
+ expect(controller.mock:GetRaw("player_1").items).toEqual({})
464
+
465
+ -- Server B still holds the stale sword and writes it back on its own save.
466
+ serverB:Store("items", { "rare_sword" })
467
+ if not PromiseTestUtils.awaitSettled(serverB:Save(), 5) then
468
+ expect("B save hung").toEqual("B save settled")
469
+ controller:destroy()
470
+ return
471
+ end
472
+
473
+ -- The traded-away sword is back: duplicated. An unlocked store cannot prevent this.
474
+ expect(controller.mock:GetRaw("player_1").items).toEqual({ "rare_sword" })
475
+
476
+ controller:destroy()
477
+ end)
478
+ end)
@@ -0,0 +1,71 @@
1
+ --!nonstrict
2
+ --[[
3
+ Cross-server session messaging: one session asks another to close (the "you got kicked because
4
+ you activated elsewhere" flow). Modeled with two DataStoreMessageHelpers (session A and B) over
5
+ one server. A MessagingServiceMock is injected into the PlaceMessagingService and loops messages
6
+ back in-process, so B's close-session request is delivered to A's subscription without ever
7
+ touching the real MessagingService.
8
+
9
+ @class DataStoreSessionMessaging.spec.lua
10
+ ]]
11
+ local require = require(script.Parent.loader).load(script)
12
+
13
+ local DataStoreTestUtils = require("DataStoreTestUtils")
14
+ local Jest = require("Jest")
15
+ local PromiseTestUtils = require("PromiseTestUtils")
16
+
17
+ local describe = Jest.Globals.describe
18
+ local expect = Jest.Globals.expect
19
+ local it = Jest.Globals.it
20
+
21
+ describe("cross-server session messaging (close-session kick-out)", function()
22
+ it("fires SessionCloseRequested on the session that receives a close-session message", function()
23
+ local controller = DataStoreTestUtils.setup()
24
+
25
+ -- Two sessions on the same key, each with its own message helper (subscribes to its own topic).
26
+ local sessionA = controller.newDataStore()
27
+ local sessionB = controller.newDataStore()
28
+
29
+ local _helperA = controller.newMessageHelper(sessionA)
30
+ local helperB = controller.newMessageHelper(sessionB)
31
+
32
+ local closeRequested = false
33
+ sessionA.SessionCloseRequested:Connect(function()
34
+ closeRequested = true
35
+ end)
36
+
37
+ -- B asks A to close (as if A just teleported / activated on server B).
38
+ local sendPromise = helperB:PromiseSendSessionMessage(game.PlaceId, game.JobId, sessionA:GetSessionId(), {
39
+ type = "close-session",
40
+ requesterSessionId = sessionB:GetSessionId(),
41
+ })
42
+
43
+ if not PromiseTestUtils.awaitSettled(sendPromise, 10) then
44
+ expect("message send hung").toEqual("message send settled")
45
+ controller:destroy()
46
+ return
47
+ end
48
+
49
+ local received = PromiseTestUtils.awaitValue(function()
50
+ return closeRequested
51
+ end, 15)
52
+ expect(received).toEqual(true)
53
+
54
+ controller:destroy()
55
+ end)
56
+
57
+ it("rejects sending a message to our own session", function()
58
+ local controller = DataStoreTestUtils.setup()
59
+ local sessionA = controller.newDataStore()
60
+ local helperA = controller.newMessageHelper(sessionA)
61
+
62
+ expect(function()
63
+ helperA:PromiseSendSessionMessage(game.PlaceId, game.JobId, sessionA:GetSessionId(), {
64
+ type = "close-session",
65
+ requesterSessionId = sessionA:GetSessionId(),
66
+ })
67
+ end).toThrow("Cannot message self")
68
+
69
+ controller:destroy()
70
+ end)
71
+ end)