@typetorch/framework 0.5.0 → 0.5.2

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/LICENSE CHANGED
@@ -1,22 +1,22 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 TypeTorch contributors
4
- Copyright (c) 2021 Flamework (src/reflection: the Reflect and Modding runtime, adapted from @flamework/core)
5
-
6
- Permission is hereby granted, free of charge, to any person obtaining a copy
7
- of this software and associated documentation files (the "Software"), to deal
8
- in the Software without restriction, including without limitation the rights
9
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- copies of the Software, and to permit persons to whom the Software is
11
- furnished to do so, subject to the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be included in all
14
- copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TypeTorch contributors
4
+ Copyright (c) 2021 Flamework (src/reflection: the Reflect and Modding runtime, adapted from @flamework/core)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -427,7 +427,7 @@ const variant = analytics.experiment("onboarding", ["short", "long"]); // same a
427
427
  row carries a UserId.
428
428
  - **Identities** (option `identity`, default on): once the pid is known, one row `{ pid, uid, t }` (the UserId, nothing
429
429
  else) per session goes to the dev's own server only, never into the events: duckdb games in the batch body, Basin
430
- games to the fleet API (the settings' `fleet` url + `/v1/identity`, or the `identity` / `identityToken` settings). The
430
+ games to the backend (the record's `backend` url + `/v1/identity`, or the `identity` / `identityToken` settings). The
431
431
  analytics server keeps pid -> UserId in a deletable table (support lookups, Right to Erasure).
432
432
  - **Player ids:** DataStore `TypeTorchAnalytics`, key `p/<UserId>` -> `{ pid, first, last }`: one read per join, a
433
433
  write on the first join and at leave. Deleting the key (Right to Erasure) leaves that player's rows anonymous.
@@ -465,8 +465,9 @@ count affected players by pid, never by name) and counts its own DataStore and H
465
465
  menu **Server > Budget**: bars per Roblox limit for the player count, who spends it, the DataStore budget left, memory).
466
466
 
467
467
  **Settings** (server only, never sent to clients): the signed settings record's `analytics` field (kernel 0.3.8,
468
- plans/20), written with `typetorch settings set analytics -` (JSON on stdin; `@typetorch/analytics`' `writeSettings()`
469
- and `bun run local` call it). Every new copy applies live within seconds (the CLI pings servers). Before kernel 0.3.8
468
+ plans/20), derived from `backend` by `typetorch backend setup` (`writeBackendSettings()` in `@typetorch/backend` runs it, and
469
+ `bun run local` too). Every new copy applies live within seconds (the CLI pings servers). A Basin sink has no CLI writer
470
+ since CLI 0.9: `backend setup` replaces it. Before kernel 0.3.8
470
471
  there are no settings unless the game passes `new AnalyticsEngine({ settings })`:
471
472
 
472
473
  ```json
@@ -142,7 +142,7 @@ repo's `bun run local` does it). Every new signed copy applies at once, so dials
142
142
  "flushSeconds": 15,
143
143
  "recordShare": 1,
144
144
  "techEvery": 60,
145
- "experiments": { "onboarding": { "active": true, "weights": [1, 1], "variant": "short" } }
145
+ "experiments": { "onboarding": { "active": true, "weights": [1, 1], "variant": "short", "players": { "123456": "long" } } }
146
146
  }
147
147
  ```
148
148
 
@@ -155,7 +155,7 @@ repo's `bun run local` does it). Every new signed copy applies at once, so dials
155
155
  | `flushSeconds` | 15 | 5..300 |
156
156
  | `recordShare` | 1 | 0..1, share of new players recorded (stable per pid) |
157
157
  | `techEvery` | 60 | 15..3600 seconds between tech samples |
