@quenty/servicebag 11.19.0 → 11.20.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/CHANGELOG.md +6 -0
- package/deploy.nevermore.json +10 -0
- package/package.json +6 -4
- package/src/Shared/ServiceBag.lua +37 -14
- package/src/Shared/ServiceBag.spec.lua +296 -0
- package/src/jest.config.lua +3 -0
- package/test/default.project.json +25 -0
- package/test/scripts/Server/ServerMain.server.lua +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [11.20.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/servicebag@11.19.0...@quenty/servicebag@11.20.0) (2026-07-23)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- Add baseline player-mock and support across Nevermore for mocked players. ([567d121](https://github.com/Quenty/NevermoreEngine/commit/567d121ffc014b42391554088189a1a6296dda83))
|
|
11
|
+
|
|
6
12
|
# [11.19.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/servicebag@11.18.1...@quenty/servicebag@11.19.0) (2026-07-18)
|
|
7
13
|
|
|
8
14
|
**Note:** Version bump only for package @quenty/servicebag
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quenty/servicebag",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.20.0",
|
|
4
4
|
"description": "Service providing mechanisms for Nevermore",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Roblox",
|
|
@@ -29,12 +29,14 @@
|
|
|
29
29
|
"Quenty"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@quenty/baseobject": "10.
|
|
32
|
+
"@quenty/baseobject": "10.15.0",
|
|
33
33
|
"@quenty/loader": "10.11.0",
|
|
34
|
-
"@quenty/
|
|
34
|
+
"@quenty/nevermore-test-runner": "1.5.0",
|
|
35
|
+
"@quenty/signal": "7.13.1",
|
|
36
|
+
"@quentystudios/jest-lua": "3.10.0-quenty.2"
|
|
35
37
|
},
|
|
36
38
|
"publishConfig": {
|
|
37
39
|
"access": "public"
|
|
38
40
|
},
|
|
39
|
-
"gitHead": "
|
|
41
|
+
"gitHead": "1de37218a2bedb8e3f8614a2e09bba9eddc812da"
|
|
40
42
|
}
|
|
@@ -58,6 +58,7 @@ export type ServiceBag =
|
|
|
58
58
|
_parentProvider: ServiceBag?,
|
|
59
59
|
_serviceTypesToInitializeSet: { [any]: true }?,
|
|
60
60
|
_initializedServiceTypeSet: { [any]: true },
|
|
61
|
+
_initializedServiceTypeList: { any },
|
|
61
62
|
_serviceTypesToStartSet: { [any]: true }?,
|
|
62
63
|
|
|
63
64
|
_initRunAllowed: boolean,
|
|
@@ -85,6 +86,7 @@ function ServiceBag.new(parentProvider: ServiceBag?): ServiceBag
|
|
|
85
86
|
|
|
86
87
|
self._serviceTypesToInitializeSet = {}
|
|
87
88
|
self._initializedServiceTypeSet = {}
|
|
89
|
+
self._initializedServiceTypeList = {}
|
|
88
90
|
self._serviceTypesToStartSet = {}
|
|
89
91
|
|
|
90
92
|
self._initRunAllowed = false
|
|
@@ -345,6 +347,9 @@ function ServiceBag._initService(self: ServiceBag, serviceType)
|
|
|
345
347
|
end
|
|
346
348
|
end
|
|
347
349
|
|
|
350
|
+
-- Init-completion order is a valid dependency order (a service's dependencies finish
|
|
351
|
+
-- initializing before it does, via the GetService recursion), so record it for teardown.
|
|
352
|
+
table.insert(self._initializedServiceTypeList, serviceType)
|
|
348
353
|
table.insert(self._serviceTypesToStartSet, serviceType)
|
|
349
354
|
end
|
|
350
355
|
|
|
@@ -371,30 +376,48 @@ end
|
|
|
371
376
|
|
|
372
377
|
function ServiceBag._destructServices(self: ServiceBag)
|
|
373
378
|
local services = self._services
|
|
379
|
+
|
|
380
|
+
-- Destroy in reverse initialization order, so dependents tear down before the dependencies
|
|
381
|
+
-- they may still be writing to (see _initService: init-completion order is a dependency order).
|
|
382
|
+
local initializedList = self._initializedServiceTypeList
|
|
383
|
+
for index = #initializedList, 1, -1 do
|
|
384
|
+
local serviceType = initializedList[index]
|
|
385
|
+
local service = services[serviceType]
|
|
386
|
+
if service ~= nil then
|
|
387
|
+
services[serviceType] = nil
|
|
388
|
+
self:_destructService(serviceType, service)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
-- Anything left never finished initializing; destroy it in arbitrary order, as before.
|
|
374
393
|
local serviceType, service = next(services)
|
|
375
394
|
while service ~= nil do
|
|
376
395
|
services[serviceType] = nil
|
|
377
396
|
|
|
378
397
|
if not (self._serviceTypesToInitializeSet and self._serviceTypesToInitializeSet[serviceType]) then
|
|
379
|
-
|
|
398
|
+
self:_destructService(serviceType, service)
|
|
399
|
+
end
|
|
380
400
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
current = coroutine.running()
|
|
401
|
+
serviceType, service = next(services)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
385
404
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
end
|
|
389
|
-
end)
|
|
405
|
+
function ServiceBag._destructService(self: ServiceBag, serviceType, service)
|
|
406
|
+
local serviceName = self:_getServiceName(serviceType)
|
|
390
407
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
408
|
+
local current
|
|
409
|
+
task.spawn(function()
|
|
410
|
+
debug.setmemorycategory(serviceName)
|
|
411
|
+
current = coroutine.running()
|
|
412
|
+
|
|
413
|
+
if service.Destroy then
|
|
414
|
+
service:Destroy()
|
|
395
415
|
end
|
|
416
|
+
end)
|
|
396
417
|
|
|
397
|
-
|
|
418
|
+
local isDead = coroutine.status(current) == "dead"
|
|
419
|
+
if not isDead then
|
|
420
|
+
warn(debug.traceback(current, string.format("Destroying service %q yielded", serviceName)))
|
|
398
421
|
end
|
|
399
422
|
end
|
|
400
423
|
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Pins ServiceBag lifecycle behavior: registration, recursive dependency initialization, start,
|
|
4
|
+
the lifecycle guards, per-bag isolation, and teardown.
|
|
5
|
+
|
|
6
|
+
The "destruction order" describe is the repro for dependents outliving their dependencies:
|
|
7
|
+
destruction must run in reverse initialization order, so a service destroyed mid-teardown can
|
|
8
|
+
still write to the dependencies it resolved in Init (the way PlayerTelemetry queues its leave
|
|
9
|
+
point into InfluxDBService). Destruction that walks the service map in hash order fails these.
|
|
10
|
+
|
|
11
|
+
@class ServiceBag.spec.lua
|
|
12
|
+
]]
|
|
13
|
+
local require = require(script.Parent.loader).load(script)
|
|
14
|
+
|
|
15
|
+
local Jest = require("Jest")
|
|
16
|
+
local ServiceBag = require("ServiceBag")
|
|
17
|
+
|
|
18
|
+
local describe = Jest.Globals.describe
|
|
19
|
+
local expect = Jest.Globals.expect
|
|
20
|
+
local it = Jest.Globals.it
|
|
21
|
+
|
|
22
|
+
-- Builds a named service definition that records its lifecycle into the shared `events` list as
|
|
23
|
+
-- "<name>.<init|start|destroy>". `initDependencies` are resolved via GetService during Init,
|
|
24
|
+
-- mirroring how real services acquire their dependencies.
|
|
25
|
+
local function makeService(name: string, events: { string }, initDependencies: { any }?)
|
|
26
|
+
local serviceDefinition = {}
|
|
27
|
+
serviceDefinition.ServiceName = name
|
|
28
|
+
serviceDefinition._serviceBag = nil :: any
|
|
29
|
+
|
|
30
|
+
function serviceDefinition:Init(serviceBag)
|
|
31
|
+
self._serviceBag = serviceBag
|
|
32
|
+
|
|
33
|
+
if initDependencies then
|
|
34
|
+
for _, dependency in initDependencies do
|
|
35
|
+
serviceBag:GetService(dependency)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
table.insert(events, name .. ".init")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
function serviceDefinition:Start()
|
|
43
|
+
table.insert(events, name .. ".start")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
function serviceDefinition:Destroy()
|
|
47
|
+
table.insert(events, name .. ".destroy")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return serviceDefinition
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
local function filterSuffix(events: { string }, suffix: string): { string }
|
|
54
|
+
local found = {}
|
|
55
|
+
for _, event in events do
|
|
56
|
+
if string.sub(event, -#suffix) == suffix then
|
|
57
|
+
table.insert(found, (string.gsub(event, "%" .. suffix .. "$", "")))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
return found
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe("ServiceBag lifecycle", function()
|
|
64
|
+
it("initializes and starts a registered service exactly once", function()
|
|
65
|
+
local events = {}
|
|
66
|
+
local serviceBag = ServiceBag.new()
|
|
67
|
+
local definition = makeService("Solo", events)
|
|
68
|
+
|
|
69
|
+
serviceBag:GetService(definition)
|
|
70
|
+
serviceBag:Init()
|
|
71
|
+
serviceBag:Start()
|
|
72
|
+
|
|
73
|
+
expect(events).toEqual({ "Solo.init", "Solo.start" })
|
|
74
|
+
|
|
75
|
+
serviceBag:Destroy()
|
|
76
|
+
end)
|
|
77
|
+
|
|
78
|
+
it("passes the bag itself to Init", function()
|
|
79
|
+
local events = {}
|
|
80
|
+
local serviceBag = ServiceBag.new()
|
|
81
|
+
local definition = makeService("Solo", events)
|
|
82
|
+
|
|
83
|
+
local service = serviceBag:GetService(definition)
|
|
84
|
+
serviceBag:Init()
|
|
85
|
+
serviceBag:Start()
|
|
86
|
+
|
|
87
|
+
expect(service._serviceBag).toBe(serviceBag)
|
|
88
|
+
|
|
89
|
+
serviceBag:Destroy()
|
|
90
|
+
end)
|
|
91
|
+
|
|
92
|
+
it("returns the same isolated instance per bag", function()
|
|
93
|
+
local events = {}
|
|
94
|
+
local definition = makeService("Shared", events)
|
|
95
|
+
|
|
96
|
+
local bagA = ServiceBag.new()
|
|
97
|
+
local bagB = ServiceBag.new()
|
|
98
|
+
local serviceA = bagA:GetService(definition)
|
|
99
|
+
local serviceB = bagB:GetService(definition)
|
|
100
|
+
|
|
101
|
+
expect(bagA:GetService(definition)).toBe(serviceA)
|
|
102
|
+
expect(serviceA).never.toBe(serviceB)
|
|
103
|
+
|
|
104
|
+
bagA:Destroy()
|
|
105
|
+
bagB:Destroy()
|
|
106
|
+
end)
|
|
107
|
+
|
|
108
|
+
it("completes a dependency's Init before the dependent's Init completes", function()
|
|
109
|
+
local events = {}
|
|
110
|
+
local sink = makeService("Sink", events)
|
|
111
|
+
local dependent = makeService("Dependent", events, { sink })
|
|
112
|
+
|
|
113
|
+
local serviceBag = ServiceBag.new()
|
|
114
|
+
serviceBag:GetService(dependent)
|
|
115
|
+
serviceBag:Init()
|
|
116
|
+
serviceBag:Start()
|
|
117
|
+
|
|
118
|
+
expect(filterSuffix(events, ".init")).toEqual({ "Sink", "Dependent" })
|
|
119
|
+
|
|
120
|
+
serviceBag:Destroy()
|
|
121
|
+
end)
|
|
122
|
+
|
|
123
|
+
it("initializes a transitive dependency chain leaf-first", function()
|
|
124
|
+
local events = {}
|
|
125
|
+
local leaf = makeService("Leaf", events)
|
|
126
|
+
local middle = makeService("Middle", events, { leaf })
|
|
127
|
+
local root = makeService("Root", events, { middle })
|
|
128
|
+
|
|
129
|
+
local serviceBag = ServiceBag.new()
|
|
130
|
+
serviceBag:GetService(root)
|
|
131
|
+
serviceBag:Init()
|
|
132
|
+
serviceBag:Start()
|
|
133
|
+
|
|
134
|
+
expect(filterSuffix(events, ".init")).toEqual({ "Leaf", "Middle", "Root" })
|
|
135
|
+
|
|
136
|
+
serviceBag:Destroy()
|
|
137
|
+
end)
|
|
138
|
+
end)
|
|
139
|
+
|
|
140
|
+
describe("ServiceBag guards", function()
|
|
141
|
+
it("reports HasService without registering", function()
|
|
142
|
+
local events = {}
|
|
143
|
+
local definition = makeService("Queried", events)
|
|
144
|
+
local serviceBag = ServiceBag.new()
|
|
145
|
+
|
|
146
|
+
expect(serviceBag:HasService(definition)).toBe(false)
|
|
147
|
+
|
|
148
|
+
serviceBag:GetService(definition)
|
|
149
|
+
expect(serviceBag:HasService(definition)).toBe(true)
|
|
150
|
+
|
|
151
|
+
serviceBag:Destroy()
|
|
152
|
+
end)
|
|
153
|
+
|
|
154
|
+
it("errors when adding a new service after start", function()
|
|
155
|
+
local events = {}
|
|
156
|
+
local serviceBag = ServiceBag.new()
|
|
157
|
+
serviceBag:Init()
|
|
158
|
+
serviceBag:Start()
|
|
159
|
+
|
|
160
|
+
expect(function()
|
|
161
|
+
serviceBag:GetService(makeService("Late", events))
|
|
162
|
+
end).toThrow("Already started")
|
|
163
|
+
|
|
164
|
+
serviceBag:Destroy()
|
|
165
|
+
end)
|
|
166
|
+
|
|
167
|
+
it("errors when querying a service after destroy", function()
|
|
168
|
+
local events = {}
|
|
169
|
+
local serviceBag = ServiceBag.new()
|
|
170
|
+
serviceBag:Init()
|
|
171
|
+
serviceBag:Start()
|
|
172
|
+
serviceBag:Destroy()
|
|
173
|
+
|
|
174
|
+
expect(function()
|
|
175
|
+
serviceBag:GetService(makeService("PostMortem", events))
|
|
176
|
+
end).toThrow()
|
|
177
|
+
end)
|
|
178
|
+
|
|
179
|
+
it("errors when starting before initializing", function()
|
|
180
|
+
local serviceBag = ServiceBag.new()
|
|
181
|
+
|
|
182
|
+
expect(function()
|
|
183
|
+
serviceBag:Start()
|
|
184
|
+
end).toThrow()
|
|
185
|
+
|
|
186
|
+
serviceBag:Destroy()
|
|
187
|
+
end)
|
|
188
|
+
end)
|
|
189
|
+
|
|
190
|
+
describe("ServiceBag destruction order", function()
|
|
191
|
+
it("destroys every service on bag destroy", function()
|
|
192
|
+
local events = {}
|
|
193
|
+
local serviceBag = ServiceBag.new()
|
|
194
|
+
serviceBag:GetService(makeService("First", events))
|
|
195
|
+
serviceBag:GetService(makeService("Second", events))
|
|
196
|
+
serviceBag:Init()
|
|
197
|
+
serviceBag:Start()
|
|
198
|
+
|
|
199
|
+
serviceBag:Destroy()
|
|
200
|
+
|
|
201
|
+
local destroyed = filterSuffix(events, ".destroy")
|
|
202
|
+
table.sort(destroyed)
|
|
203
|
+
expect(destroyed).toEqual({ "First", "Second" })
|
|
204
|
+
end)
|
|
205
|
+
|
|
206
|
+
it("destroys a dependency chain in reverse initialization order", function()
|
|
207
|
+
-- Repeat with fresh bags: hash-ordered destruction can pass any single arrangement by luck.
|
|
208
|
+
for _ = 1, 5 do
|
|
209
|
+
local events = {}
|
|
210
|
+
local leaf = makeService("Leaf", events)
|
|
211
|
+
local middle = makeService("Middle", events, { leaf })
|
|
212
|
+
local root = makeService("Root", events, { middle })
|
|
213
|
+
|
|
214
|
+
local serviceBag = ServiceBag.new()
|
|
215
|
+
serviceBag:GetService(root)
|
|
216
|
+
serviceBag:Init()
|
|
217
|
+
serviceBag:Start()
|
|
218
|
+
|
|
219
|
+
serviceBag:Destroy()
|
|
220
|
+
|
|
221
|
+
expect(filterSuffix(events, ".destroy")).toEqual({ "Root", "Middle", "Leaf" })
|
|
222
|
+
end
|
|
223
|
+
end)
|
|
224
|
+
|
|
225
|
+
it("destroys services in reverse of their recorded initialization order", function()
|
|
226
|
+
for _ = 1, 3 do
|
|
227
|
+
local events = {}
|
|
228
|
+
local serviceBag = ServiceBag.new()
|
|
229
|
+
|
|
230
|
+
local sink = makeService("Sink", events)
|
|
231
|
+
for index = 1, 6 do
|
|
232
|
+
serviceBag:GetService(makeService("Dependent" .. index, events, { sink }))
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
serviceBag:Init()
|
|
236
|
+
serviceBag:Start()
|
|
237
|
+
serviceBag:Destroy()
|
|
238
|
+
|
|
239
|
+
local initOrder = filterSuffix(events, ".init")
|
|
240
|
+
local destroyOrder = filterSuffix(events, ".destroy")
|
|
241
|
+
|
|
242
|
+
local expectedDestroyOrder = {}
|
|
243
|
+
for index = #initOrder, 1, -1 do
|
|
244
|
+
table.insert(expectedDestroyOrder, initOrder[index])
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
expect(destroyOrder).toEqual(expectedDestroyOrder)
|
|
248
|
+
end
|
|
249
|
+
end)
|
|
250
|
+
|
|
251
|
+
it("lets a dependent write into its dependency during teardown", function()
|
|
252
|
+
-- The PlayerTelemetry/InfluxDBService shape: on Destroy, a dependent flushes into the sink
|
|
253
|
+
-- it resolved during Init. The sink must still be alive to accept the write.
|
|
254
|
+
for _ = 1, 5 do
|
|
255
|
+
local writesAccepted = 0
|
|
256
|
+
local writesDropped = 0
|
|
257
|
+
|
|
258
|
+
local sink = {}
|
|
259
|
+
sink.ServiceName = "WriteSink"
|
|
260
|
+
function sink:Init(_serviceBag)
|
|
261
|
+
self._destroyed = false
|
|
262
|
+
end
|
|
263
|
+
function sink:Write()
|
|
264
|
+
if self._destroyed then
|
|
265
|
+
writesDropped += 1
|
|
266
|
+
else
|
|
267
|
+
writesAccepted += 1
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
function sink:Destroy()
|
|
271
|
+
self._destroyed = true
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
local serviceBag = ServiceBag.new()
|
|
275
|
+
|
|
276
|
+
for index = 1, 6 do
|
|
277
|
+
local dependent = {}
|
|
278
|
+
dependent.ServiceName = "Writer" .. index
|
|
279
|
+
function dependent:Init(bag)
|
|
280
|
+
self._sink = bag:GetService(sink)
|
|
281
|
+
end
|
|
282
|
+
function dependent:Destroy()
|
|
283
|
+
self._sink:Write()
|
|
284
|
+
end
|
|
285
|
+
serviceBag:GetService(dependent)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
serviceBag:Init()
|
|
289
|
+
serviceBag:Start()
|
|
290
|
+
serviceBag:Destroy()
|
|
291
|
+
|
|
292
|
+
expect(writesDropped).toBe(0)
|
|
293
|
+
expect(writesAccepted).toBe(6)
|
|
294
|
+
end
|
|
295
|
+
end)
|
|
296
|
+
end)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ServiceBagTest",
|
|
3
|
+
"globIgnorePaths": [
|
|
4
|
+
"**/.package-lock.json",
|
|
5
|
+
"**/.pnpm",
|
|
6
|
+
"**/.pnpm-workspace-state-v1.json",
|
|
7
|
+
"**/.modules.yaml",
|
|
8
|
+
"**/.ignored",
|
|
9
|
+
"**/.ignored_*"
|
|
10
|
+
],
|
|
11
|
+
"tree": {
|
|
12
|
+
"$className": "DataModel",
|
|
13
|
+
"ServerScriptService": {
|
|
14
|
+
"$properties": {
|
|
15
|
+
"LoadStringEnabled": true
|
|
16
|
+
},
|
|
17
|
+
"servicebag": {
|
|
18
|
+
"$path": ".."
|
|
19
|
+
},
|
|
20
|
+
"Script": {
|
|
21
|
+
"$path": "scripts/Server"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
@class ServerMain
|
|
3
|
+
]]
|
|
4
|
+
local ServerScriptService = game:GetService("ServerScriptService")
|
|
5
|
+
|
|
6
|
+
local root = ServerScriptService.servicebag
|
|
7
|
+
local loader = root:FindFirstChild("LoaderUtils", true).Parent
|
|
8
|
+
local require = require(loader).bootstrapGame(root)
|
|
9
|
+
|
|
10
|
+
local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
|
|
11
|
+
if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then
|
|
12
|
+
return
|
|
13
|
+
end
|