@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 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
@@ -0,0 +1,10 @@
1
+ {
2
+ "targets": {
3
+ "test": {
4
+ "universeId": 9716264427,
5
+ "placeId": 107599629424361,
6
+ "project": "test/default.project.json",
7
+ "scriptTemplate": "test/scripts/Server/ServerMain.server.lua"
8
+ }
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/servicebag",
3
- "version": "11.19.0",
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.14.0",
32
+ "@quenty/baseobject": "10.15.0",
33
33
  "@quenty/loader": "10.11.0",
34
- "@quenty/signal": "7.13.1"
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": "647acfad93dca41c38f59cda247d2bfc0fff36d4"
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
- local serviceName = self:_getServiceName(serviceType)
398
+ self:_destructService(serviceType, service)
399
+ end
380
400
 
381
- local current
382
- task.spawn(function()
383
- debug.setmemorycategory(serviceName)
384
- current = coroutine.running()
401
+ serviceType, service = next(services)
402
+ end
403
+ end
385
404
 
386
- if service.Destroy then
387
- service:Destroy()
388
- end
389
- end)
405
+ function ServiceBag._destructService(self: ServiceBag, serviceType, service)
406
+ local serviceName = self:_getServiceName(serviceType)
390
407
 
391
- local isDead = coroutine.status(current) == "dead"
392
- if not isDead then
393
- warn(debug.traceback(current, string.format("Destroying service %q yielded", serviceName)))
394
- end
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
- serviceType, service = next(services)
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,3 @@
1
+ return {
2
+ testMatch = { "**/*.spec" },
3
+ }
@@ -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