158
- | `experiments` | none | Per experiment: `active: false` (everyone gets the first variant, not stamped), `weights` (per variant, in the game's order), `variant` (force one) |
158
+ | `experiments` | none | Per experiment: `active: false` (everyone gets the first variant, not stamped), `weights` (per variant, in the game's order), `variant` (force one), `players` (`{ [UserId or pid]: variant }`, at most 200: forces one player's variant over everything else, stamped `forced`; each client's hello gets only its own entry). The CLI writes them: `typetorch experiment` |
159
159
  | `identity` | the settings' `fleet` url + `/v1/identity` | basin: where identity rows go (below); none and no `fleet` in the settings: not sent. duckdb: unused |
160
160
  | `identityToken` | the settings' `fleet` token | basin: the token for `identity`. Never logged |
161
161
 
@@ -19,10 +19,16 @@ export interface Assignment {
19
19
  /**
20
20
  * The variant of experiment `name` for player `pid`. The first variant is the control: everyone gets it while the
21
21
  * experiment is switched off (`active: false`). `weights` split players unevenly (`[9, 1]`: 10% get the second);
22
- * `variant` forces one for everyone. The point in [0, 1) is unitHash(`<name>:<pid>`), so changing weights moves as few
22
+ * `variant` forces one for everyone; `player` (one player's entry of `players`, see `playerOverride`) forces one for
23
+ * this player and wins over the rest. The point in [0, 1) is unitHash(`<name>:<pid>`), so changing weights moves as few
23
24
  * players as possible.
24
25
  */
25
26
  export declare function assignVariant(pid: string, name: string, variants: string[], override?: ExperimentOverride): Assignment;
27
+ /**
28
+ * One player's view of an override: `players` resolved to `player` (by UserId, else pid) and dropped, so a client's
29
+ * hello never carries other players' ids.
30
+ */
31
+ export declare function playerOverride(override: ExperimentOverride | undefined, userId: number | undefined, pid: string): ExperimentOverride | undefined;
26
32
  /** Whether player `pid` is in the recorded share (0..1) of new players. Stable per player. */
27
33
  export declare function inShare(pid: string, share: number): boolean;
28
34
  /** A valid experiment name: 1-64 chars, letters, digits, `_`, `-`, `.`, `/`. */
@@ -44,7 +44,8 @@ end
44
44
  *
45
45
  * The variant of experiment `name` for player `pid`. The first variant is the control: everyone gets it while the
46
46
  * experiment is switched off (`active: false`). `weights` split players unevenly (`[9, 1]`: 10% get the second);
47
- * `variant` forces one for everyone. The point in [0, 1) is unitHash(`<name>:<pid>`), so changing weights moves as few
47
+ * `variant` forces one for everyone; `player` (one player's entry of `players`, see `playerOverride`) forces one for
48
+ * this player and wins over the rest. The point in [0, 1) is unitHash(`<name>:<pid>`), so changing weights moves as few
48
49
  * players as possible.
49
50
 
50
51
  ]]
@@ -61,6 +62,18 @@ local function assignVariant(pid, name, variants, override)
61
62
  forced = false,
62
63
  }
63
64
  end
65
+ local _mine = override
66
+ if _mine ~= nil then
67
+ _mine = _mine.player
68
+ end
69
+ local mine = _mine
70
+ if type(mine) == "string" and table.find(variants, mine) ~= nil then
71
+ return {
72
+ variant = mine,
73
+ active = true,
74
+ forced = true,
75
+ }
76
+ end
64
77
  local _result = override
65
78
  if _result ~= nil then
66
79
  _result = _result.active
@@ -129,6 +142,34 @@ local function assignVariant(pid, name, variants, override)
129
142
  forced = false,
130
143
  }
131
144
  end
145
+ --[[
146
+ *
147
+ * One player's view of an override: `players` resolved to `player` (by UserId, else pid) and dropped, so a client's
148
+ * hello never carries other players' ids.
149
+
150
+ ]]
151
+ local function playerOverride(override, userId, pid)
152
+ if override == nil then
153
+ return nil
154
+ end
155
+ local result = {
156
+ active = override.active,
157
+ weights = override.weights,
158
+ variant = override.variant,
159
+ }
160
+ local players = override.players
161
+ if players ~= nil then
162
+ local _condition = (if userId ~= nil then players[tostring(userId)] else nil)
163
+ if _condition == nil then
164
+ _condition = players[pid]
165
+ end
166
+ local mine = _condition
167
+ if mine ~= nil then
168
+ result.player = mine
169
+ end
170
+ end
171
+ return result
172
+ end
132
173
  --* Whether player `pid` is in the recorded share (0..1) of new players. Stable per player.
133
174
  local function inShare(pid, share)
134
175
  if share >= 1 then
@@ -182,6 +223,7 @@ return {
182
223
  hash32 = hash32,
183
224
  unitHash = unitHash,
184
225
  assignVariant = assignVariant,
226
+ playerOverride = playerOverride,
185
227
  inShare = inShare,
186
228
  isExperimentName = isExperimentName,
187
229
  isVariantList = isVariantList,
@@ -86,6 +86,13 @@ export interface ExperimentOverride {
86
86
  weights?: number[];
87
87
  /** Everyone gets this variant (must be one of the game's variants). */
88
88
  variant?: string;
89
+ /**
90
+ * Per-player forced variants, keyed by UserId (`"123456"`) or pid (32 hex): wins over `active`, `variant` and
91
+ * `weights` for that player (QA, support). Server-only: a client gets only its own entry, as `player`.
92
+ */
93
+ players?: Record<string, string>;
94
+ /** This player's forced variant (resolved from `players` by `playerOverride`; what a client's hello carries). */
95
+ player?: string;
89
96
  }
90
97
  /** The sink settings: the signed settings' `analytics` (kernel 0.3.8, server-only), or `new AnalyticsEngine({ settings })`. */
91
98
  export interface AnalyticsSettings {
@@ -101,6 +101,7 @@ export declare class ServerAnalytics {
101
101
  /** Activity: per player, or server-wide (every player without their own) when `player` is undefined. */
102
102
  setActivity(player: Player | undefined, activity: string | undefined): void;
103
103
  setScreen(session: PlayerSession, screen: string, src: "server" | "client", t?: number, props?: string): void;
104
+ /** The overrides as this player sees them (their own `players` entry only; hello). */
104
105
  private overrides;
105
106
  /** The player's variant (stamped on their later events while active). May yield until the player's pid loads. */
106
107
  experiment(player: Player, name: string, variants: string[]): string;
@@ -28,6 +28,7 @@ local assignVariant = _experiments.assignVariant
28
28
  local inShare = _experiments.inShare
29
29
  local isExperimentName = _experiments.isExperimentName
30
30
  local isVariantList = _experiments.isVariantList
31
+ local playerOverride = _experiments.playerOverride
31
32
  local _protocol = TS.import(script, script.Parent, "protocol")
32
33
  local CHANNEL = _protocol.CHANNEL
33
34
  local CHUNK_MAX_BYTES = _protocol.CHUNK_MAX_BYTES
@@ -586,11 +587,11 @@ do
586
587
  end
587
588
  _self:emit(_exp, "state", "screen", _condition, src, t)
588
589
  end
589
- function ServerAnalytics:overrides()
590
+ function ServerAnalytics:overrides(player, session)
590
591
  local result = {}
591
592
  if self.settings then
592
593
  for name, override in self.settings.experiments do
593
- result[name] = override
594
+ result[name] = playerOverride(override, player.UserId, session.pid)
594
595
  end
595
596
  end
596
597
  return result
@@ -602,7 +603,7 @@ do
602
603
  end
603
604
  local control = _condition
604
605
  if not isExperimentName(name) or not isVariantList(variants) then
605
- warn("[src/analytics/server.ts:522]", `[analytics] experiment("{string.sub(tostring(name), 1, 64)}"): bad name or variants (1-16 distinct names)`)
606
+ warn("[src/analytics/server.ts:523]", `[analytics] experiment("{string.sub(tostring(name), 1, 64)}"): bad name or variants (1-16 distinct names)`)
606
607
  return control
607
608
  end
608
609
  local session = self:sessionOf(player)
@@ -641,7 +642,7 @@ do
641
642
  local _name_1 = name
642
643
  _result = _experiments_1[_name_1]
643
644
  end
644
- local assignment = assignVariant(_exp_1, _exp_2, _exp_3, _result)
645
+ local assignment = assignVariant(_exp_1, _exp_2, _exp_3, playerOverride(_result, player.UserId, session.pid))
645
646
  local _exp_4 = session.exp
646
647
  local _name_1 = name
647
648
  local current = _exp_4[_name_1]
@@ -667,7 +668,7 @@ do
667
668
  function ServerAnalytics:applySettings(parsed)
668
669
  local errorsText = table.concat(parsed.errors, "; ")
669
670
  if errorsText ~= table.concat(self.settingsErrors, "; ") and errorsText ~= "" then
670
- warn("[src/analytics/server.ts:557]", `[analytics] the sink settings (settings.backend or settings.analytics): {errorsText}`)
671
+ warn("[src/analytics/server.ts:558]", `[analytics] the sink settings (settings.backend or settings.analytics): {errorsText}`)
671
672
  end
672
673
  self.settingsErrors = parsed.errors
673
674
  local hadSettings = self.settings ~= nil
@@ -697,7 +698,7 @@ do
697
698
  -- `new AnalyticsEngine({ settings })`.
698
699
  if not settingsSupported() then
699
700
  if self.kernel ~= nil and self.kernel.test ~= true then
700
- warn("[src/analytics/server.ts:584]", `[analytics] no sink settings: they come from the signed settings record (kernel 0.3.8+, "typetorch backend setup"); this server runs kernel {self.kernel.kernelVersion}. Or pass new AnalyticsEngine(\{ settings \})`)
701
+ warn("[src/analytics/server.ts:585]", `[analytics] no sink settings: they come from the signed settings record (kernel 0.3.8+, "typetorch backend setup"); this server runs kernel {self.kernel.kernelVersion}. Or pass new AnalyticsEngine(\{ settings \})`)
701
702
  end
702
703
  self.settingsTried = true
703
704
  return nil
@@ -1273,7 +1274,7 @@ do
1273
1274
  _condition = DEFAULT_TECH_EVERY
1274
1275
  end
1275
1276
  _object[_left] = _condition
1276
- _object.exps = self:overrides()
1277
+ _object.exps = self:overrides(player, session)
1277
1278
  local hello = _object
1278
1279
  dispatcher.kernel:send(player, CHANNEL, "hi", hello)
1279
1280
  end
@@ -1499,7 +1500,7 @@ do
1499
1500
  end
1500
1501
  end)
1501
1502
  if not ok then
1502
- warn("[src/analytics/server.ts:1077]", `[analytics] client message failed: {string.sub(tostring(err), 1, 200)}`)
1503
+ warn("[src/analytics/server.ts:1078]", `[analytics] client message failed: {string.sub(tostring(err), 1, 200)}`)
1503
1504
  end
1504
1505
  end)
1505
1506
  self.trove:add(function()
@@ -1782,7 +1783,7 @@ do
1782
1783
  self.lastWarnAt = now
1783
1784
  local more = if self.suppressed > 0 then ` (+{self.suppressed} more in the last minute)` else ""
1784
1785
  self.suppressed = 0
1785
- warn("[src/analytics/server.ts:1272]", `[analytics] {message}{more}`)
1786
+ warn("[src/analytics/server.ts:1273]", `[analytics] {message}{more}`)
1786
1787
  return true
1787
1788
  end
1788
1789
  function ServerAnalytics:noteSuccess()
@@ -13,6 +13,8 @@ export interface ParsedSettings {
13
13
  settings?: ResolvedSettings;
14
14
  errors: string[];
15
15
  }
16
+ /** Per-player overrides one experiment may hold (`players`). */
17
+ export declare const EXPERIMENT_PLAYERS_MAX = 200;
16
18
  /**
17
19
  * Reads the settings value: a table (the configs API stores JSON objects) or a JSON string. `undefined` (no key) gives
18
20
  * no settings and no errors. Unknown fields are ignored.
@@ -35,6 +35,19 @@ local function clampNumber(value, fallback, low, high, label, errors)
35
35
  end
36
36
  return math.clamp(value, low, high)
37
37
  end
38
+ --* Per-player overrides one experiment may hold (`players`).
39
+ local EXPERIMENT_PLAYERS_MAX = 200
40
+ --* A `players` key: a UserId (digits) or a pid (32 lowercase hex).
41
+ local function isPlayerKey(key)
42
+ local _key = key
43
+ if not (type(_key) == "string") then
44
+ return false
45
+ end
46
+ if #key >= 1 and #key <= 19 and (string.match(key, "^%d+$")) ~= nil then
47
+ return true
48
+ end
49
+ return #key == 32 and (string.match(key, "^[0-9a-f]+$")) ~= nil
50
+ end
38
51
  local function parseExperiments(value, errors)
39
52
  local result = {}
40
53
  if value == nil then
@@ -103,6 +116,29 @@ local function parseExperiments(value, errors)
103
116
  table.insert(_errors, _arg0)
104
117
  end
105
118
  end
119
+ if entry.players ~= nil then
120
+ local players = {}
121
+ local _players = entry.players
122
+ local ok = type(_players) == "table"
123
+ local count = 0
124
+ if ok then
125
+ for key, variant in pairs(entry.players) do
126
+ count += 1
127
+ if not isPlayerKey(key) or not (type(variant) == "string") or #variant < 1 or #variant > 32 then
128
+ ok = false
129
+ else
130
+ players[key] = variant
131
+ end
132
+ end
133
+ end
134
+ if ok and count <= EXPERIMENT_PLAYERS_MAX then
135
+ override.players = players
136
+ else
137
+ local _errors = errors
138
+ local _arg0 = `experiments.{name}.players must map UserIds or pids to variant names (at most {EXPERIMENT_PLAYERS_MAX})`
139
+ table.insert(_errors, _arg0)
140
+ end
141
+ end
106
142
  result[name] = override
107
143
  end
108
144
  return result
@@ -326,4 +362,5 @@ return {
326
362
  recordsSupported = recordsSupported,
327
363
  DEFAULT_FLUSH_SECONDS = DEFAULT_FLUSH_SECONDS,
328
364
  DEFAULT_TECH_EVERY = DEFAULT_TECH_EVERY,
365
+ EXPERIMENT_PLAYERS_MAX = EXPERIMENT_PLAYERS_MAX,
329
366
  }
@@ -5,9 +5,4 @@ export interface DevtoolsClient {
5
5
  /** Call once the client generation is up (all modules started). */
6
6
  readonly started: () => void;
7
7
  }
8
- /**
9
- * Starts the dev menu for this client generation. Everything it creates or connects lives in `trove`, so a swap
10
- * removes it; the open state, tab, sub-tab, window rectangle and options survive the swap through the kernel persist
11
- * store.
12
- */
13
8
  export declare function startDevtoolsClient(kernel: ClientKernel, dispatcher: ClientDispatcher, trove: Trove): DevtoolsClient;
@@ -467,6 +467,52 @@ local function shareableLogs(entries)
467
467
  return ordered
468
468
  end
469
469
  -- The menu ----------------------------------------------------------------------------------------------------------
470
+ --[[
471
+ *
472
+ * The channel in a Builds group heading: the branch's own channel, not its newest artifact's. A promoted prod artifact
473
+ * keeps channel "prod" on a dev branch, and the heading used to say "(prod)" until the next dev deploy flipped it back.
474
+ * This server's branch: `branchChannel` from its status (kernel 0.3.9); another branch: the branch list's
475
+ * `branchChannel` (kernel 0.5.2), else its `channel`. "dev, prod artifact" when the newest artifact differs.
476
+
477
+ ]]
478
+ local function buildsHeadingChannel(branchName, artifactChannel, branches, status)
479
+ local channel
480
+ if status ~= nil and status.branch == branchName and status.branchChannel ~= nil then
481
+ channel = status.branchChannel
482
+ end
483
+ if channel == nil then
484
+ -- ▼ ReadonlyArray.find ▼
485
+ local _callback = function(branch)
486
+ return branch.name == branchName
487
+ end
488
+ local _result
489
+ for _i, _v in branches do
490
+ if _callback(_v, _i - 1, branches) == true then
491
+ _result = _v
492
+ break
493
+ end
494
+ end
495
+ -- ▲ ReadonlyArray.find ▲
496
+ local info = _result
497
+ local _result_1 = info
498
+ if _result_1 ~= nil then
499
+ _result_1 = _result_1.branchChannel
500
+ end
501
+ local _condition = _result_1
502
+ if _condition == nil then
503
+ local _result_2 = info
504
+ if _result_2 ~= nil then
505
+ _result_2 = _result_2.channel
506
+ end
507
+ _condition = _result_2
508
+ end
509
+ channel = _condition
510
+ end
511
+ if channel == nil or channel == artifactChannel then
512
+ return artifactChannel
513
+ end
514
+ return `{channel}, {artifactChannel} artifact`
515
+ end
470
516
  --[[
471
517
  *
472
518
  * Kernel 0.3.9: "dev" for a dev branch, plus the rules when they are stricter ("dev (prod rules)": a dev branch on a
@@ -488,7 +534,36 @@ end
488
534
  * store.
489
535
 
490
536
  ]]
537
+ local DEV_GUI_NAME = "TypeTorchDev"
538
+ --* The client generation that built a dev menu gui (an attribute on the ScreenGui).
539
+ local DEV_GUI_GENERATION = "TypeTorchGeneration"
540
+ --[[
541
+ *
542
+ * Destroys dev menus an older generation left behind. Its trove should have removed them, but a generation whose stop
543
+ * was cut short (a throwing cleanup, before framework 0.5.2) left its menu on screen, dead, over the new one.
544
+ * Menus without the attribute come from a framework before 0.5.2: always older, since this generation hasn't built one.
545
+
546
+ ]]
547
+ local function sweepStaleMenus(playerGui, generation)
548
+ for _, child in playerGui:GetChildren() do
549
+ if child.Name ~= DEV_GUI_NAME or not child:IsA("ScreenGui") then
550
+ continue
551
+ end
552
+ local built = child:GetAttribute(DEV_GUI_GENERATION)
553
+ if type(built) == "number" and built >= generation then
554
+ continue
555
+ end
556
+ pcall(function()
557
+ return child:Destroy()
558
+ end)
559
+ end
560
+ end
491
561
  local function startDevtoolsClient(kernel, dispatcher, trove)
562
+ -- Before anything else, so a leaked menu goes away even for players who never get the dev menu.
563
+ local startGui = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui")
564
+ if startGui then
565
+ sweepStaleMenus(startGui, kernel.generation)
566
+ end
492
567
  local state = kernel:persist(PERSIST_KEY, function()
493
568
  return {
494
569
  open = false,
@@ -664,7 +739,7 @@ local function startDevtoolsClient(kernel, dispatcher, trove)
664
739
  -- picks the file up on its PC).
665
740
  local _devGui = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui")
666
741
  if _devGui ~= nil then
667
- _devGui = _devGui:FindFirstChild("TypeTorchDev")
742
+ _devGui = _devGui:FindFirstChild(DEV_GUI_NAME)
668
743
  end
669
744
  local devGui = _devGui
670
745
  local taken = takeScreenshot(if devGui ~= nil and devGui:IsA("ScreenGui") then { devGui } else {})
@@ -2080,7 +2155,7 @@ local function startDevtoolsClient(kernel, dispatcher, trove)
2080
2155
  end
2081
2156
  for _, branchName in order do
2082
2157
  local group = groups[branchName]
2083
- local heading = body:text(`{branchName} ({group[1].channel})`, COLORS.text)
2158
+ local heading = body:text(`{branchName} ({buildsHeadingChannel(branchName, group[1].channel, data.branches, status)})`, COLORS.text)
2084
2159
  heading.Font = Enum.Font.BuilderSansBold
2085
2160
  local showAll = expanded[branchName] ~= nil
2086
2161
  -- ▼ ReadonlyArray.forEach ▼
@@ -2472,7 +2547,7 @@ local function startDevtoolsClient(kernel, dispatcher, trove)
2472
2547
  })
2473
2548
  end)
2474
2549
  if not ok then
2475
- warn("[src/devtools/client.ts:1725]", `[devtools] {key} tab failed: {err}`)
2550
+ warn("[src/devtools/client.ts:1768]", `[devtools] {key} tab failed: {err}`)
2476
2551
  page:text(`This tab failed: {err}`, COLORS.bad)
2477
2552
  end
2478
2553
  end
@@ -2543,13 +2618,15 @@ local function startDevtoolsClient(kernel, dispatcher, trove)
2543
2618
  local open
2544
2619
  local build = function()
2545
2620
  local playerGui = Players.LocalPlayer:WaitForChild("PlayerGui")
2621
+ sweepStaleMenus(playerGui, kernel.generation)
2546
2622
  local gui = trove:add(make("ScreenGui", {
2547
- Name = "TypeTorchDev",
2623
+ Name = DEV_GUI_NAME,
2548
2624
  ResetOnSpawn = false,
2549
2625
  DisplayOrder = DEV_MENU_DISPLAY_ORDER,
2550
2626
  IgnoreGuiInset = true,
2551
2627
  ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
2552
2628
  }))
2629
+ gui:SetAttribute(DEV_GUI_GENERATION, kernel.generation)
2553
2630
  local toggle = style(make("TextButton", {
2554
2631
  Name = "Toggle",
2555
2632
  AutoButtonColor = true,
@@ -3077,7 +3154,7 @@ local function startDevtoolsClient(kernel, dispatcher, trove)
3077
3154
  return playReloadSound()
3078
3155
  end)
3079
3156
  if not ok then
3080
- warn("[src/devtools/client.ts:2144]", `[devtools] reload sound failed: {err}`)
3157
+ warn("[src/devtools/client.ts:2189]", `[devtools] reload sound failed: {err}`)
3081
3158
  end
3082
3159
  end))
3083
3160
  end,
@@ -2,7 +2,7 @@ import type { AnalyticsStats } from "../analytics/schema";
2
2
  import type { AssetFacts } from "../assets/manifest";
3
3
  import type { DetachedStatus, ErrorReportsStatus, FleetSenderInfo, HealthInfo, KernelStatus, PerfSummary, SettingsStatus } from "../kernel";
4
4
  /** The newest kernel this framework release knows about. Bump it with every kernel release. */
5
- export declare const LATEST_KERNEL = "0.4.2";
5
+ export declare const LATEST_KERNEL = "0.5.1";
6
6
  /**
7
7
  * The live health window while it is open (kernel 0.3.7 thresholds; older kernels: 3): "1/3 errors, 24 s left", plus
8
8
  * ", no rollback" when a failure would only mark the server degraded. Undefined once the window closed.
@@ -2,7 +2,7 @@
2
2
  local TS = _G[script]
3
3
  local describeFleetError = TS.import(script, script.Parent.Parent, "analytics", "hints").describeFleetError
4
4
  --* The newest kernel this framework release knows about. Bump it with every kernel release.
5
- local LATEST_KERNEL = "0.4.2"
5
+ local LATEST_KERNEL = "0.5.1"
6
6
  --* Kernels before 0.3.7 use fixed thresholds: 3 errors within 30 s of ready roll back.
7
7
  local OLD_HEALTH_ERRORS = 3
8
8
  local OLD_HEALTH_WINDOW = 30
package/out/kernel.d.ts CHANGED
@@ -353,7 +353,10 @@ export interface ArtifactEntry {
353
353
  }
354
354
  export interface BranchInfo {
355
355
  name: string;
356
+ /** The head artifact's channel when the settings don't configure the branch (a promoted prod build: "prod"). */
356
357
  channel: Channel;
358
+ /** Kernel 0.5.2: what the branch is ("prod": the default branch or one configured prod; else "dev"). */
359
+ branchChannel?: Channel;
357
360
  artifactId?: string;
358
361
  /** Kernel 0.2+. */
359
362
  assetId?: number;
@@ -0,0 +1,11 @@
1
+ import type { Trove } from "@rbxts/trove";
2
+ /**
3
+ * `trove.clean()`, but every object is cleaned in its own pcall: a cleanup that throws warns (`<label> cleanup threw`)
4
+ * and the rest still run. `trove.clean()` stops at the first error and leaves the trove stuck "cleaning", so one
5
+ * `trove.remove(x)` inside a cleanup ("Cannot call trove.remove() while cleaning") used to abort the whole generation
6
+ * stop: later modules' troves, the network and the dev menu stayed alive. Child troves (`trove.extend()`) are swept the
7
+ * same way. Falls back to a pcalled `trove.clean()` for a trove it doesn't recognize.
8
+ */
9
+ export declare function cleanTrove(trove: Trove, label: string): void;
10
+ /** Runs one step of a generation stop; a step that throws warns and the stop goes on. */
11
+ export declare function stopStep(label: string, step: () => void): void;
@@ -0,0 +1,57 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ --* The parts of @rbxts/trove 1.x that `trove.clean()` uses; read so one throwing cleanup can't skip the rest.
3
+ --[[
4
+ *
5
+ * `trove.clean()`, but every object is cleaned in its own pcall: a cleanup that throws warns (`<label> cleanup threw`)
6
+ * and the rest still run. `trove.clean()` stops at the first error and leaves the trove stuck "cleaning", so one
7
+ * `trove.remove(x)` inside a cleanup ("Cannot call trove.remove() while cleaning") used to abort the whole generation
8
+ * stop: later modules' troves, the network and the dev menu stayed alive. Child troves (`trove.extend()`) are swept the
9
+ * same way. Falls back to a pcalled `trove.clean()` for a trove it doesn't recognize.
10
+
11
+ ]]
12
+ local function cleanTrove(trove, label)
13
+ local internals = trove
14
+ local objects = internals.objects
15
+ local cleanupObj = internals.cleanupObj
16
+ if not (type(objects) == "table") or not (type(cleanupObj) == "function") then
17
+ local ok, err = pcall(function()
18
+ return trove:clean()
19
+ end)
20
+ if not ok then
21
+ warn("[src/runtime/safe-clean.ts:28]", `{label} cleanup threw: {err}`)
22
+ end
23
+ return nil
24
+ end
25
+ -- Already being cleaned further up the stack (a cleanup that cleans its own trove): leave it to that clean.
26
+ if internals.cleaning == true then
27
+ return nil
28
+ end
29
+ internals.cleaning = true
30
+ local troveClass = getmetatable(trove)
31
+ for _, track in objects do
32
+ local obj = track.obj
33
+ local ok, err = pcall(function()
34
+ if type(obj) == "table" and getmetatable(obj) == troveClass then
35
+ cleanTrove(obj, label)
36
+ else
37
+ cleanupObj(internals, track)
38
+ end
39
+ end)
40
+ if not ok then
41
+ warn("[src/runtime/safe-clean.ts:41]", `{label} cleanup threw: {err}`)
42
+ end
43
+ end
44
+ table.clear(objects)
45
+ internals.cleaning = false
46
+ end
47
+ --* Runs one step of a generation stop; a step that throws warns and the stop goes on.
48
+ local function stopStep(label, step)
49
+ local ok, err = pcall(step)
50
+ if not ok then
51
+ warn("[src/runtime/safe-clean.ts:50]", `TypeTorch stop: {label} threw: {err}`)
52
+ end
53
+ end
54
+ return {
55
+ cleanTrove = cleanTrove,
56
+ stopStep = stopStep,
57
+ }
@@ -36,6 +36,9 @@ local _registry = TS.import(script, script.Parent, "registry")
36
36
  local persistKeys = _registry.persistKeys
37
37
  local registered = _registry.registered
38
38
  local runningModules = _registry.runningModules
39
+ local _safe_clean = TS.import(script, script.Parent, "safe-clean")
40
+ local cleanTrove = _safe_clean.cleanTrove
41
+ local stopStep = _safe_clean.stopStep
39
42
  local function has(instance, method)
40
43
  local _arg0 = instance[method]
41
44
  return type(_arg0) == "function"
@@ -305,14 +308,29 @@ local function start(realm, kernel, options)
305
308
  return (running.instance):onStop()
306
309
  end)
307
310
  if not ok then
308
- warn("[src/runtime/start.ts:205]", `{running.name}.onStop threw: {err}`)
311
+ warn("[src/runtime/start.ts:206]", `{running.name}.onStop threw: {err}`)
309
312
  end
310
313
  end
311
- root:remove(running.trove)
314
+ -- Object by object, each pcalled: a cleanup that throws (e.g. trove.remove inside a cleanup) can't skip the
315
+ -- rest of this module, the modules after it, or the rest of the stop.
316
+ cleanTrove(running.trove, running.name)
317
+ stopStep(`{running.name} trove`, function()
318
+ return root:remove(running.trove)
319
+ end)
312
320
  end
313
321
  table.clear(runningModules)
314
322
  setModulePhase("stopped")
315
323
  end
324
+ --* Every step runs even when an earlier one throws (each warns), so a bad cleanup can't keep a dead generation alive.
325
+ local stopGeneration = function()
326
+ stopStep("stopping modules", stopModules)
327
+ stopStep("stopping the network", stopNetwork)
328
+ stopStep("unbinding TypeTorch", unbindTypeTorch)
329
+ cleanTrove(root, "TypeTorch generation")
330
+ stopStep("destroying the generation trove", function()
331
+ return root:destroy()
332
+ end)
333
+ end
316
334
  setModulePhase("constructing")
317
335
  local initialized, initError = pcall(function()
318
336
  for _, mod in ordered do
@@ -377,10 +395,7 @@ local function start(realm, kernel, options)
377
395
  end
378
396
  end)
379
397
  if not initialized then
380
- stopModules()
381
- stopNetwork()
382
- unbindTypeTorch()
383
- root:destroy()
398
+ stopGeneration()
384
399
  error(`TypeTorch {realm} failed to start: {initError}`, 0)
385
400
  end
386
401
  for _, running in runningModules do
@@ -446,14 +461,13 @@ local function start(realm, kernel, options)
446
461
  _result()
447
462
  end
448
463
  startedTypeTorch()
449
- print("[src/runtime/start.ts:296]", `TypeTorch {realm} started {#runningModules} modules in {math.floor((os.clock() - startedAt) * 1000)} ms (artifact {kernel.artifact.id}, generation {kernel.generation})`)
464
+ print("[src/runtime/start.ts:305]", `TypeTorch {realm} started {#runningModules} modules in {math.floor((os.clock() - startedAt) * 1000)} ms (artifact {kernel.artifact.id}, generation {kernel.generation})`)
450
465
  return function(info)
451
466
  -- TypeTorch.onSwapOut first, while every module still runs (so they can save into persist).
452
- swapOutTypeTorch(info)
453
- stopModules()
454
- stopNetwork()
455
- unbindTypeTorch()
456
- root:destroy()
467
+ stopStep("onSwapOut", function()
468
+ return swapOutTypeTorch(info)
469
+ end)
470
+ stopGeneration()
457
471
  end
458
472
  end
459
473
  --* Call from the game's `src/server/boot.ts`: `export function boot(kernel) { return startServer(kernel, {...}) }`.
package/out/settings.luau CHANGED
@@ -168,13 +168,14 @@ do
168
168
  if _result == "client" then
169
169
  error(`TypeTorch.liveConfig("{self.key}") is server-only`, 2)
170
170
  end
171
+ -- `typetorch test --cloud` runs a stub kernel with no signed settings at all: the default is expected there.
171
172
  local _result_1 = current
172
173
  if _result_1 ~= nil then
173
174
  _result_1 = _result_1.kernel
174
175
  end
175
176
  local _condition = _result_1 ~= nil
176
177
  if _condition then
177
- _condition = not settingsSupported()
178
+ _condition = current.kernel.test ~= true and not settingsSupported()
178
179
  end
179
180
  if _condition then
180
181
  warnOnce(`old:{self.key}`, `TypeTorch.liveConfig("{self.key}") needs kernel {SETTINGS_KERNEL} (signed settings; this server runs {current.kernelVersion}): using the default`)
package/out/version.d.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * This framework's version, as package.json says (scripts/test-state-inspect.luau checks they match). Shown in
3
3
  * uploaded log headers (Logs > Upload); bump it with package.json.
4
4
  */
5
- export declare const FRAMEWORK_VERSION = "0.5.0";
5
+ export declare const FRAMEWORK_VERSION = "0.5.2";
package/out/version.luau CHANGED
@@ -5,7 +5,7 @@
5
5
  * uploaded log headers (Logs > Upload); bump it with package.json.
6
6
 
7
7
  ]]
8
- local FRAMEWORK_VERSION = "0.5.0"
8
+ local FRAMEWORK_VERSION = "0.5.2"
9
9
  return {
10
10
  FRAMEWORK_VERSION = FRAMEWORK_VERSION,
11
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typetorch/framework",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "TypeTorch framework for roblox-ts: hot-swappable modules (dependency injection, lifecycle, troves), guarded networking, hot assets and the in-game dev menu. Ships inside every TypeTorch artifact.",
5
5
  "license": "MIT",
6
6
  "author": "TypeTorch",