@bedrock-rbx/core 0.1.0-beta.1
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 +21 -0
- package/dist/cli/run.d.mts +1 -0
- package/dist/cli/run.mjs +1476 -0
- package/dist/cli/run.mjs.map +1 -0
- package/dist/config.d.mts +3 -0
- package/dist/config.mjs +2 -0
- package/dist/define-config-CroC96-C.mjs +42 -0
- package/dist/define-config-CroC96-C.mjs.map +1 -0
- package/dist/define-config-D-LAhfSJ.d.mts +1731 -0
- package/dist/define-config-D-LAhfSJ.d.mts.map +1 -0
- package/dist/index.d.mts +3449 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +67 -0
- package/dist/index.mjs.map +1 -0
- package/dist/migrate-mantle-state-DqbJ1TLq.mjs +5789 -0
- package/dist/migrate-mantle-state-DqbJ1TLq.mjs.map +1 -0
- package/package.json +80 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,3449 @@
|
|
|
1
|
+
import { C as ConfigError, S as validateConfig, _ as StateConfig, a as ConfigEnvironmentUniverseId, b as UniverseOverlayWithoutId, c as DisplayNamePrefixConfig, d as GistStateConfig, f as PlaceEntry, g as ResourceEntryByKind, h as ResolvedUniverseEntry, i as Config, l as EnvironmentEntry, m as ResolvedPlaceEntry, n as ConfigInput, o as ConfigRootUniverseId, p as ResolvedConfig, r as defineConfig, s as DeveloperProductEntry, t as ConfigContext, u as GamePassEntry, v as UniverseEntry, w as ConfigValidationIssue, x as isGistStateConfig, y as UniverseOverlayWithId } from "./define-config-D-LAhfSJ.mjs";
|
|
2
|
+
import { Type as Type$1 } from "arktype";
|
|
3
|
+
import { OpenCloudError, OpenCloudError as OpenCloudError$1, Result, Result as Result$1 } from "@bedrock-rbx/ocale";
|
|
4
|
+
import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
|
|
5
|
+
import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
|
|
6
|
+
import { PlacesClient } from "@bedrock-rbx/ocale/places";
|
|
7
|
+
import { SocialLink, SocialLink as SocialLink$1, UniversesClient } from "@bedrock-rbx/ocale/universes";
|
|
8
|
+
|
|
9
|
+
//#region ../../node_modules/.pnpm/tagged-tag@1.0.0/node_modules/tagged-tag/index.d.ts
|
|
10
|
+
declare const tag: unique symbol;
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region ../../node_modules/.pnpm/type-fest@5.6.0/node_modules/type-fest/source/tagged.d.ts
|
|
13
|
+
// eslint-disable-next-line type-fest/require-exported-types
|
|
14
|
+
type TagContainer<Token> = {
|
|
15
|
+
readonly [tag]: Token;
|
|
16
|
+
};
|
|
17
|
+
type Tag<Token extends PropertyKey, TagMetadata> = TagContainer<{ [K in Token]: TagMetadata }>;
|
|
18
|
+
/**
|
|
19
|
+
Create a [tagged type](https://medium.com/@KevinBGreene/surviving-the-typescript-ecosystem-branding-and-type-tagging-6cf6e516523d) that can support [multiple tags](https://github.com/sindresorhus/type-fest/issues/665) and [per-tag metadata](https://medium.com/@ethanresnick/advanced-typescript-tagged-types-improved-with-type-level-metadata-5072fc125fcf).
|
|
20
|
+
|
|
21
|
+
A type returned by `Tagged` can be passed to `Tagged` again, to create a type with multiple tags.
|
|
22
|
+
|
|
23
|
+
A tag's name is usually a string (and must be a string, number, or symbol), but each application of a tag can also contain an arbitrary type as its "metadata". See {@link GetTagMetadata} for examples and explanation.
|
|
24
|
+
|
|
25
|
+
A type `A` returned by `Tagged` is assignable to another type `B` returned by `Tagged` if and only if:
|
|
26
|
+
- the underlying (untagged) type of `A` is assignable to the underlying type of `B`;
|
|
27
|
+
- `A` contains at least all the tags `B` has;
|
|
28
|
+
- and the metadata type for each of `A`'s tags is assignable to the metadata type of `B`'s corresponding tag.
|
|
29
|
+
|
|
30
|
+
There have been several discussions about adding similar features to TypeScript. Unfortunately, nothing has (yet) moved forward:
|
|
31
|
+
- [Microsoft/TypeScript#202](https://github.com/microsoft/TypeScript/issues/202)
|
|
32
|
+
- [Microsoft/TypeScript#4895](https://github.com/microsoft/TypeScript/issues/4895)
|
|
33
|
+
- [Microsoft/TypeScript#33290](https://github.com/microsoft/TypeScript/pull/33290)
|
|
34
|
+
|
|
35
|
+
@example
|
|
36
|
+
```
|
|
37
|
+
import type {Tagged} from 'type-fest';
|
|
38
|
+
|
|
39
|
+
type AccountNumber = Tagged<number, 'AccountNumber'>;
|
|
40
|
+
type AccountBalance = Tagged<number, 'AccountBalance'>;
|
|
41
|
+
|
|
42
|
+
function createAccountNumber(): AccountNumber {
|
|
43
|
+
// As you can see, casting from a `number` (the underlying type being tagged) is allowed.
|
|
44
|
+
return 2 as AccountNumber;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
declare function getMoneyForAccount(accountNumber: AccountNumber): AccountBalance;
|
|
48
|
+
|
|
49
|
+
// This will compile successfully.
|
|
50
|
+
getMoneyForAccount(createAccountNumber());
|
|
51
|
+
|
|
52
|
+
// But this won't, because it has to be explicitly passed as an `AccountNumber` type!
|
|
53
|
+
// Critically, you could not accidentally use an `AccountBalance` as an `AccountNumber`.
|
|
54
|
+
// @ts-expect-error
|
|
55
|
+
getMoneyForAccount(2);
|
|
56
|
+
|
|
57
|
+
// You can also use tagged values like their underlying, untagged type.
|
|
58
|
+
// I.e., this will compile successfully because an `AccountNumber` can be used as a regular `number`.
|
|
59
|
+
// In this sense, the underlying base type is not hidden, which differentiates tagged types from opaque types in other languages.
|
|
60
|
+
const accountNumber = createAccountNumber() + 2;
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
@example
|
|
64
|
+
```
|
|
65
|
+
import type {Tagged} from 'type-fest';
|
|
66
|
+
|
|
67
|
+
// You can apply multiple tags to a type by using `Tagged` repeatedly.
|
|
68
|
+
type Url = Tagged<string, 'URL'>;
|
|
69
|
+
type SpecialCacheKey = Tagged<Url, 'SpecialCacheKey'>;
|
|
70
|
+
|
|
71
|
+
// You can also pass a union of tag names, so this is equivalent to the above, although it doesn't give you the ability to assign distinct metadata to each tag.
|
|
72
|
+
type SpecialCacheKey2 = Tagged<string, 'URL' | 'SpecialCacheKey'>;
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
@category Type
|
|
76
|
+
*/
|
|
77
|
+
type Tagged<Type, TagName extends PropertyKey, TagMetadata = never> = Type & Tag<TagName, TagMetadata>;
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/types/ids.d.ts
|
|
80
|
+
/**
|
|
81
|
+
* User-supplied identifier for a resource within a config (e.g. `"vip-pass"`).
|
|
82
|
+
* Stable across deploys; used to correlate desired ↔ current state.
|
|
83
|
+
*/
|
|
84
|
+
type ResourceKey = Tagged<string, "ResourceKey">;
|
|
85
|
+
/**
|
|
86
|
+
* Roblox-assigned numeric asset ID, represented as a string to avoid int64
|
|
87
|
+
* precision loss in JavaScript.
|
|
88
|
+
*/
|
|
89
|
+
type RobloxAssetId = Tagged<string, "RobloxAssetId">;
|
|
90
|
+
/**
|
|
91
|
+
* Lowercase hex-encoded SHA-256 digest (exactly 64 characters drawn from
|
|
92
|
+
* `0-9a-f`). Used to detect changes to file-backed resource inputs such as
|
|
93
|
+
* game-pass icons without re-uploading the file.
|
|
94
|
+
*/
|
|
95
|
+
type Sha256Hex = Tagged<string, "Sha256Hex">;
|
|
96
|
+
/**
|
|
97
|
+
* Type predicate: test whether a raw string is a valid {@link ResourceKey}.
|
|
98
|
+
*
|
|
99
|
+
* Prefer this when the caller owns error handling (for example, constructing a
|
|
100
|
+
* `Result` error in a shell-layer parser). Use {@link asResourceKey} when an
|
|
101
|
+
* exception is the right failure mode.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
*
|
|
105
|
+
* ```ts
|
|
106
|
+
* import { isResourceKey } from "@bedrock-rbx/core";
|
|
107
|
+
*
|
|
108
|
+
* const valid = isResourceKey("vip-pass");
|
|
109
|
+
* const invalid = isResourceKey("vip pass");
|
|
110
|
+
* expect(valid).toBe(true);
|
|
111
|
+
* expect(invalid).toBe(false);
|
|
112
|
+
* ```
|
|
113
|
+
*
|
|
114
|
+
* @param raw - String to test.
|
|
115
|
+
* @returns `true` when `raw` matches the ResourceKey shape; narrows `raw`.
|
|
116
|
+
*/
|
|
117
|
+
declare function isResourceKey(raw: string): raw is ResourceKey;
|
|
118
|
+
/**
|
|
119
|
+
* Validate and brand a raw string as a {@link ResourceKey}.
|
|
120
|
+
*
|
|
121
|
+
* Accepts non-empty strings of alphanumeric characters, hyphens, and
|
|
122
|
+
* underscores (matching `/^[A-Za-z0-9_-]+$/`).
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
*
|
|
126
|
+
* ```ts
|
|
127
|
+
* import { asResourceKey } from "@bedrock-rbx/core";
|
|
128
|
+
*
|
|
129
|
+
* const key = asResourceKey("vip-pass");
|
|
130
|
+
*
|
|
131
|
+
* let thrown: unknown;
|
|
132
|
+
* try {
|
|
133
|
+
* asResourceKey("vip pass");
|
|
134
|
+
* } catch (error) {
|
|
135
|
+
* thrown = error;
|
|
136
|
+
* }
|
|
137
|
+
*
|
|
138
|
+
* expect(key).toBe("vip-pass");
|
|
139
|
+
* expect(thrown).toBeInstanceOf(RangeError);
|
|
140
|
+
* ```
|
|
141
|
+
*
|
|
142
|
+
* @param raw - Raw string to validate and brand.
|
|
143
|
+
* @returns The input re-typed as a {@link ResourceKey}.
|
|
144
|
+
* @throws RangeError when `raw` is empty or contains disallowed characters.
|
|
145
|
+
*/
|
|
146
|
+
declare function asResourceKey(raw: string): ResourceKey;
|
|
147
|
+
/**
|
|
148
|
+
* Type predicate: test whether a raw string is a valid {@link RobloxAssetId}.
|
|
149
|
+
*
|
|
150
|
+
* Prefer this when the caller owns error handling (for example, constructing a
|
|
151
|
+
* `Result` error in a shell-layer parser). Use {@link asRobloxAssetId} when an
|
|
152
|
+
* exception is the right failure mode.
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
*
|
|
156
|
+
* ```ts
|
|
157
|
+
* import { isRobloxAssetId } from "@bedrock-rbx/core";
|
|
158
|
+
*
|
|
159
|
+
* const valid = isRobloxAssetId("12345");
|
|
160
|
+
* const invalid = isRobloxAssetId("12345abc");
|
|
161
|
+
* expect(valid).toBe(true);
|
|
162
|
+
* expect(invalid).toBe(false);
|
|
163
|
+
* ```
|
|
164
|
+
*
|
|
165
|
+
* @param raw - String to test.
|
|
166
|
+
* @returns `true` when `raw` matches the RobloxAssetId shape; narrows `raw`.
|
|
167
|
+
*/
|
|
168
|
+
declare function isRobloxAssetId(raw: string): raw is RobloxAssetId;
|
|
169
|
+
/**
|
|
170
|
+
* Validate and brand a raw string as a {@link RobloxAssetId}.
|
|
171
|
+
*
|
|
172
|
+
* Accepts non-empty digit-only strings (matching `/^\d+$/`). Roblox asset IDs
|
|
173
|
+
* are int64 values carried as strings because JavaScript's `number` cannot
|
|
174
|
+
* represent the full int64 range.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
*
|
|
178
|
+
* ```ts
|
|
179
|
+
* import { asRobloxAssetId } from "@bedrock-rbx/core";
|
|
180
|
+
*
|
|
181
|
+
* const id = asRobloxAssetId("12345");
|
|
182
|
+
*
|
|
183
|
+
* let thrown: unknown;
|
|
184
|
+
* try {
|
|
185
|
+
* asRobloxAssetId("12345abc");
|
|
186
|
+
* } catch (error) {
|
|
187
|
+
* thrown = error;
|
|
188
|
+
* }
|
|
189
|
+
*
|
|
190
|
+
* expect(id).toBe("12345");
|
|
191
|
+
* expect(thrown).toBeInstanceOf(RangeError);
|
|
192
|
+
* ```
|
|
193
|
+
*
|
|
194
|
+
* @param raw - Raw string to validate and brand.
|
|
195
|
+
* @returns The input re-typed as a {@link RobloxAssetId}.
|
|
196
|
+
* @throws RangeError when `raw` is empty or contains non-digit characters.
|
|
197
|
+
*/
|
|
198
|
+
declare function asRobloxAssetId(raw: string): RobloxAssetId;
|
|
199
|
+
/**
|
|
200
|
+
* Type predicate: test whether a raw string is a valid {@link Sha256Hex}.
|
|
201
|
+
*
|
|
202
|
+
* Accepts exactly 64 lowercase hexadecimal characters (matching
|
|
203
|
+
* `/^[0-9a-f]{64}$/`). Prefer this when the caller owns error handling;
|
|
204
|
+
* use {@link asSha256Hex} when throwing is the right failure mode.
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
*
|
|
208
|
+
* ```ts
|
|
209
|
+
* import { isSha256Hex } from "@bedrock-rbx/core";
|
|
210
|
+
*
|
|
211
|
+
* const digest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
212
|
+
* const valid = isSha256Hex(digest);
|
|
213
|
+
* const invalid = isSha256Hex(digest.toUpperCase());
|
|
214
|
+
* expect(valid).toBe(true);
|
|
215
|
+
* expect(invalid).toBe(false);
|
|
216
|
+
* ```
|
|
217
|
+
*
|
|
218
|
+
* @param raw - String to test.
|
|
219
|
+
* @returns `true` when `raw` matches the Sha256Hex shape; narrows `raw`.
|
|
220
|
+
*/
|
|
221
|
+
declare function isSha256Hex(raw: string): raw is Sha256Hex;
|
|
222
|
+
/**
|
|
223
|
+
* Validate and brand a raw string as a {@link Sha256Hex}.
|
|
224
|
+
*
|
|
225
|
+
* Accepts exactly 64 lowercase hexadecimal characters. Uppercase hex, lengths
|
|
226
|
+
* other than 64, and any non-hex character are rejected so the brand is a
|
|
227
|
+
* canonical representation.
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
*
|
|
231
|
+
* ```ts
|
|
232
|
+
* import { asSha256Hex } from "@bedrock-rbx/core";
|
|
233
|
+
*
|
|
234
|
+
* const digest = asSha256Hex(
|
|
235
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
236
|
+
* );
|
|
237
|
+
*
|
|
238
|
+
* let thrown: unknown;
|
|
239
|
+
* try {
|
|
240
|
+
* asSha256Hex("deadbeef");
|
|
241
|
+
* } catch (error) {
|
|
242
|
+
* thrown = error;
|
|
243
|
+
* }
|
|
244
|
+
*
|
|
245
|
+
* expect(digest).toHaveLength(64);
|
|
246
|
+
* expect(thrown).toBeInstanceOf(RangeError);
|
|
247
|
+
* ```
|
|
248
|
+
*
|
|
249
|
+
* @param raw - Raw string to validate and brand.
|
|
250
|
+
* @returns The input re-typed as a {@link Sha256Hex}.
|
|
251
|
+
* @throws RangeError when `raw` is not exactly 64 lowercase hex characters.
|
|
252
|
+
*/
|
|
253
|
+
declare function asSha256Hex(raw: string): Sha256Hex;
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region src/core/resources.d.ts
|
|
256
|
+
/**
|
|
257
|
+
* Desired state for a game pass, as declared in user config.
|
|
258
|
+
*
|
|
259
|
+
* Each field is `readonly` because desired state is treated as an immutable
|
|
260
|
+
* snapshot once normalized by `buildDesired`; downstream consumers (`diff`,
|
|
261
|
+
* drivers) must not mutate it.
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
*
|
|
265
|
+
* ```ts
|
|
266
|
+
* import { asResourceKey, asSha256Hex, type GamePassDesiredState } from "@bedrock-rbx/core";
|
|
267
|
+
*
|
|
268
|
+
* const pass: GamePassDesiredState = {
|
|
269
|
+
* description: "Grants VIP perks.",
|
|
270
|
+
* icon: { "en-us": "assets/vip-icon.png" },
|
|
271
|
+
* iconFileHashes: {
|
|
272
|
+
* "en-us": asSha256Hex(
|
|
273
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
274
|
+
* ),
|
|
275
|
+
* },
|
|
276
|
+
* key: asResourceKey("vip-pass"),
|
|
277
|
+
* kind: "gamePass",
|
|
278
|
+
* name: "VIP Pass",
|
|
279
|
+
* price: undefined,
|
|
280
|
+
* };
|
|
281
|
+
*
|
|
282
|
+
* expect(pass.kind).toBe("gamePass");
|
|
283
|
+
* expect(pass.price).toBeUndefined();
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
interface GamePassDesiredState {
|
|
287
|
+
/** User-supplied key; stable across deploys; used to correlate desired with current. */
|
|
288
|
+
readonly key: ResourceKey;
|
|
289
|
+
/** User-facing game-pass name as shown on the Roblox storefront. */
|
|
290
|
+
readonly name: string;
|
|
291
|
+
/** User-facing description shown on the game-pass detail page. */
|
|
292
|
+
readonly description: string;
|
|
293
|
+
/**
|
|
294
|
+
* Locale-keyed icon paths declared on the authored config. The Roblox
|
|
295
|
+
* game-pass API is monolingual, so only the `"en-us"` icon is ever
|
|
296
|
+
* uploaded; the map shape mirrors `UniverseDesiredState.icon` for
|
|
297
|
+
* cross-kind parity.
|
|
298
|
+
*/
|
|
299
|
+
readonly icon: Record<"en-us", string>;
|
|
300
|
+
/**
|
|
301
|
+
* SHA-256 digests of the local icon files keyed by the same locales as
|
|
302
|
+
* the icon map. The diff compares this map against the prior current
|
|
303
|
+
* state so the driver re-uploads only when a file's bytes change.
|
|
304
|
+
*/
|
|
305
|
+
readonly iconFileHashes: Record<"en-us", Sha256Hex>;
|
|
306
|
+
/** Discriminator tag for the `ResourceDesiredState` union. */
|
|
307
|
+
readonly kind: "gamePass";
|
|
308
|
+
/**
|
|
309
|
+
* Robux price. `undefined` means off-sale (mirrors Mantle's `Option<u32>`;
|
|
310
|
+
* the state parser normalizes JSON `null` to `undefined` at the wire
|
|
311
|
+
* boundary per the project type convention).
|
|
312
|
+
*/
|
|
313
|
+
readonly price: number | undefined;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Desired state for a place, the `.rbxl` or `.rbxlx` file a universe serves
|
|
317
|
+
* as one of its levels.
|
|
318
|
+
*
|
|
319
|
+
* `placeId` sits on desired state (rather than on outputs like
|
|
320
|
+
* {@link GamePassOutputs.assetId}) because Roblox Open Cloud cannot mint
|
|
321
|
+
* places; the user supplies the existing place ID per entry. `filePath` and
|
|
322
|
+
* `fileHash` describe the local file the driver publishes; `buildDesired`
|
|
323
|
+
* computes `fileHash` from the file bytes so `diff` can detect drift without
|
|
324
|
+
* re-uploading unchanged content. `displayName`, `description`, and
|
|
325
|
+
* `serverSize` are optional metadata fields routed through
|
|
326
|
+
* `PlacesClient.update`; `undefined` leaves the server value untouched.
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
*
|
|
330
|
+
* ```ts
|
|
331
|
+
* import {
|
|
332
|
+
* asResourceKey,
|
|
333
|
+
* asRobloxAssetId,
|
|
334
|
+
* asSha256Hex,
|
|
335
|
+
* type PlaceDesiredState,
|
|
336
|
+
* } from "@bedrock-rbx/core";
|
|
337
|
+
*
|
|
338
|
+
* const place: PlaceDesiredState = {
|
|
339
|
+
* description: undefined,
|
|
340
|
+
* displayName: "Start Place",
|
|
341
|
+
* fileHash: asSha256Hex(
|
|
342
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
343
|
+
* ),
|
|
344
|
+
* filePath: "places/start.rbxl",
|
|
345
|
+
* key: asResourceKey("start-place"),
|
|
346
|
+
* kind: "place",
|
|
347
|
+
* placeId: asRobloxAssetId("4711"),
|
|
348
|
+
* serverSize: 50,
|
|
349
|
+
* };
|
|
350
|
+
*
|
|
351
|
+
* expect(place.kind).toBe("place");
|
|
352
|
+
* expect(place.displayName).toBe("Start Place");
|
|
353
|
+
* expect(place.description).toBeUndefined();
|
|
354
|
+
* expect(place.serverSize).toBe(50);
|
|
355
|
+
* ```
|
|
356
|
+
*/
|
|
357
|
+
interface PlaceDesiredState {
|
|
358
|
+
/** User-supplied key; stable across deploys; used to correlate desired with current. */
|
|
359
|
+
readonly key: ResourceKey;
|
|
360
|
+
/** User-facing place description; `undefined` leaves the server value untouched. */
|
|
361
|
+
readonly description: string | undefined;
|
|
362
|
+
/** User-facing place name; `undefined` leaves the server value untouched. */
|
|
363
|
+
readonly displayName: string | undefined;
|
|
364
|
+
/** SHA-256 hex digest of the place file, computed by `buildDesired` in shell. */
|
|
365
|
+
readonly fileHash: Sha256Hex;
|
|
366
|
+
/** Path to the `.rbxl` or `.rbxlx` file on disk, relative to the config file. */
|
|
367
|
+
readonly filePath: string;
|
|
368
|
+
/** Discriminator tag for the `ResourceDesiredState` union. */
|
|
369
|
+
readonly kind: "place";
|
|
370
|
+
/** Existing Roblox place ID; Open Cloud cannot create places, so this is an input, not an output. */
|
|
371
|
+
readonly placeId: RobloxAssetId;
|
|
372
|
+
/** Maximum players per server; positive integer. `undefined` leaves the server value untouched. */
|
|
373
|
+
readonly serverSize: number | undefined;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Roblox-returned value produced by publishing a place version. The publish
|
|
377
|
+
* endpoint does not return an asset ID (the `placeId` is supplied by the
|
|
378
|
+
* caller); `versionNumber` is the only Roblox-assigned field the response
|
|
379
|
+
* carries.
|
|
380
|
+
*/
|
|
381
|
+
interface PlaceOutputs {
|
|
382
|
+
/** Auto-incrementing version number assigned by Roblox on every publish. */
|
|
383
|
+
readonly versionNumber: number;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Desired state for the singleton universe a config manages.
|
|
387
|
+
*
|
|
388
|
+
* The universe is adopted rather than provisioned: the user supplies an
|
|
389
|
+
* existing `universeId` (Open Cloud cannot mint universes) and bedrock
|
|
390
|
+
* reconciles the declared managed fields against it.
|
|
391
|
+
*
|
|
392
|
+
* Most managed fields use `T | undefined` to mean "unmanaged": the diff
|
|
393
|
+
* treats `undefined` as absent and the driver omits the field from the
|
|
394
|
+
* `updateMask`. The clearable fields (`privateServerPriceRobux` and each
|
|
395
|
+
* social link) are additionally key-presence aware: a present key with
|
|
396
|
+
* `undefined` tells the driver to clear the server value (ocale emits
|
|
397
|
+
* JSON `null`), while an absent key leaves the server value untouched.
|
|
398
|
+
*
|
|
399
|
+
* @example
|
|
400
|
+
*
|
|
401
|
+
* ```ts
|
|
402
|
+
* import {
|
|
403
|
+
* asRobloxAssetId,
|
|
404
|
+
* UNIVERSE_SINGLETON_KEY,
|
|
405
|
+
* type UniverseDesiredState,
|
|
406
|
+
* } from "@bedrock-rbx/core";
|
|
407
|
+
*
|
|
408
|
+
* const universe: UniverseDesiredState = {
|
|
409
|
+
* consoleEnabled: undefined,
|
|
410
|
+
* desktopEnabled: true,
|
|
411
|
+
* discordSocialLink: { title: "Join our Discord", uri: "https://discord.gg/example" },
|
|
412
|
+
* displayName: "Fun Universe",
|
|
413
|
+
* key: UNIVERSE_SINGLETON_KEY,
|
|
414
|
+
* kind: "universe",
|
|
415
|
+
* mobileEnabled: false,
|
|
416
|
+
* privateServerPriceRobux: undefined,
|
|
417
|
+
* tabletEnabled: undefined,
|
|
418
|
+
* twitterSocialLink: undefined,
|
|
419
|
+
* universeId: asRobloxAssetId("1234567890"),
|
|
420
|
+
* voiceChatEnabled: true,
|
|
421
|
+
* vrEnabled: undefined,
|
|
422
|
+
* };
|
|
423
|
+
*
|
|
424
|
+
* expect(universe.kind).toBe("universe");
|
|
425
|
+
* expect("privateServerPriceRobux" in universe).toBeTrue();
|
|
426
|
+
* expect(universe.discordSocialLink?.title).toBe("Join our Discord");
|
|
427
|
+
* expect(universe.twitterSocialLink).toBeUndefined();
|
|
428
|
+
* ```
|
|
429
|
+
*/
|
|
430
|
+
interface UniverseDesiredState {
|
|
431
|
+
/** Fixed singleton key (`"main"`); bedrock synthesizes it in `flattenConfig`. */
|
|
432
|
+
readonly key: ResourceKey;
|
|
433
|
+
/** Whether console players can join; `undefined` leaves the server value untouched. */
|
|
434
|
+
readonly consoleEnabled: boolean | undefined;
|
|
435
|
+
/** Whether desktop players can join; `undefined` leaves the server value untouched. */
|
|
436
|
+
readonly desktopEnabled: boolean | undefined;
|
|
437
|
+
/** Discord social link; tri-state (absent/undefined/set) — see interface JSDoc. */
|
|
438
|
+
readonly discordSocialLink?: SocialLink$1 | undefined;
|
|
439
|
+
/**
|
|
440
|
+
* Display name for the universe. `undefined` leaves the server
|
|
441
|
+
* value untouched. The driver routes declared updates through
|
|
442
|
+
* `PlacesClient.update` because the universe PATCH endpoint treats
|
|
443
|
+
* `displayName` as read-only.
|
|
444
|
+
*/
|
|
445
|
+
readonly displayName: string | undefined;
|
|
446
|
+
/** Facebook social link; tri-state (absent/undefined/set) — see interface JSDoc. */
|
|
447
|
+
readonly facebookSocialLink?: SocialLink$1 | undefined;
|
|
448
|
+
/** Guilded social link; tri-state (absent/undefined/set) — see interface JSDoc. */
|
|
449
|
+
readonly guildedSocialLink?: SocialLink$1 | undefined;
|
|
450
|
+
/**
|
|
451
|
+
* Locale-keyed experience-icon paths declared on the authored config.
|
|
452
|
+
* Absent when the user did not declare an icon block.
|
|
453
|
+
*/
|
|
454
|
+
readonly icon?: Record<"en-us", string>;
|
|
455
|
+
/**
|
|
456
|
+
* SHA-256 digests of the local icon files keyed by the same locales as
|
|
457
|
+
* the icon map. The diff compares this map against the prior current
|
|
458
|
+
* state so the driver re-uploads only when a file's bytes change.
|
|
459
|
+
*/
|
|
460
|
+
readonly iconFileHashes?: Record<"en-us", Sha256Hex>;
|
|
461
|
+
/** Discriminator tag for the `ResourceDesiredState` union. */
|
|
462
|
+
readonly kind: "universe";
|
|
463
|
+
/** Whether mobile players can join; `undefined` leaves the server value untouched. */
|
|
464
|
+
readonly mobileEnabled: boolean | undefined;
|
|
465
|
+
/**
|
|
466
|
+
* Private-server price in Robux. A present key with `undefined`
|
|
467
|
+
* clears the server value on apply; an absent key leaves the server
|
|
468
|
+
* value untouched.
|
|
469
|
+
*/
|
|
470
|
+
readonly privateServerPriceRobux?: number | undefined;
|
|
471
|
+
/** Roblox Group social link; tri-state (absent/undefined/set) — see interface JSDoc. */
|
|
472
|
+
readonly robloxGroupSocialLink?: SocialLink$1 | undefined;
|
|
473
|
+
/** Whether tablet players can join; `undefined` leaves the server value untouched. */
|
|
474
|
+
readonly tabletEnabled: boolean | undefined;
|
|
475
|
+
/** Twitch social link; tri-state (absent/undefined/set) — see interface JSDoc. */
|
|
476
|
+
readonly twitchSocialLink?: SocialLink$1 | undefined;
|
|
477
|
+
/** Twitter social link; tri-state (absent/undefined/set) — see interface JSDoc. */
|
|
478
|
+
readonly twitterSocialLink?: SocialLink$1 | undefined;
|
|
479
|
+
/** User-supplied Roblox universe ID; the universe must already exist. */
|
|
480
|
+
readonly universeId: RobloxAssetId;
|
|
481
|
+
/** Whether voice chat is enabled; `undefined` leaves the server value untouched. */
|
|
482
|
+
readonly voiceChatEnabled: boolean | undefined;
|
|
483
|
+
/** Whether VR players can join; `undefined` leaves the server value untouched. */
|
|
484
|
+
readonly vrEnabled: boolean | undefined;
|
|
485
|
+
/** YouTube social link; tri-state (absent/undefined/set) — see interface JSDoc. */
|
|
486
|
+
readonly youtubeSocialLink?: SocialLink$1 | undefined;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Tuple of every social link field name on {@link UniverseDesiredState}.
|
|
490
|
+
* Iterated by flatten, driver, and diff to handle the tri-state clearable
|
|
491
|
+
* semantics uniformly across all seven fields.
|
|
492
|
+
*/
|
|
493
|
+
declare const SOCIAL_LINK_FIELDS: readonly ["discordSocialLink", "facebookSocialLink", "guildedSocialLink", "robloxGroupSocialLink", "twitchSocialLink", "twitterSocialLink", "youtubeSocialLink"];
|
|
494
|
+
/** Union of the seven social link field names on {@link UniverseDesiredState}. */
|
|
495
|
+
type SocialLinkField = (typeof SOCIAL_LINK_FIELDS)[number];
|
|
496
|
+
/**
|
|
497
|
+
* Desired state for a developer product, the consumable a player can buy via
|
|
498
|
+
* `MarketplaceService:PromptProductPurchase`.
|
|
499
|
+
*
|
|
500
|
+
* @example
|
|
501
|
+
*
|
|
502
|
+
* ```ts
|
|
503
|
+
* import { asResourceKey, type DeveloperProductDesiredState } from "@bedrock-rbx/core";
|
|
504
|
+
*
|
|
505
|
+
* const product: DeveloperProductDesiredState = {
|
|
506
|
+
* description: "Stocks the player up with 1,000 premium gems.",
|
|
507
|
+
* isRegionalPricingEnabled: true,
|
|
508
|
+
* key: asResourceKey("gem-pack"),
|
|
509
|
+
* kind: "developerProduct",
|
|
510
|
+
* name: "Gem Pack",
|
|
511
|
+
* price: 100,
|
|
512
|
+
* storePageEnabled: true,
|
|
513
|
+
* };
|
|
514
|
+
*
|
|
515
|
+
* expect(product.kind).toBe("developerProduct");
|
|
516
|
+
* expect(product.price).toBe(100);
|
|
517
|
+
* ```
|
|
518
|
+
*/
|
|
519
|
+
interface DeveloperProductDesiredState {
|
|
520
|
+
/** User-supplied key; stable across deploys; used to correlate desired with current. */
|
|
521
|
+
readonly key: ResourceKey;
|
|
522
|
+
/** User-facing developer product name as shown on the storefront. */
|
|
523
|
+
readonly name: string;
|
|
524
|
+
/** User-facing description shown on the developer product detail page. */
|
|
525
|
+
readonly description: string;
|
|
526
|
+
/**
|
|
527
|
+
* Locale-keyed icon paths declared on the authored config. Absent when
|
|
528
|
+
* the user did not declare an icon block. The Roblox developer-product
|
|
529
|
+
* API is monolingual, so only the `"en-us"` icon is ever uploaded; the
|
|
530
|
+
* map shape mirrors `GamePassDesiredState.icon` for cross-kind parity.
|
|
531
|
+
*/
|
|
532
|
+
readonly icon?: Record<"en-us", string>;
|
|
533
|
+
/**
|
|
534
|
+
* SHA-256 digests of the local icon files keyed by the same locales as
|
|
535
|
+
* the icon map. The diff compares this map against the prior current
|
|
536
|
+
* state so the driver re-uploads only when a file's bytes change. Absent
|
|
537
|
+
* when `icon` is absent.
|
|
538
|
+
*/
|
|
539
|
+
readonly iconFileHashes?: Record<"en-us", Sha256Hex>;
|
|
540
|
+
/**
|
|
541
|
+
* Whether Roblox-managed regional pricing applies to the product.
|
|
542
|
+
* Tri-state: `undefined` means the flag is unmanaged (the diff ignores
|
|
543
|
+
* it); a defined value is propagated to Roblox on every deploy.
|
|
544
|
+
*/
|
|
545
|
+
readonly isRegionalPricingEnabled: boolean | undefined;
|
|
546
|
+
/** Discriminator tag for the `ResourceDesiredState` union. */
|
|
547
|
+
readonly kind: "developerProduct";
|
|
548
|
+
/**
|
|
549
|
+
* Robux price. `undefined` means off-sale; removing the field from config
|
|
550
|
+
* takes the product off-sale on the next deploy, re-adding puts it back
|
|
551
|
+
* on sale.
|
|
552
|
+
*/
|
|
553
|
+
readonly price: number | undefined;
|
|
554
|
+
/**
|
|
555
|
+
* Whether the product appears on the universe's external store page.
|
|
556
|
+
* Tri-state: `undefined` means the flag is unmanaged. A defined value is
|
|
557
|
+
* applied via a follow-up PATCH after the create POST because the v2
|
|
558
|
+
* create endpoint does not accept this field.
|
|
559
|
+
*/
|
|
560
|
+
readonly storePageEnabled: boolean | undefined;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Discriminated union of every desired-state shape Bedrock manages.
|
|
564
|
+
*
|
|
565
|
+
* Extend by adding new members to this union; the mapped
|
|
566
|
+
* `ResourceOutputsByKind` interface then forces a matching outputs entry for
|
|
567
|
+
* the new kind at compile time.
|
|
568
|
+
*/
|
|
569
|
+
type ResourceDesiredState = DeveloperProductDesiredState | GamePassDesiredState | PlaceDesiredState | UniverseDesiredState;
|
|
570
|
+
/**
|
|
571
|
+
* Roblox-returned identifiers produced by creating or updating a game pass.
|
|
572
|
+
*
|
|
573
|
+
* Distinct from `GamePassDesiredState.key`: the desired-state key is a
|
|
574
|
+
* user-supplied handle, while these IDs are assigned by Roblox and
|
|
575
|
+
* discovered only after the first successful API call.
|
|
576
|
+
*/
|
|
577
|
+
interface GamePassOutputs {
|
|
578
|
+
/** Primary Roblox asset ID for the game pass itself. */
|
|
579
|
+
readonly assetId: RobloxAssetId;
|
|
580
|
+
/**
|
|
581
|
+
* Locale-keyed Roblox-assigned image IDs for the game-pass icons.
|
|
582
|
+
* Mirrors `UniverseOutputs.iconAssetIds` for cross-kind shape parity;
|
|
583
|
+
* the Roblox game-pass API only ever populates the `"en-us"` entry.
|
|
584
|
+
*/
|
|
585
|
+
readonly iconAssetIds: Record<"en-us", RobloxAssetId>;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Roblox-returned identifiers produced by creating or updating a developer
|
|
589
|
+
* product. `productId` is what `MarketplaceService:PromptProductPurchase`
|
|
590
|
+
* accepts and is stable across re-deploys. `iconImageAssetId` is optional
|
|
591
|
+
* because the wire returns it as nullable when no icon is uploaded; slice 1
|
|
592
|
+
* never populates it because icon support lands in a later slice.
|
|
593
|
+
*/
|
|
594
|
+
interface DeveloperProductOutputs {
|
|
595
|
+
/** Roblox asset ID of the uploaded icon image; `undefined` when no icon is uploaded. */
|
|
596
|
+
readonly iconImageAssetId?: RobloxAssetId | undefined;
|
|
597
|
+
/** Roblox-assigned developer product ID; stable across re-deploys. */
|
|
598
|
+
readonly productId: RobloxAssetId;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Roblox-returned value produced by reconciling a universe. The root place
|
|
602
|
+
* ID is server-authoritative: bedrock cannot set it directly, but records it
|
|
603
|
+
* so a future places slice can cross-validate the declared start place.
|
|
604
|
+
*/
|
|
605
|
+
interface UniverseOutputs {
|
|
606
|
+
/**
|
|
607
|
+
* Locale-keyed Roblox-assigned image IDs for the experience icons. Only
|
|
608
|
+
* populated for locales whose icon was uploaded by the universe driver;
|
|
609
|
+
* the entry persists across re-deploys until the locale is removed from
|
|
610
|
+
* the authored config.
|
|
611
|
+
*/
|
|
612
|
+
readonly iconAssetIds?: Record<"en-us", RobloxAssetId>;
|
|
613
|
+
/** Server-assigned root place ID for the universe. */
|
|
614
|
+
readonly rootPlaceId: RobloxAssetId;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* String union of every discriminator tag in `ResourceDesiredState`.
|
|
618
|
+
*
|
|
619
|
+
* Derived from the union rather than hand-maintained so adding a new
|
|
620
|
+
* `ResourceDesiredState` member automatically widens this type.
|
|
621
|
+
*/
|
|
622
|
+
type ResourceKind = ResourceDesiredState["kind"];
|
|
623
|
+
/**
|
|
624
|
+
* Per-kind outputs registry. Each `ResourceKind` must have a matching entry
|
|
625
|
+
* or `ResourceOutputs<K>` is a compile error. Modelled as an interface (not a
|
|
626
|
+
* type alias) so downstream packages can use declaration merging to register
|
|
627
|
+
* outputs for new kinds without touching this module.
|
|
628
|
+
*
|
|
629
|
+
* @example
|
|
630
|
+
*
|
|
631
|
+
* ```ts
|
|
632
|
+
* import { asRobloxAssetId, type ResourceOutputsByKind } from "@bedrock-rbx/core";
|
|
633
|
+
*
|
|
634
|
+
* const outputs: ResourceOutputsByKind["gamePass"] = {
|
|
635
|
+
* assetId: asRobloxAssetId("9876543210"),
|
|
636
|
+
* iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
|
|
637
|
+
* };
|
|
638
|
+
*
|
|
639
|
+
* expect(outputs.assetId).toBe("9876543210");
|
|
640
|
+
* ```
|
|
641
|
+
*/
|
|
642
|
+
interface ResourceOutputsByKind {
|
|
643
|
+
/** Outputs returned by the Roblox API for a developer-product resource. */
|
|
644
|
+
developerProduct: DeveloperProductOutputs;
|
|
645
|
+
/** Outputs returned by the Roblox API for a game-pass resource. */
|
|
646
|
+
gamePass: GamePassOutputs;
|
|
647
|
+
/** Outputs returned by the Roblox API for a place publish. */
|
|
648
|
+
place: PlaceOutputs;
|
|
649
|
+
/** Outputs returned by the Roblox API for a universe reconcile. */
|
|
650
|
+
universe: UniverseOutputs;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Resolved outputs for a specific resource kind.
|
|
654
|
+
*
|
|
655
|
+
* @template K - The resource kind discriminator.
|
|
656
|
+
*/
|
|
657
|
+
type ResourceOutputs<K extends ResourceKind> = ResourceOutputsByKind[K];
|
|
658
|
+
/**
|
|
659
|
+
* Current (live) state for a resource kind.
|
|
660
|
+
*
|
|
661
|
+
* Composed from the matching desired-state shape plus a nested `outputs`
|
|
662
|
+
* object carrying Roblox-assigned identifiers. The outer `K extends
|
|
663
|
+
* ResourceKind` conditional distributes `K` across the union so the default
|
|
664
|
+
* `ResourceCurrentState` resolves to a clean per-kind union rather than a
|
|
665
|
+
* cross-product intersection of every kind's fields.
|
|
666
|
+
*
|
|
667
|
+
* The `outputs` sub-object stays nested (rather than flattening into the
|
|
668
|
+
* top level) to mirror Mantle's `{ inputs, outputs }` state layout,
|
|
669
|
+
* keeping migration copy clean.
|
|
670
|
+
*
|
|
671
|
+
* @template K - The resource kind discriminator. Defaults to the full
|
|
672
|
+
* `ResourceKind` union for the broad form used in `ReadonlyArray`
|
|
673
|
+
* collections.
|
|
674
|
+
*
|
|
675
|
+
* @example
|
|
676
|
+
*
|
|
677
|
+
* ```ts
|
|
678
|
+
* import {
|
|
679
|
+
* asResourceKey,
|
|
680
|
+
* asRobloxAssetId,
|
|
681
|
+
* asSha256Hex,
|
|
682
|
+
* type ResourceCurrentState,
|
|
683
|
+
* } from "@bedrock-rbx/core";
|
|
684
|
+
*
|
|
685
|
+
* const current: ResourceCurrentState<"gamePass"> = {
|
|
686
|
+
* description: "Grants VIP perks.",
|
|
687
|
+
* icon: { "en-us": "assets/vip-icon.png" },
|
|
688
|
+
* iconFileHashes: {
|
|
689
|
+
* "en-us": asSha256Hex(
|
|
690
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
691
|
+
* ),
|
|
692
|
+
* },
|
|
693
|
+
* key: asResourceKey("vip-pass"),
|
|
694
|
+
* kind: "gamePass",
|
|
695
|
+
* name: "VIP Pass",
|
|
696
|
+
* outputs: {
|
|
697
|
+
* assetId: asRobloxAssetId("9876543210"),
|
|
698
|
+
* iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
|
|
699
|
+
* },
|
|
700
|
+
* price: 500,
|
|
701
|
+
* };
|
|
702
|
+
*
|
|
703
|
+
* expect(current.outputs.assetId).toBe("9876543210");
|
|
704
|
+
* expect(current.kind).toBe("gamePass");
|
|
705
|
+
* ```
|
|
706
|
+
*/
|
|
707
|
+
type ResourceCurrentState<K extends ResourceKind = ResourceKind> = K extends ResourceKind ? Prettify<Extract<ResourceDesiredState, {
|
|
708
|
+
kind: K;
|
|
709
|
+
}> & {
|
|
710
|
+
readonly outputs: ResourceOutputs<K>;
|
|
711
|
+
}> : never;
|
|
712
|
+
type Prettify<T> = { readonly [K in keyof T]: T[K] };
|
|
713
|
+
/**
|
|
714
|
+
* Fixed stable key for the singleton universe resource. `flattenConfig`
|
|
715
|
+
* stamps this onto the sole `UniverseDesiredInput` it emits; fixtures and
|
|
716
|
+
* state adapters share the constant so the invariant is encoded once.
|
|
717
|
+
*
|
|
718
|
+
* @example
|
|
719
|
+
*
|
|
720
|
+
* ```ts
|
|
721
|
+
* import { UNIVERSE_SINGLETON_KEY } from "@bedrock-rbx/core";
|
|
722
|
+
*
|
|
723
|
+
* expect(UNIVERSE_SINGLETON_KEY).toBe("main");
|
|
724
|
+
* ```
|
|
725
|
+
*/
|
|
726
|
+
declare const UNIVERSE_SINGLETON_KEY: ResourceKey;
|
|
727
|
+
//#endregion
|
|
728
|
+
//#region src/ports/resource-driver.d.ts
|
|
729
|
+
/**
|
|
730
|
+
* Plugin contract for a resource adapter: the interface a third-party author
|
|
731
|
+
* implements to teach Bedrock how to reconcile one {@link ResourceKind} against
|
|
732
|
+
* its upstream API.
|
|
733
|
+
*
|
|
734
|
+
* `ResourceDriver<K>` is a *driven* (secondary) port in hexagonal terms; the
|
|
735
|
+
* name "driver" follows Terraform, Pulumi, and Mantle IaC convention for a
|
|
736
|
+
* component that talks to a specific resource API.
|
|
737
|
+
*
|
|
738
|
+
* @template K - The {@link ResourceKind} discriminator this driver handles.
|
|
739
|
+
*
|
|
740
|
+
* @example
|
|
741
|
+
*
|
|
742
|
+
* ```ts
|
|
743
|
+
* import {
|
|
744
|
+
* asResourceKey,
|
|
745
|
+
* asRobloxAssetId,
|
|
746
|
+
* asSha256Hex,
|
|
747
|
+
* type ResourceDriver,
|
|
748
|
+
* } from "@bedrock-rbx/core";
|
|
749
|
+
*
|
|
750
|
+
* const gamePassDriver: ResourceDriver<"gamePass"> = {
|
|
751
|
+
* async create(desired) {
|
|
752
|
+
* return {
|
|
753
|
+
* data: {
|
|
754
|
+
* ...desired,
|
|
755
|
+
* outputs: {
|
|
756
|
+
* assetId: asRobloxAssetId("9876543210"),
|
|
757
|
+
* iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
|
|
758
|
+
* },
|
|
759
|
+
* },
|
|
760
|
+
* success: true,
|
|
761
|
+
* };
|
|
762
|
+
* },
|
|
763
|
+
* };
|
|
764
|
+
*
|
|
765
|
+
* return gamePassDriver
|
|
766
|
+
* .create({
|
|
767
|
+
* description: "Grants VIP perks.",
|
|
768
|
+
* icon: { "en-us": "assets/vip-icon.png" },
|
|
769
|
+
* iconFileHashes: {
|
|
770
|
+
* "en-us": asSha256Hex(
|
|
771
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
772
|
+
* ),
|
|
773
|
+
* },
|
|
774
|
+
* key: asResourceKey("vip-pass"),
|
|
775
|
+
* kind: "gamePass",
|
|
776
|
+
* name: "VIP Pass",
|
|
777
|
+
* price: undefined,
|
|
778
|
+
* })
|
|
779
|
+
* .then((result) => {
|
|
780
|
+
* expect(result.success).toBeTrue();
|
|
781
|
+
* if (result.success) {
|
|
782
|
+
* expect(result.data.outputs.assetId).toBe("9876543210");
|
|
783
|
+
* }
|
|
784
|
+
* });
|
|
785
|
+
* ```
|
|
786
|
+
*/
|
|
787
|
+
interface ResourceDriver<K extends ResourceKind> {
|
|
788
|
+
/**
|
|
789
|
+
* Create the resource upstream from its desired state and return the
|
|
790
|
+
* resulting current state (desired fields + Roblox-assigned outputs).
|
|
791
|
+
*/
|
|
792
|
+
create(desired: Extract<ResourceDesiredState, {
|
|
793
|
+
kind: K;
|
|
794
|
+
}>): Promise<Result$1<ResourceCurrentState<K>, OpenCloudError$1>>;
|
|
795
|
+
/**
|
|
796
|
+
* Reconcile an upstream resource whose managed content has drifted from its
|
|
797
|
+
* desired state. Receives the last-known current state so the driver can
|
|
798
|
+
* compute a minimal patch (or no-op upstream, for file-backed kinds where
|
|
799
|
+
* republishing is unconditional).
|
|
800
|
+
*
|
|
801
|
+
* Optional. Drivers whose upstream API has no update operation omit this
|
|
802
|
+
* method; `applyOps` surfaces an `updateUnsupported` error at dispatch time
|
|
803
|
+
* instead.
|
|
804
|
+
*/
|
|
805
|
+
update?(current: ResourceCurrentState<K>, desired: Extract<ResourceDesiredState, {
|
|
806
|
+
kind: K;
|
|
807
|
+
}>): Promise<Result$1<ResourceCurrentState<K>, OpenCloudError$1>>;
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Polymorphic dispatch table keyed by {@link ResourceKind}, mapping each kind
|
|
811
|
+
* to the {@link ResourceDriver} that handles it. `applyOps` indexes the
|
|
812
|
+
* registry by `op.desired.kind` to reach the matching driver with full type
|
|
813
|
+
* safety: adding a new kind to `ResourceDesiredState` is a compile error until
|
|
814
|
+
* a matching registry entry is supplied.
|
|
815
|
+
*
|
|
816
|
+
* @example
|
|
817
|
+
*
|
|
818
|
+
* ```ts
|
|
819
|
+
* import { OpenCloudError, type DriverRegistry } from "@bedrock-rbx/core";
|
|
820
|
+
*
|
|
821
|
+
* const registry: DriverRegistry = {
|
|
822
|
+
* gamePass: {
|
|
823
|
+
* async create() {
|
|
824
|
+
* return { err: new OpenCloudError("not implemented"), success: false };
|
|
825
|
+
* },
|
|
826
|
+
* },
|
|
827
|
+
* place: {
|
|
828
|
+
* async create() {
|
|
829
|
+
* return { err: new OpenCloudError("not implemented"), success: false };
|
|
830
|
+
* },
|
|
831
|
+
* },
|
|
832
|
+
* universe: {
|
|
833
|
+
* async create() {
|
|
834
|
+
* return { err: new OpenCloudError("not implemented"), success: false };
|
|
835
|
+
* },
|
|
836
|
+
* },
|
|
837
|
+
* developerProduct: {
|
|
838
|
+
* async create() {
|
|
839
|
+
* return { err: new OpenCloudError("not implemented"), success: false };
|
|
840
|
+
* },
|
|
841
|
+
* },
|
|
842
|
+
* };
|
|
843
|
+
*
|
|
844
|
+
* expect(registry.gamePass).toBeObject();
|
|
845
|
+
* ```
|
|
846
|
+
*/
|
|
847
|
+
type DriverRegistry = { [K in ResourceKind]: ResourceDriver<K> };
|
|
848
|
+
//#endregion
|
|
849
|
+
//#region src/adapters/developer-product-driver.d.ts
|
|
850
|
+
/**
|
|
851
|
+
* Dependencies of `createDeveloperProductDriver`. `universeId` is captured
|
|
852
|
+
* at construction time (matching `GamePassDriverDeps`) so each driver
|
|
853
|
+
* instance is bound to a single universe; multi-universe deploys construct
|
|
854
|
+
* one driver per universe. `readFile` exists on the driver (not upstream
|
|
855
|
+
* in shell) because icon hashes flow through `diff` but bytes do not.
|
|
856
|
+
*
|
|
857
|
+
* @example
|
|
858
|
+
*
|
|
859
|
+
* ```ts
|
|
860
|
+
* import type { HttpClient } from "@bedrock-rbx/ocale";
|
|
861
|
+
* import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
|
|
862
|
+
* import { asRobloxAssetId, type DeveloperProductDriverDeps } from "@bedrock-rbx/core";
|
|
863
|
+
*
|
|
864
|
+
* const httpClient: HttpClient = {
|
|
865
|
+
* async request() {
|
|
866
|
+
* return { data: { body: {}, headers: {}, status: 200 }, success: true };
|
|
867
|
+
* },
|
|
868
|
+
* };
|
|
869
|
+
*
|
|
870
|
+
* const deps: DeveloperProductDriverDeps = {
|
|
871
|
+
* client: new DeveloperProductsClient({
|
|
872
|
+
* apiKey: "rbx-your-key",
|
|
873
|
+
* httpClient,
|
|
874
|
+
* sleep: async () => {},
|
|
875
|
+
* }),
|
|
876
|
+
* readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
|
|
877
|
+
* universeId: asRobloxAssetId("1234567890"),
|
|
878
|
+
* };
|
|
879
|
+
*
|
|
880
|
+
* expect(deps.universeId).toBe("1234567890");
|
|
881
|
+
* ```
|
|
882
|
+
*/
|
|
883
|
+
interface DeveloperProductDriverDeps {
|
|
884
|
+
/** Configured developer-products client from `@bedrock-rbx/ocale/developer-products`. */
|
|
885
|
+
readonly client: DeveloperProductsClient;
|
|
886
|
+
/** Reads icon bytes for upload; rejections propagate out of `create` and `update`. */
|
|
887
|
+
readonly readFile: (path: string) => Promise<Uint8Array>;
|
|
888
|
+
/** Universe that owns every developer product this driver creates. */
|
|
889
|
+
readonly universeId: RobloxAssetId;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Wraps {@link DeveloperProductsClient} as a `ResourceDriver<"developerProduct">`
|
|
893
|
+
* that maps a desired-state entry to an ocale create or update call and the
|
|
894
|
+
* response back to a `ResourceCurrentState<"developerProduct">`. The
|
|
895
|
+
* `update` path consumes the upstream `204 No Content` response and
|
|
896
|
+
* synthesizes the post-update `ResourceCurrentState` from `desired` plus
|
|
897
|
+
* the existing `current.outputs`, carrying `iconImageAssetId` forward when
|
|
898
|
+
* present.
|
|
899
|
+
*
|
|
900
|
+
* Upstream `OpenCloudError` results pass through as `Result` failures.
|
|
901
|
+
*
|
|
902
|
+
* @param deps - Injected ocale client and owning universe.
|
|
903
|
+
* @returns A driver indexable by `"developerProduct"` in a `DriverRegistry`.
|
|
904
|
+
*
|
|
905
|
+
* @example
|
|
906
|
+
*
|
|
907
|
+
* ```ts
|
|
908
|
+
* import type { HttpClient } from "@bedrock-rbx/ocale";
|
|
909
|
+
* import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
|
|
910
|
+
* import {
|
|
911
|
+
* asResourceKey,
|
|
912
|
+
* asRobloxAssetId,
|
|
913
|
+
* createDeveloperProductDriver,
|
|
914
|
+
* } from "@bedrock-rbx/core";
|
|
915
|
+
*
|
|
916
|
+
* const httpClient: HttpClient = {
|
|
917
|
+
* async request() {
|
|
918
|
+
* return {
|
|
919
|
+
* data: {
|
|
920
|
+
* body: {
|
|
921
|
+
* createdTimestamp: "2024-01-15T10:30:00.000Z",
|
|
922
|
+
* description: "Stocks the player up with 1,000 premium gems.",
|
|
923
|
+
* iconImageAssetId: null,
|
|
924
|
+
* isForSale: false,
|
|
925
|
+
* isImmutable: false,
|
|
926
|
+
* name: "Gem Pack",
|
|
927
|
+
* priceInformation: null,
|
|
928
|
+
* productId: 9_876_543_210,
|
|
929
|
+
* storePageEnabled: false,
|
|
930
|
+
* universeId: 1_234_567_890,
|
|
931
|
+
* updatedTimestamp: "2024-01-15T10:30:00.000Z",
|
|
932
|
+
* },
|
|
933
|
+
* headers: {},
|
|
934
|
+
* status: 200,
|
|
935
|
+
* },
|
|
936
|
+
* success: true,
|
|
937
|
+
* };
|
|
938
|
+
* },
|
|
939
|
+
* };
|
|
940
|
+
*
|
|
941
|
+
* const driver = createDeveloperProductDriver({
|
|
942
|
+
* client: new DeveloperProductsClient({
|
|
943
|
+
* apiKey: "rbx-your-key",
|
|
944
|
+
* httpClient,
|
|
945
|
+
* sleep: async () => {},
|
|
946
|
+
* }),
|
|
947
|
+
* readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
|
|
948
|
+
* universeId: asRobloxAssetId("1234567890"),
|
|
949
|
+
* });
|
|
950
|
+
*
|
|
951
|
+
* return driver
|
|
952
|
+
* .create({
|
|
953
|
+
* description: "Stocks the player up with 1,000 premium gems.",
|
|
954
|
+
* isRegionalPricingEnabled: undefined,
|
|
955
|
+
* key: asResourceKey("gem-pack"),
|
|
956
|
+
* kind: "developerProduct",
|
|
957
|
+
* name: "Gem Pack",
|
|
958
|
+
* price: undefined,
|
|
959
|
+
* storePageEnabled: undefined,
|
|
960
|
+
* })
|
|
961
|
+
* .then((result) => {
|
|
962
|
+
* expect(result.success).toBeTrue();
|
|
963
|
+
* if (result.success) {
|
|
964
|
+
* expect(result.data.outputs.productId).toBe("9876543210");
|
|
965
|
+
* }
|
|
966
|
+
* });
|
|
967
|
+
* ```
|
|
968
|
+
*/
|
|
969
|
+
declare function createDeveloperProductDriver(deps: DeveloperProductDriverDeps): ResourceDriver<"developerProduct">;
|
|
970
|
+
//#endregion
|
|
971
|
+
//#region src/adapters/game-pass-driver.d.ts
|
|
972
|
+
/**
|
|
973
|
+
* `universeId` is captured at construction time rather than on
|
|
974
|
+
* `GamePassDesiredState` so state files round-trip with Mantle's `PassInputs`
|
|
975
|
+
* shape. `readFile` exists on the driver (not upstream in shell) because icon
|
|
976
|
+
* hashes flow through `diff` but bytes do not.
|
|
977
|
+
*
|
|
978
|
+
* @example
|
|
979
|
+
*
|
|
980
|
+
* ```ts
|
|
981
|
+
* import type { HttpClient } from "@bedrock-rbx/ocale";
|
|
982
|
+
* import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
|
|
983
|
+
* import { asRobloxAssetId, type GamePassDriverDeps } from "@bedrock-rbx/core";
|
|
984
|
+
*
|
|
985
|
+
* const httpClient: HttpClient = {
|
|
986
|
+
* async request() {
|
|
987
|
+
* return { data: { body: {}, headers: {}, status: 200 }, success: true };
|
|
988
|
+
* },
|
|
989
|
+
* };
|
|
990
|
+
*
|
|
991
|
+
* const deps: GamePassDriverDeps = {
|
|
992
|
+
* client: new GamePassesClient({
|
|
993
|
+
* apiKey: "rbx-your-key",
|
|
994
|
+
* httpClient,
|
|
995
|
+
* sleep: async () => {},
|
|
996
|
+
* }),
|
|
997
|
+
* readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
|
|
998
|
+
* universeId: asRobloxAssetId("1234567890"),
|
|
999
|
+
* };
|
|
1000
|
+
*
|
|
1001
|
+
* expect(deps.universeId).toBe("1234567890");
|
|
1002
|
+
* ```
|
|
1003
|
+
*/
|
|
1004
|
+
interface GamePassDriverDeps {
|
|
1005
|
+
/** Configured game-passes client from `@bedrock-rbx/ocale/game-passes`. */
|
|
1006
|
+
readonly client: GamePassesClient;
|
|
1007
|
+
/** Reads icon bytes for upload; rejections propagate out of `create`. */
|
|
1008
|
+
readonly readFile: (path: string) => Promise<Uint8Array>;
|
|
1009
|
+
/** Universe that owns every game pass this driver creates. */
|
|
1010
|
+
readonly universeId: RobloxAssetId;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Wraps {@link GamePassesClient} as a `ResourceDriver<"gamePass">` that maps
|
|
1014
|
+
* a desired-state entry to an ocale create call and the response back to a
|
|
1015
|
+
* `ResourceCurrentState<"gamePass">`.
|
|
1016
|
+
*
|
|
1017
|
+
* Upstream `OpenCloudError` results pass through as `Result` failures.
|
|
1018
|
+
* Filesystem errors from `deps.readFile` do not fit the `OpenCloudError`
|
|
1019
|
+
* shape and propagate as promise rejections; shell callers are expected to
|
|
1020
|
+
* translate them if a unified error surface is required.
|
|
1021
|
+
*
|
|
1022
|
+
* @param deps - Injected ocale client, file reader, and owning universe.
|
|
1023
|
+
* @returns A driver indexable by `"gamePass"` in a `DriverRegistry`.
|
|
1024
|
+
* @throws Whatever `deps.readFile` rejects with.
|
|
1025
|
+
*
|
|
1026
|
+
* @example
|
|
1027
|
+
*
|
|
1028
|
+
* ```ts
|
|
1029
|
+
* import type { HttpClient } from "@bedrock-rbx/ocale";
|
|
1030
|
+
* import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
|
|
1031
|
+
* import {
|
|
1032
|
+
* asResourceKey,
|
|
1033
|
+
* asRobloxAssetId,
|
|
1034
|
+
* asSha256Hex,
|
|
1035
|
+
* createGamePassDriver,
|
|
1036
|
+
* } from "@bedrock-rbx/core";
|
|
1037
|
+
*
|
|
1038
|
+
* const httpClient: HttpClient = {
|
|
1039
|
+
* async request() {
|
|
1040
|
+
* return {
|
|
1041
|
+
* data: {
|
|
1042
|
+
* body: {
|
|
1043
|
+
* createdTimestamp: "2024-01-15T10:30:00.000Z",
|
|
1044
|
+
* description: "Grants VIP perks.",
|
|
1045
|
+
* gamePassId: 9_876_543_210,
|
|
1046
|
+
* iconAssetId: 1_122_334_455,
|
|
1047
|
+
* isForSale: true,
|
|
1048
|
+
* name: "VIP Pass",
|
|
1049
|
+
* updatedTimestamp: "2024-01-15T10:30:00.000Z",
|
|
1050
|
+
* },
|
|
1051
|
+
* headers: {},
|
|
1052
|
+
* status: 200,
|
|
1053
|
+
* },
|
|
1054
|
+
* success: true,
|
|
1055
|
+
* };
|
|
1056
|
+
* },
|
|
1057
|
+
* };
|
|
1058
|
+
*
|
|
1059
|
+
* const driver = createGamePassDriver({
|
|
1060
|
+
* client: new GamePassesClient({
|
|
1061
|
+
* apiKey: "rbx-your-key",
|
|
1062
|
+
* httpClient,
|
|
1063
|
+
* sleep: async () => {},
|
|
1064
|
+
* }),
|
|
1065
|
+
* readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
|
|
1066
|
+
* universeId: asRobloxAssetId("1234567890"),
|
|
1067
|
+
* });
|
|
1068
|
+
*
|
|
1069
|
+
* return driver
|
|
1070
|
+
* .create({
|
|
1071
|
+
* description: "Grants VIP perks.",
|
|
1072
|
+
* icon: { "en-us": "assets/vip-icon.png" },
|
|
1073
|
+
* iconFileHashes: {
|
|
1074
|
+
* "en-us": asSha256Hex(
|
|
1075
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
1076
|
+
* ),
|
|
1077
|
+
* },
|
|
1078
|
+
* key: asResourceKey("vip-pass"),
|
|
1079
|
+
* kind: "gamePass",
|
|
1080
|
+
* name: "VIP Pass",
|
|
1081
|
+
* price: 500,
|
|
1082
|
+
* })
|
|
1083
|
+
* .then((result) => {
|
|
1084
|
+
* expect(result.success).toBeTrue();
|
|
1085
|
+
* if (result.success) {
|
|
1086
|
+
* expect(result.data.outputs.assetId).toBe("9876543210");
|
|
1087
|
+
* }
|
|
1088
|
+
* });
|
|
1089
|
+
* ```
|
|
1090
|
+
*/
|
|
1091
|
+
declare function createGamePassDriver(deps: GamePassDriverDeps): ResourceDriver<"gamePass">;
|
|
1092
|
+
//#endregion
|
|
1093
|
+
//#region src/core/state.d.ts
|
|
1094
|
+
/**
|
|
1095
|
+
* In-memory state snapshot for one environment.
|
|
1096
|
+
*
|
|
1097
|
+
* The on-disk JSON wraps this shape with a `$bedrock: { version: N }` envelope.
|
|
1098
|
+
* Adapters flatten the envelope on read and re-wrap it on write; nothing
|
|
1099
|
+
* outside an adapter sees the `$bedrock` key.
|
|
1100
|
+
*
|
|
1101
|
+
* `version` is a literal so a breaking schema change is a compile-time type
|
|
1102
|
+
* shift rather than a silently accepted runtime value.
|
|
1103
|
+
*
|
|
1104
|
+
* @example
|
|
1105
|
+
*
|
|
1106
|
+
* ```ts
|
|
1107
|
+
* import {
|
|
1108
|
+
* asResourceKey,
|
|
1109
|
+
* asRobloxAssetId,
|
|
1110
|
+
* asSha256Hex,
|
|
1111
|
+
* type BedrockState,
|
|
1112
|
+
* } from "@bedrock-rbx/core";
|
|
1113
|
+
*
|
|
1114
|
+
* const state: BedrockState = {
|
|
1115
|
+
* environment: "production",
|
|
1116
|
+
* resources: [
|
|
1117
|
+
* {
|
|
1118
|
+
* description: "Grants VIP perks.",
|
|
1119
|
+
* icon: { "en-us": "assets/vip-icon.png" },
|
|
1120
|
+
* iconFileHashes: {
|
|
1121
|
+
* "en-us": asSha256Hex(
|
|
1122
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
1123
|
+
* ),
|
|
1124
|
+
* },
|
|
1125
|
+
* key: asResourceKey("vip-pass"),
|
|
1126
|
+
* kind: "gamePass",
|
|
1127
|
+
* name: "VIP Pass",
|
|
1128
|
+
* outputs: {
|
|
1129
|
+
* assetId: asRobloxAssetId("9876543210"),
|
|
1130
|
+
* iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
|
|
1131
|
+
* },
|
|
1132
|
+
* price: 500,
|
|
1133
|
+
* },
|
|
1134
|
+
* ],
|
|
1135
|
+
* version: 1,
|
|
1136
|
+
* };
|
|
1137
|
+
*
|
|
1138
|
+
* expect(state.version).toBe(1);
|
|
1139
|
+
* expect(state.resources).toHaveLength(1);
|
|
1140
|
+
* ```
|
|
1141
|
+
*/
|
|
1142
|
+
interface BedrockState {
|
|
1143
|
+
/** Environment name this snapshot belongs to (e.g. `"production"`, `"staging"`). */
|
|
1144
|
+
readonly environment: string;
|
|
1145
|
+
/** Current state of every resource Bedrock manages in this environment. */
|
|
1146
|
+
readonly resources: ReadonlyArray<ResourceCurrentState>;
|
|
1147
|
+
/** Schema-version literal; bumped only for breaking changes to the on-disk format. */
|
|
1148
|
+
readonly version: 1;
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Failure surfaced by a `StatePort` when a state file exists but cannot be
|
|
1152
|
+
* trusted: corrupt JSON, schema failure, or an unknown `$bedrock.version`.
|
|
1153
|
+
*
|
|
1154
|
+
* Narrow on `kind` rather than using `instanceof`: `StateError` is plain data,
|
|
1155
|
+
* not a thrown error subclass.
|
|
1156
|
+
*
|
|
1157
|
+
* @example
|
|
1158
|
+
*
|
|
1159
|
+
* ```ts
|
|
1160
|
+
* import type { StateError } from "@bedrock-rbx/core";
|
|
1161
|
+
*
|
|
1162
|
+
* const err: StateError = {
|
|
1163
|
+
* file: ".bedrock/state/production.json",
|
|
1164
|
+
* kind: "stateError",
|
|
1165
|
+
* reason: "Corrupt JSON: unexpected token at line 1 column 5",
|
|
1166
|
+
* };
|
|
1167
|
+
*
|
|
1168
|
+
* expect(err.kind).toBe("stateError");
|
|
1169
|
+
* ```
|
|
1170
|
+
*/
|
|
1171
|
+
interface StateError {
|
|
1172
|
+
/** Adapter-specific path or identifier of the file that failed to parse. */
|
|
1173
|
+
readonly file: string;
|
|
1174
|
+
/** Literal discriminator for narrowing. */
|
|
1175
|
+
readonly kind: "stateError";
|
|
1176
|
+
/** Human-readable explanation of why the file could not be trusted. */
|
|
1177
|
+
readonly reason: string;
|
|
1178
|
+
}
|
|
1179
|
+
//#endregion
|
|
1180
|
+
//#region src/ports/state-port.d.ts
|
|
1181
|
+
/**
|
|
1182
|
+
* Plugin contract for persisting deployment state: the interface an adapter
|
|
1183
|
+
* (Gist, local filesystem, cloud object store) implements to let Bedrock load
|
|
1184
|
+
* and save its per-environment {@link BedrockState} snapshot.
|
|
1185
|
+
*
|
|
1186
|
+
* `StatePort` is a *driven* (secondary) port in hexagonal terms, following the
|
|
1187
|
+
* same naming convention as {@link "./resource-driver".ResourceDriver}.
|
|
1188
|
+
*
|
|
1189
|
+
* @example
|
|
1190
|
+
*
|
|
1191
|
+
* ```ts
|
|
1192
|
+
* import type { BedrockState, StatePort } from "@bedrock-rbx/core";
|
|
1193
|
+
*
|
|
1194
|
+
* const store = new Map<string, BedrockState>();
|
|
1195
|
+
*
|
|
1196
|
+
* const statePort: StatePort = {
|
|
1197
|
+
* async read(environment) {
|
|
1198
|
+
* return { data: store.get(environment), success: true };
|
|
1199
|
+
* },
|
|
1200
|
+
* async write(state) {
|
|
1201
|
+
* store.set(state.environment, state);
|
|
1202
|
+
* return { data: undefined, success: true };
|
|
1203
|
+
* },
|
|
1204
|
+
* };
|
|
1205
|
+
*
|
|
1206
|
+
* return statePort
|
|
1207
|
+
* .read("production")
|
|
1208
|
+
* .then((firstRead) => {
|
|
1209
|
+
* expect(firstRead.success).toBeTrue();
|
|
1210
|
+
* if (firstRead.success) {
|
|
1211
|
+
* expect(firstRead.data).toBeUndefined();
|
|
1212
|
+
* }
|
|
1213
|
+
* return statePort.write({
|
|
1214
|
+
* environment: "production",
|
|
1215
|
+
* resources: [],
|
|
1216
|
+
* version: 1,
|
|
1217
|
+
* });
|
|
1218
|
+
* })
|
|
1219
|
+
* .then((writeResult) => {
|
|
1220
|
+
* expect(writeResult.success).toBeTrue();
|
|
1221
|
+
* return statePort.read("production");
|
|
1222
|
+
* })
|
|
1223
|
+
* .then((secondRead) => {
|
|
1224
|
+
* expect(secondRead.success).toBeTrue();
|
|
1225
|
+
* if (secondRead.success && secondRead.data !== undefined) {
|
|
1226
|
+
* expect(secondRead.data.environment).toBe("production");
|
|
1227
|
+
* expect(secondRead.data.resources).toBeEmpty();
|
|
1228
|
+
* }
|
|
1229
|
+
* });
|
|
1230
|
+
* ```
|
|
1231
|
+
*/
|
|
1232
|
+
interface StatePort {
|
|
1233
|
+
/**
|
|
1234
|
+
* Reads state for the given environment.
|
|
1235
|
+
*
|
|
1236
|
+
* - Returns `Ok(undefined)` when no state file exists (legitimate first deploy).
|
|
1237
|
+
* - Returns `Err(StateError)` when a file exists but cannot be parsed
|
|
1238
|
+
* (corrupt JSON, schema failure, unknown `$bedrock.version`).
|
|
1239
|
+
*
|
|
1240
|
+
* Never silently falls back to empty state: a malformed file that collapsed
|
|
1241
|
+
* to `{ resources: [] }` would cause the next apply to re-create every
|
|
1242
|
+
* resource on Roblox.
|
|
1243
|
+
*/
|
|
1244
|
+
read(environment: string): Promise<Result$1<BedrockState | undefined, StateError>>;
|
|
1245
|
+
/** Writes state for the given environment, overwriting any existing file. */
|
|
1246
|
+
write(state: BedrockState): Promise<Result$1<void, StateError>>;
|
|
1247
|
+
}
|
|
1248
|
+
//#endregion
|
|
1249
|
+
//#region src/adapters/gist-state-adapter.d.ts
|
|
1250
|
+
/**
|
|
1251
|
+
* Minimal `fetch`-compatible signature the adapter needs, narrower than
|
|
1252
|
+
* `typeof globalThis.fetch` so test fakes do not have to stub runtime
|
|
1253
|
+
* extensions such as `fetch.preconnect`.
|
|
1254
|
+
*/
|
|
1255
|
+
type GistFetch = (input: globalThis.Request | string | URL, init?: RequestInit) => Promise<Response>;
|
|
1256
|
+
/**
|
|
1257
|
+
* Configuration for {@link createGistStateAdapter}.
|
|
1258
|
+
*/
|
|
1259
|
+
interface GistStateAdapterDeps {
|
|
1260
|
+
/** Injection seam for tests; defaults to `globalThis.fetch`. */
|
|
1261
|
+
readonly fetch?: GistFetch | undefined;
|
|
1262
|
+
/** ID of an existing GitHub Gist that holds this project's state files. */
|
|
1263
|
+
readonly gistId: string;
|
|
1264
|
+
/**
|
|
1265
|
+
* Injection seam for retry backoff timing; defaults to a `setTimeout`-based
|
|
1266
|
+
* promise. Tests pass a fake to keep retry assertions deterministic.
|
|
1267
|
+
*/
|
|
1268
|
+
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
1269
|
+
/** GitHub token (fine-grained PAT or classic PAT) with gist read/write scope. */
|
|
1270
|
+
readonly token: string;
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Build a `StatePort` that persists Bedrock state in a GitHub Gist.
|
|
1274
|
+
*
|
|
1275
|
+
* One gist holds one file per environment, named `state.<env>.json`. The
|
|
1276
|
+
* adapter authenticates with a user-supplied token and speaks the GitHub
|
|
1277
|
+
* REST API directly; no SDK dependency.
|
|
1278
|
+
*
|
|
1279
|
+
* @example
|
|
1280
|
+
*
|
|
1281
|
+
* ```ts
|
|
1282
|
+
* import { createGistStateAdapter } from "@bedrock-rbx/core";
|
|
1283
|
+
*
|
|
1284
|
+
* const port = createGistStateAdapter({
|
|
1285
|
+
* fetch: async () =>
|
|
1286
|
+
* new Response(JSON.stringify({ files: {} }), { status: 200 }),
|
|
1287
|
+
* gistId: "abc123def456",
|
|
1288
|
+
* token: "ghp_example",
|
|
1289
|
+
* });
|
|
1290
|
+
*
|
|
1291
|
+
* return port.read("production").then((result) => {
|
|
1292
|
+
* expect(result.success).toBeTrue();
|
|
1293
|
+
* if (result.success) {
|
|
1294
|
+
* expect(result.data).toBeUndefined();
|
|
1295
|
+
* }
|
|
1296
|
+
* });
|
|
1297
|
+
* ```
|
|
1298
|
+
*
|
|
1299
|
+
* @param deps - Gist ID, GitHub token, and optional fetch override.
|
|
1300
|
+
* @returns A `StatePort` ready to be passed to `deploy()`.
|
|
1301
|
+
*/
|
|
1302
|
+
declare function createGistStateAdapter(deps: GistStateAdapterDeps): StatePort;
|
|
1303
|
+
//#endregion
|
|
1304
|
+
//#region src/adapters/place-driver.d.ts
|
|
1305
|
+
/**
|
|
1306
|
+
* Dependencies of `createPlaceDriver`. `universeId` is captured at
|
|
1307
|
+
* construction time (matching `GamePassDriverDeps`) so each driver instance
|
|
1308
|
+
* is bound to a single universe; multi-universe deploys construct one driver
|
|
1309
|
+
* per universe. `readFile` is injected because `diff` operates on file hashes
|
|
1310
|
+
* while the driver is the only place that needs the raw bytes.
|
|
1311
|
+
*
|
|
1312
|
+
* @example
|
|
1313
|
+
*
|
|
1314
|
+
* ```ts
|
|
1315
|
+
* import type { HttpClient } from "@bedrock-rbx/ocale";
|
|
1316
|
+
* import { PlacesClient } from "@bedrock-rbx/ocale/places";
|
|
1317
|
+
* import { asRobloxAssetId, type PlaceDriverDeps } from "@bedrock-rbx/core";
|
|
1318
|
+
*
|
|
1319
|
+
* const httpClient: HttpClient = {
|
|
1320
|
+
* async request() {
|
|
1321
|
+
* return { data: { body: {}, headers: {}, status: 200 }, success: true };
|
|
1322
|
+
* },
|
|
1323
|
+
* };
|
|
1324
|
+
*
|
|
1325
|
+
* const deps: PlaceDriverDeps = {
|
|
1326
|
+
* client: new PlacesClient({
|
|
1327
|
+
* apiKey: "rbx-your-key",
|
|
1328
|
+
* httpClient,
|
|
1329
|
+
* sleep: async () => {},
|
|
1330
|
+
* }),
|
|
1331
|
+
* readFile: async () => new Uint8Array(),
|
|
1332
|
+
* universeId: asRobloxAssetId("1234567890"),
|
|
1333
|
+
* };
|
|
1334
|
+
*
|
|
1335
|
+
* expect(deps.universeId).toBe("1234567890");
|
|
1336
|
+
* ```
|
|
1337
|
+
*/
|
|
1338
|
+
interface PlaceDriverDeps {
|
|
1339
|
+
/** Configured places client from `@bedrock-rbx/ocale/places`. */
|
|
1340
|
+
readonly client: PlacesClient;
|
|
1341
|
+
/** Reads place-file bytes for upload; rejections propagate out of the driver. */
|
|
1342
|
+
readonly readFile: (path: string) => Promise<Uint8Array>;
|
|
1343
|
+
/** Universe that owns every place this driver publishes. */
|
|
1344
|
+
readonly universeId: RobloxAssetId;
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Wraps {@link PlacesClient} as a `ResourceDriver<"place">`. `create` and
|
|
1348
|
+
* `update` are both thin wrappers over a shared publish helper because the
|
|
1349
|
+
* upstream Open Cloud call is identical either way: there is no "create
|
|
1350
|
+
* place" endpoint (the place is user-supplied input), only "publish version".
|
|
1351
|
+
*
|
|
1352
|
+
* Format is detected from the file extension (`.rbxl` → binary,
|
|
1353
|
+
* `.rbxlx` → XML); any other extension returns an `ApiError`-backed failure
|
|
1354
|
+
* without hitting the network.
|
|
1355
|
+
*
|
|
1356
|
+
* @param deps - Injected ocale client, file reader, and owning universe.
|
|
1357
|
+
* @returns A driver indexable by `"place"` in a `DriverRegistry`.
|
|
1358
|
+
* @throws Whatever `deps.readFile` rejects with.
|
|
1359
|
+
*
|
|
1360
|
+
* @example
|
|
1361
|
+
*
|
|
1362
|
+
* ```ts
|
|
1363
|
+
* import type { HttpClient } from "@bedrock-rbx/ocale";
|
|
1364
|
+
* import { PlacesClient } from "@bedrock-rbx/ocale/places";
|
|
1365
|
+
* import {
|
|
1366
|
+
* asResourceKey,
|
|
1367
|
+
* asRobloxAssetId,
|
|
1368
|
+
* asSha256Hex,
|
|
1369
|
+
* createPlaceDriver,
|
|
1370
|
+
* } from "@bedrock-rbx/core";
|
|
1371
|
+
*
|
|
1372
|
+
* const httpClient: HttpClient = {
|
|
1373
|
+
* async request() {
|
|
1374
|
+
* return {
|
|
1375
|
+
* data: { body: { versionNumber: 1 }, headers: {}, status: 200 },
|
|
1376
|
+
* success: true,
|
|
1377
|
+
* };
|
|
1378
|
+
* },
|
|
1379
|
+
* };
|
|
1380
|
+
*
|
|
1381
|
+
* const driver = createPlaceDriver({
|
|
1382
|
+
* client: new PlacesClient({
|
|
1383
|
+
* apiKey: "rbx-your-key",
|
|
1384
|
+
* httpClient,
|
|
1385
|
+
* sleep: async () => {},
|
|
1386
|
+
* }),
|
|
1387
|
+
* readFile: async () =>
|
|
1388
|
+
* new Uint8Array([
|
|
1389
|
+
* 0x3c, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x21, 0x89, 0xff, 0x0d, 0x0a, 0x1a,
|
|
1390
|
+
* 0x0a,
|
|
1391
|
+
* ]),
|
|
1392
|
+
* universeId: asRobloxAssetId("1234567890"),
|
|
1393
|
+
* });
|
|
1394
|
+
*
|
|
1395
|
+
* return driver
|
|
1396
|
+
* .create({
|
|
1397
|
+
* description: undefined,
|
|
1398
|
+
* displayName: undefined,
|
|
1399
|
+
* fileHash: asSha256Hex(
|
|
1400
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
1401
|
+
* ),
|
|
1402
|
+
* filePath: "places/start.rbxl",
|
|
1403
|
+
* key: asResourceKey("start-place"),
|
|
1404
|
+
* kind: "place",
|
|
1405
|
+
* placeId: asRobloxAssetId("4711"),
|
|
1406
|
+
* serverSize: undefined,
|
|
1407
|
+
* })
|
|
1408
|
+
* .then((result) => {
|
|
1409
|
+
* expect(result.success).toBeTrue();
|
|
1410
|
+
* if (result.success) {
|
|
1411
|
+
* expect(result.data.outputs.versionNumber).toBe(1);
|
|
1412
|
+
* }
|
|
1413
|
+
* });
|
|
1414
|
+
* ```
|
|
1415
|
+
*/
|
|
1416
|
+
declare function createPlaceDriver(deps: PlaceDriverDeps): ResourceDriver<"place">;
|
|
1417
|
+
//#endregion
|
|
1418
|
+
//#region src/adapters/universe-driver.d.ts
|
|
1419
|
+
/**
|
|
1420
|
+
* Dependencies of `createUniverseDriver`. The driver reconciles the
|
|
1421
|
+
* universe singleton against both the universes endpoint and the root
|
|
1422
|
+
* place (for fields Roblox marks read-only on the universe, like
|
|
1423
|
+
* `displayName`). There is no `universeId` at construction time because
|
|
1424
|
+
* the universe *is* the resource the driver reconciles, so the ID rides
|
|
1425
|
+
* along on each `UniverseDesiredState`.
|
|
1426
|
+
*/
|
|
1427
|
+
interface UniverseDriverDeps {
|
|
1428
|
+
/** Configured places client from `@bedrock-rbx/ocale/places`. */
|
|
1429
|
+
readonly places: PlacesClient;
|
|
1430
|
+
/** Reads icon bytes for upload; rejections propagate out of `create`/`update`. */
|
|
1431
|
+
readonly readFile: (path: string) => Promise<Uint8Array>;
|
|
1432
|
+
/**
|
|
1433
|
+
* Configured universes client from `@bedrock-rbx/ocale/universes`. Localized
|
|
1434
|
+
* experience-icon Operations are reached through `universes.icon.*`.
|
|
1435
|
+
*/
|
|
1436
|
+
readonly universes: UniversesClient;
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Wraps {@link UniversesClient} as a `ResourceDriver<"universe">`. `create`
|
|
1440
|
+
* and `update` both delegate to a shared reconcile helper because Open
|
|
1441
|
+
* Cloud cannot mint universes; the user supplies an existing `universeId`
|
|
1442
|
+
* and bedrock adopts the universe on first apply.
|
|
1443
|
+
*
|
|
1444
|
+
* A `NotFound` error (HTTP 404) from `UniversesClient.update` is repackaged
|
|
1445
|
+
* as an adoption-error `ApiError` whose message names the config key and
|
|
1446
|
+
* the `universeId`, so operators can tell adoption failure apart from
|
|
1447
|
+
* transient upstream errors. A successful response whose `rootPlaceId` is
|
|
1448
|
+
* absent surfaces as an `ApiError` with status 200, mirroring the
|
|
1449
|
+
* malformed-response guard in `GamePassDriver`.
|
|
1450
|
+
*
|
|
1451
|
+
* When `displayName` is declared, the driver routes that field through
|
|
1452
|
+
* `PlacesClient.update` on the root place after the universe PATCH
|
|
1453
|
+
* succeeds. A subsequent places failure surfaces to the caller as the
|
|
1454
|
+
* driver's error result without rolling back the prior universe patch,
|
|
1455
|
+
* so callers observing a partial failure should reconcile by
|
|
1456
|
+
* reapplying rather than assuming the universe-level fields are
|
|
1457
|
+
* unchanged.
|
|
1458
|
+
*
|
|
1459
|
+
* @param deps - Injected ocale clients (universes plus places for the
|
|
1460
|
+
* read-only universe fields Roblox derives from the root place).
|
|
1461
|
+
* @returns A driver indexable by `"universe"` in a `DriverRegistry`.
|
|
1462
|
+
*
|
|
1463
|
+
* @example
|
|
1464
|
+
*
|
|
1465
|
+
* ```ts
|
|
1466
|
+
* import type { HttpClient } from "@bedrock-rbx/ocale";
|
|
1467
|
+
* import { PlacesClient } from "@bedrock-rbx/ocale/places";
|
|
1468
|
+
* import { UniversesClient } from "@bedrock-rbx/ocale/universes";
|
|
1469
|
+
* import { validUniverseBody } from "@bedrock-rbx/ocale/testing";
|
|
1470
|
+
* import {
|
|
1471
|
+
* asRobloxAssetId,
|
|
1472
|
+
* createUniverseDriver,
|
|
1473
|
+
* UNIVERSE_SINGLETON_KEY,
|
|
1474
|
+
* } from "@bedrock-rbx/core";
|
|
1475
|
+
*
|
|
1476
|
+
* const universeBodyHttpClient: HttpClient = {
|
|
1477
|
+
* async request() {
|
|
1478
|
+
* return {
|
|
1479
|
+
* data: {
|
|
1480
|
+
* body: validUniverseBody({
|
|
1481
|
+
* path: "universes/1234567890",
|
|
1482
|
+
* rootPlace: "universes/1234567890/places/4711",
|
|
1483
|
+
* }),
|
|
1484
|
+
* headers: {},
|
|
1485
|
+
* status: 200,
|
|
1486
|
+
* },
|
|
1487
|
+
* success: true,
|
|
1488
|
+
* };
|
|
1489
|
+
* },
|
|
1490
|
+
* };
|
|
1491
|
+
*
|
|
1492
|
+
* const driver = createUniverseDriver({
|
|
1493
|
+
* places: new PlacesClient({
|
|
1494
|
+
* apiKey: "rbx-your-key",
|
|
1495
|
+
* httpClient: universeBodyHttpClient,
|
|
1496
|
+
* sleep: async () => {},
|
|
1497
|
+
* }),
|
|
1498
|
+
* readFile: async () => new Uint8Array(),
|
|
1499
|
+
* universes: new UniversesClient({
|
|
1500
|
+
* apiKey: "rbx-your-key",
|
|
1501
|
+
* httpClient: universeBodyHttpClient,
|
|
1502
|
+
* sleep: async () => {},
|
|
1503
|
+
* }),
|
|
1504
|
+
* });
|
|
1505
|
+
*
|
|
1506
|
+
* return driver
|
|
1507
|
+
* .create({
|
|
1508
|
+
* consoleEnabled: undefined,
|
|
1509
|
+
* desktopEnabled: true,
|
|
1510
|
+
* displayName: undefined,
|
|
1511
|
+
* key: UNIVERSE_SINGLETON_KEY,
|
|
1512
|
+
* kind: "universe",
|
|
1513
|
+
* mobileEnabled: undefined,
|
|
1514
|
+
* privateServerPriceRobux: undefined,
|
|
1515
|
+
* tabletEnabled: undefined,
|
|
1516
|
+
* universeId: asRobloxAssetId("1234567890"),
|
|
1517
|
+
* voiceChatEnabled: true,
|
|
1518
|
+
* vrEnabled: undefined,
|
|
1519
|
+
* })
|
|
1520
|
+
* .then((result) => {
|
|
1521
|
+
* expect(result.success).toBeTrue();
|
|
1522
|
+
* if (result.success) {
|
|
1523
|
+
* expect(result.data.outputs.rootPlaceId).toBe("4711");
|
|
1524
|
+
* }
|
|
1525
|
+
* });
|
|
1526
|
+
* ```
|
|
1527
|
+
*/
|
|
1528
|
+
declare function createUniverseDriver(deps: UniverseDriverDeps): ResourceDriver<"universe">;
|
|
1529
|
+
//#endregion
|
|
1530
|
+
//#region src/core/derive-price-fields.d.ts
|
|
1531
|
+
/**
|
|
1532
|
+
* Wire-shape pricing fragment produced by {@link derivePriceFields}: the
|
|
1533
|
+
* `isForSale` flag and an optional numeric `price`. Mirrors the multipart
|
|
1534
|
+
* fields the Open Cloud `developer-products` create and update endpoints
|
|
1535
|
+
* accept for setting Robux pricing.
|
|
1536
|
+
*/
|
|
1537
|
+
interface PriceFields {
|
|
1538
|
+
/** Whether the developer product should be purchasable. */
|
|
1539
|
+
readonly isForSale: boolean;
|
|
1540
|
+
/** Default price in Robux; absent when the product is off-sale. */
|
|
1541
|
+
readonly price?: number;
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Translate a Mantle-style optional price into the Open Cloud wire shape.
|
|
1545
|
+
*
|
|
1546
|
+
* `desired.price === undefined` (no price declared) becomes
|
|
1547
|
+
* `{ isForSale: false }` and the `price` key is omitted entirely. A defined
|
|
1548
|
+
* price (including `0`) becomes `{ isForSale: true, price }`. Both
|
|
1549
|
+
* `developerProduct` create and update paths share this helper so the
|
|
1550
|
+
* "absent ⇒ off-sale" semantics live in exactly one place.
|
|
1551
|
+
*
|
|
1552
|
+
* @param desired - Object carrying the user-declared `price`.
|
|
1553
|
+
* @returns The wire-shape `{ isForSale, price? }` fragment.
|
|
1554
|
+
*
|
|
1555
|
+
* @example
|
|
1556
|
+
*
|
|
1557
|
+
* ```ts
|
|
1558
|
+
* import { derivePriceFields } from "@bedrock-rbx/core";
|
|
1559
|
+
*
|
|
1560
|
+
* expect(derivePriceFields({ price: undefined })).toStrictEqual({ isForSale: false });
|
|
1561
|
+
* expect(derivePriceFields({ price: 250 })).toStrictEqual({ isForSale: true, price: 250 });
|
|
1562
|
+
* ```
|
|
1563
|
+
*/
|
|
1564
|
+
declare function derivePriceFields(desired: {
|
|
1565
|
+
readonly price: number | undefined;
|
|
1566
|
+
}): PriceFields;
|
|
1567
|
+
//#endregion
|
|
1568
|
+
//#region src/core/operations.d.ts
|
|
1569
|
+
/**
|
|
1570
|
+
* Fields shared by every operation variant.
|
|
1571
|
+
*
|
|
1572
|
+
* `key` is hoisted to op-level (rather than nested under `desired` or
|
|
1573
|
+
* `current`) so callers can read it from any variant without first narrowing
|
|
1574
|
+
* on the discriminator. `applyOps` and logging both rely on this uniform
|
|
1575
|
+
* access pattern.
|
|
1576
|
+
*
|
|
1577
|
+
* @example
|
|
1578
|
+
*
|
|
1579
|
+
* ```ts
|
|
1580
|
+
* import { asResourceKey, type BaseOperation } from "@bedrock-rbx/core";
|
|
1581
|
+
*
|
|
1582
|
+
* const base: BaseOperation = { key: asResourceKey("vip-pass") };
|
|
1583
|
+
*
|
|
1584
|
+
* expect(base.key).toBe("vip-pass");
|
|
1585
|
+
* ```
|
|
1586
|
+
*/
|
|
1587
|
+
interface BaseOperation {
|
|
1588
|
+
/** Resource key copied from the desired or current entry the op describes. */
|
|
1589
|
+
readonly key: ResourceKey;
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* Reconcile an absent resource: produced when a `desired` entry has no
|
|
1593
|
+
* matching `current` entry. The driver creates the resource and records the
|
|
1594
|
+
* Roblox-assigned outputs into state.
|
|
1595
|
+
*
|
|
1596
|
+
* @example
|
|
1597
|
+
*
|
|
1598
|
+
* ```ts
|
|
1599
|
+
* import {
|
|
1600
|
+
* asResourceKey,
|
|
1601
|
+
* asSha256Hex,
|
|
1602
|
+
* type CreateOperation,
|
|
1603
|
+
* } from "@bedrock-rbx/core";
|
|
1604
|
+
*
|
|
1605
|
+
* const op: CreateOperation = {
|
|
1606
|
+
* desired: {
|
|
1607
|
+
* description: "Grants VIP perks.",
|
|
1608
|
+
* icon: { "en-us": "assets/vip-icon.png" },
|
|
1609
|
+
* iconFileHashes: {
|
|
1610
|
+
* "en-us": asSha256Hex(
|
|
1611
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
1612
|
+
* ),
|
|
1613
|
+
* },
|
|
1614
|
+
* key: asResourceKey("vip-pass"),
|
|
1615
|
+
* kind: "gamePass",
|
|
1616
|
+
* name: "VIP Pass",
|
|
1617
|
+
* price: 500,
|
|
1618
|
+
* },
|
|
1619
|
+
* key: asResourceKey("vip-pass"),
|
|
1620
|
+
* type: "create",
|
|
1621
|
+
* };
|
|
1622
|
+
*
|
|
1623
|
+
* expect(op.type).toBe("create");
|
|
1624
|
+
* expect(op.desired.kind).toBe("gamePass");
|
|
1625
|
+
* ```
|
|
1626
|
+
*/
|
|
1627
|
+
interface CreateOperation extends BaseOperation {
|
|
1628
|
+
/** Declared desired state to materialize through the driver. */
|
|
1629
|
+
readonly desired: ResourceDesiredState;
|
|
1630
|
+
/** Discriminator tag for the `Operation` union. */
|
|
1631
|
+
readonly type: "create";
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Reconcile a drifted resource: produced when a `desired` entry differs from
|
|
1635
|
+
* its matching `current` entry. Both states are carried so the driver can
|
|
1636
|
+
* compute the minimal patch.
|
|
1637
|
+
*
|
|
1638
|
+
* @example
|
|
1639
|
+
*
|
|
1640
|
+
* ```ts
|
|
1641
|
+
* import {
|
|
1642
|
+
* asResourceKey,
|
|
1643
|
+
* asRobloxAssetId,
|
|
1644
|
+
* asSha256Hex,
|
|
1645
|
+
* type UpdateOperation,
|
|
1646
|
+
* } from "@bedrock-rbx/core";
|
|
1647
|
+
*
|
|
1648
|
+
* const op: UpdateOperation = {
|
|
1649
|
+
* current: {
|
|
1650
|
+
* description: "Grants VIP perks.",
|
|
1651
|
+
* icon: { "en-us": "assets/vip-icon.png" },
|
|
1652
|
+
* iconFileHashes: {
|
|
1653
|
+
* "en-us": asSha256Hex(
|
|
1654
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
1655
|
+
* ),
|
|
1656
|
+
* },
|
|
1657
|
+
* key: asResourceKey("vip-pass"),
|
|
1658
|
+
* kind: "gamePass",
|
|
1659
|
+
* name: "VIP Pass",
|
|
1660
|
+
* outputs: {
|
|
1661
|
+
* assetId: asRobloxAssetId("9876543210"),
|
|
1662
|
+
* iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
|
|
1663
|
+
* },
|
|
1664
|
+
* price: 500,
|
|
1665
|
+
* },
|
|
1666
|
+
* desired: {
|
|
1667
|
+
* description: "Grants VIP perks plus emote.",
|
|
1668
|
+
* icon: { "en-us": "assets/vip-icon.png" },
|
|
1669
|
+
* iconFileHashes: {
|
|
1670
|
+
* "en-us": asSha256Hex(
|
|
1671
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
1672
|
+
* ),
|
|
1673
|
+
* },
|
|
1674
|
+
* key: asResourceKey("vip-pass"),
|
|
1675
|
+
* kind: "gamePass",
|
|
1676
|
+
* name: "VIP Pass",
|
|
1677
|
+
* price: 750,
|
|
1678
|
+
* },
|
|
1679
|
+
* key: asResourceKey("vip-pass"),
|
|
1680
|
+
* type: "update",
|
|
1681
|
+
* };
|
|
1682
|
+
*
|
|
1683
|
+
* expect(op.type).toBe("update");
|
|
1684
|
+
* if (op.desired.kind === "gamePass") {
|
|
1685
|
+
* expect(op.desired.price).toBe(750);
|
|
1686
|
+
* }
|
|
1687
|
+
* if (op.current.kind === "gamePass") {
|
|
1688
|
+
* expect(op.current.outputs.assetId).toBe("9876543210");
|
|
1689
|
+
* }
|
|
1690
|
+
* ```
|
|
1691
|
+
*/
|
|
1692
|
+
interface UpdateOperation extends BaseOperation {
|
|
1693
|
+
/** Last-known live state; the driver computes a patch against `desired`. */
|
|
1694
|
+
readonly current: ResourceCurrentState;
|
|
1695
|
+
/** Declared desired state to converge toward. */
|
|
1696
|
+
readonly desired: ResourceDesiredState;
|
|
1697
|
+
/** Discriminator tag for the `Operation` union. */
|
|
1698
|
+
readonly type: "update";
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Acknowledge that a resource is already in sync: produced when a `desired`
|
|
1702
|
+
* entry matches its `current` entry exactly. The driver performs no I/O for
|
|
1703
|
+
* this variant.
|
|
1704
|
+
*
|
|
1705
|
+
* Bare by design: the operation carries only `key` and `type` because no
|
|
1706
|
+
* payload is needed at apply time. Callers that need the matching desired or
|
|
1707
|
+
* current state look it up in the snapshots passed to `diff`.
|
|
1708
|
+
*
|
|
1709
|
+
* @example
|
|
1710
|
+
*
|
|
1711
|
+
* ```ts
|
|
1712
|
+
* import { asResourceKey, type NoopOperation } from "@bedrock-rbx/core";
|
|
1713
|
+
*
|
|
1714
|
+
* const op: NoopOperation = {
|
|
1715
|
+
* key: asResourceKey("vip-pass"),
|
|
1716
|
+
* type: "noop",
|
|
1717
|
+
* };
|
|
1718
|
+
*
|
|
1719
|
+
* expect(op.type).toBe("noop");
|
|
1720
|
+
* expect(op.key).toBe("vip-pass");
|
|
1721
|
+
* ```
|
|
1722
|
+
*/
|
|
1723
|
+
interface NoopOperation extends BaseOperation {
|
|
1724
|
+
/** Discriminator tag for the `Operation` union. */
|
|
1725
|
+
readonly type: "noop";
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* Discriminated union of every reconciliation step `diff` produces and
|
|
1729
|
+
* `applyOps` consumes. The `type` field is the discriminator (`kind` is
|
|
1730
|
+
* reserved for the resource discriminator in `ResourceDesiredState`).
|
|
1731
|
+
*
|
|
1732
|
+
* A `delete` variant is intentionally absent: resources present only in
|
|
1733
|
+
* current state (orphans) are ignored, never reconciled.
|
|
1734
|
+
*
|
|
1735
|
+
* @example
|
|
1736
|
+
*
|
|
1737
|
+
* ```ts
|
|
1738
|
+
* import { asResourceKey, type Operation } from "@bedrock-rbx/core";
|
|
1739
|
+
*
|
|
1740
|
+
* function describeOp(op: Operation): string {
|
|
1741
|
+
* switch (op.type) {
|
|
1742
|
+
* case "create": {
|
|
1743
|
+
* return `create ${op.desired.kind} ${op.key}`;
|
|
1744
|
+
* }
|
|
1745
|
+
* case "update": {
|
|
1746
|
+
* return `update ${op.desired.kind} ${op.key}`;
|
|
1747
|
+
* }
|
|
1748
|
+
* case "noop": {
|
|
1749
|
+
* return `noop ${op.key}`;
|
|
1750
|
+
* }
|
|
1751
|
+
* }
|
|
1752
|
+
* }
|
|
1753
|
+
*
|
|
1754
|
+
* const op: Operation = {
|
|
1755
|
+
* key: asResourceKey("vip-pass"),
|
|
1756
|
+
* type: "noop",
|
|
1757
|
+
* };
|
|
1758
|
+
*
|
|
1759
|
+
* expect(describeOp(op)).toBe("noop vip-pass");
|
|
1760
|
+
* ```
|
|
1761
|
+
*/
|
|
1762
|
+
type Operation = CreateOperation | NoopOperation | UpdateOperation;
|
|
1763
|
+
//#endregion
|
|
1764
|
+
//#region src/core/diff.d.ts
|
|
1765
|
+
/**
|
|
1766
|
+
* Computes the operations required to reconcile `current` state with `desired`
|
|
1767
|
+
* state. Pure and synchronous: no I/O, no side effects, no `Result` wrapper.
|
|
1768
|
+
*
|
|
1769
|
+
* Each entry in `desired` is matched to `current` by `(kind, key)`: resources
|
|
1770
|
+
* are uniquely identified by that pair, so a `place` and a `universe` keyed
|
|
1771
|
+
* `"main"` are independent slots. A `(kind, key)` pair present only in
|
|
1772
|
+
* `desired` produces a `create` op; a pair present in both produces an
|
|
1773
|
+
* `update` op if any declared field differs or a `noop` op if every field
|
|
1774
|
+
* matches.
|
|
1775
|
+
*
|
|
1776
|
+
* Ops appear in the order their desired entries appear in the input array so
|
|
1777
|
+
* callers can rely on declaration order when logging or applying ops.
|
|
1778
|
+
*
|
|
1779
|
+
* @param desired - Declared desired state from user config, already normalized
|
|
1780
|
+
* (file hashes computed, nullable wire values mapped to `undefined`).
|
|
1781
|
+
* @param current - Last-known live state from the state file.
|
|
1782
|
+
* @returns Operations to reconcile the two snapshots.
|
|
1783
|
+
*
|
|
1784
|
+
* @example
|
|
1785
|
+
*
|
|
1786
|
+
* ```ts
|
|
1787
|
+
* import {
|
|
1788
|
+
* asResourceKey,
|
|
1789
|
+
* asRobloxAssetId,
|
|
1790
|
+
* asSha256Hex,
|
|
1791
|
+
* diff,
|
|
1792
|
+
* type GamePassDesiredState,
|
|
1793
|
+
* type ResourceCurrentState,
|
|
1794
|
+
* } from "@bedrock-rbx/core";
|
|
1795
|
+
*
|
|
1796
|
+
* const hash = asSha256Hex(
|
|
1797
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
1798
|
+
* );
|
|
1799
|
+
*
|
|
1800
|
+
* const unchanged: GamePassDesiredState = {
|
|
1801
|
+
* description: "Grants VIP perks.",
|
|
1802
|
+
* icon: { "en-us": "assets/vip-icon.png" },
|
|
1803
|
+
* iconFileHashes: { "en-us": hash },
|
|
1804
|
+
* key: asResourceKey("vip-pass"),
|
|
1805
|
+
* kind: "gamePass",
|
|
1806
|
+
* name: "VIP Pass",
|
|
1807
|
+
* price: 500,
|
|
1808
|
+
* };
|
|
1809
|
+
* const drifted: GamePassDesiredState = {
|
|
1810
|
+
* ...unchanged,
|
|
1811
|
+
* key: asResourceKey("legend-pass"),
|
|
1812
|
+
* name: "Legend Pass (renamed)",
|
|
1813
|
+
* };
|
|
1814
|
+
* const fresh: GamePassDesiredState = {
|
|
1815
|
+
* ...unchanged,
|
|
1816
|
+
* key: asResourceKey("rookie-pass"),
|
|
1817
|
+
* name: "Rookie Pass",
|
|
1818
|
+
* };
|
|
1819
|
+
*
|
|
1820
|
+
* const current: ReadonlyArray<ResourceCurrentState> = [
|
|
1821
|
+
* {
|
|
1822
|
+
* ...unchanged,
|
|
1823
|
+
* outputs: {
|
|
1824
|
+
* assetId: asRobloxAssetId("111"),
|
|
1825
|
+
* iconAssetIds: { "en-us": asRobloxAssetId("222") },
|
|
1826
|
+
* },
|
|
1827
|
+
* },
|
|
1828
|
+
* {
|
|
1829
|
+
* ...drifted,
|
|
1830
|
+
* name: "Legend Pass",
|
|
1831
|
+
* outputs: {
|
|
1832
|
+
* assetId: asRobloxAssetId("333"),
|
|
1833
|
+
* iconAssetIds: { "en-us": asRobloxAssetId("444") },
|
|
1834
|
+
* },
|
|
1835
|
+
* },
|
|
1836
|
+
* ];
|
|
1837
|
+
*
|
|
1838
|
+
* const ops = diff([unchanged, drifted, fresh], current);
|
|
1839
|
+
*
|
|
1840
|
+
* expect(ops.map((op) => op.type)).toEqual(["noop", "update", "create"]);
|
|
1841
|
+
* ```
|
|
1842
|
+
*/
|
|
1843
|
+
declare function diff(desired: ReadonlyArray<ResourceDesiredState>, current: ReadonlyArray<ResourceCurrentState>): ReadonlyArray<Operation>;
|
|
1844
|
+
//#endregion
|
|
1845
|
+
//#region src/core/display-name-prefix.d.ts
|
|
1846
|
+
/**
|
|
1847
|
+
* Default template applied when a project enables display-name prefixing
|
|
1848
|
+
* without supplying its own `displayNamePrefix.format`. Yields outputs
|
|
1849
|
+
* like `[STAGING] ` for an environment whose `label` is `"staging"`.
|
|
1850
|
+
*/
|
|
1851
|
+
declare const DEFAULT_PREFIX_FORMAT = "[{LABEL}] ";
|
|
1852
|
+
/**
|
|
1853
|
+
* Render the prefix that selectEnvironment prepends to declared display
|
|
1854
|
+
* names when a project enables `displayNamePrefix`. The template
|
|
1855
|
+
* recognizes three placeholders:
|
|
1856
|
+
*
|
|
1857
|
+
* - `{label}`: label as written.
|
|
1858
|
+
* - `{LABEL}`: upper-cased label.
|
|
1859
|
+
* - `{Label}`: capitalized label (first character upper, rest as written).
|
|
1860
|
+
*
|
|
1861
|
+
* Other characters in the template flow through verbatim.
|
|
1862
|
+
*
|
|
1863
|
+
* @param label - Environment label declared on `EnvironmentEntry.label`.
|
|
1864
|
+
* @param format - Template string. Falls back to
|
|
1865
|
+
* {@link DEFAULT_PREFIX_FORMAT} when omitted.
|
|
1866
|
+
* @returns The rendered prefix string.
|
|
1867
|
+
*
|
|
1868
|
+
* @example
|
|
1869
|
+
*
|
|
1870
|
+
* ```ts
|
|
1871
|
+
* import { renderDisplayNamePrefix } from "@bedrock-rbx/core";
|
|
1872
|
+
*
|
|
1873
|
+
* expect(renderDisplayNamePrefix("staging")).toBe("[STAGING] ");
|
|
1874
|
+
* expect(renderDisplayNamePrefix("staging", "{Label}: ")).toBe("Staging: ");
|
|
1875
|
+
* expect(renderDisplayNamePrefix("dev", "{LABEL}-{label}")).toBe("DEV-dev");
|
|
1876
|
+
* ```
|
|
1877
|
+
*/
|
|
1878
|
+
declare function renderDisplayNamePrefix(label: string, format?: string): string;
|
|
1879
|
+
//#endregion
|
|
1880
|
+
//#region src/core/environment.d.ts
|
|
1881
|
+
/**
|
|
1882
|
+
* Validate an environment name at a state-adapter boundary.
|
|
1883
|
+
*
|
|
1884
|
+
* Adapters that map environment names onto filesystem-like identifiers
|
|
1885
|
+
* (gist filenames, S3 keys) must reject names that could collide or escape
|
|
1886
|
+
* their storage layout. This helper accepts letters, digits, `-`, and `_`
|
|
1887
|
+
* only, with length between 1 and 64, and returns a `StateError` for
|
|
1888
|
+
* anything outside that set so the adapter can fail loudly instead of
|
|
1889
|
+
* silently stripping characters.
|
|
1890
|
+
*
|
|
1891
|
+
* @example
|
|
1892
|
+
*
|
|
1893
|
+
* ```ts
|
|
1894
|
+
* import { validateEnvironmentName } from "@bedrock-rbx/core";
|
|
1895
|
+
*
|
|
1896
|
+
* const ok = validateEnvironmentName("production");
|
|
1897
|
+
* expect(ok.success).toBeTrue();
|
|
1898
|
+
*
|
|
1899
|
+
* const bad = validateEnvironmentName("prod/staging");
|
|
1900
|
+
* expect(bad.success).toBeFalse();
|
|
1901
|
+
* ```
|
|
1902
|
+
*
|
|
1903
|
+
* @param environment - Raw environment name supplied by a caller.
|
|
1904
|
+
* @returns `Ok(environment)` when the name is safe to use, or
|
|
1905
|
+
* `Err(StateError)` with a descriptive reason when it is not.
|
|
1906
|
+
*/
|
|
1907
|
+
declare function validateEnvironmentName(environment: string): Result$1<string, StateError>;
|
|
1908
|
+
//#endregion
|
|
1909
|
+
//#region src/core/flatten.d.ts
|
|
1910
|
+
/**
|
|
1911
|
+
* Pre-I/O game-pass input the flattener emits. Extends the authored
|
|
1912
|
+
* `GamePassEntry` with the tag discriminator and the `ResourceKey`-branded
|
|
1913
|
+
* key so `buildDesired` can consume a flat tagged list and layer on the
|
|
1914
|
+
* SHA-256 icon digest.
|
|
1915
|
+
*
|
|
1916
|
+
* @example
|
|
1917
|
+
*
|
|
1918
|
+
* ```ts
|
|
1919
|
+
* import { asResourceKey, type GamePassDesiredInput } from "@bedrock-rbx/core";
|
|
1920
|
+
*
|
|
1921
|
+
* const input: GamePassDesiredInput = {
|
|
1922
|
+
* description: "Grants VIP perks.",
|
|
1923
|
+
* icon: { "en-us": "assets/vip-icon.png" },
|
|
1924
|
+
* key: asResourceKey("vip-pass"),
|
|
1925
|
+
* kind: "gamePass",
|
|
1926
|
+
* name: "VIP Pass",
|
|
1927
|
+
* price: 500,
|
|
1928
|
+
* };
|
|
1929
|
+
*
|
|
1930
|
+
* expect(input.kind).toBe("gamePass");
|
|
1931
|
+
* ```
|
|
1932
|
+
*/
|
|
1933
|
+
interface GamePassDesiredInput extends Readonly<GamePassEntry> {
|
|
1934
|
+
/** User-supplied handle, already validated against the `ResourceKey` brand. */
|
|
1935
|
+
readonly key: ResourceKey;
|
|
1936
|
+
/** Discriminator tag for the `ResourceDesiredInput` union. */
|
|
1937
|
+
readonly kind: "gamePass";
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Pre-I/O place input the flattener emits. Carries the resolved place
|
|
1941
|
+
* fields (`filePath` from the root, `placeId` from the per-environment
|
|
1942
|
+
* overlay) plus the optional metadata fields and the tag discriminator and
|
|
1943
|
+
* the `ResourceKey`-branded key, so `buildDesired` can consume a flat
|
|
1944
|
+
* tagged list and layer on the SHA-256 file digest.
|
|
1945
|
+
*
|
|
1946
|
+
* @example
|
|
1947
|
+
*
|
|
1948
|
+
* ```ts
|
|
1949
|
+
* import { asResourceKey, asRobloxAssetId, type PlaceDesiredInput } from "@bedrock-rbx/core";
|
|
1950
|
+
*
|
|
1951
|
+
* const input: PlaceDesiredInput = {
|
|
1952
|
+
* description: undefined,
|
|
1953
|
+
* displayName: "Start Place",
|
|
1954
|
+
* filePath: "places/start.rbxl",
|
|
1955
|
+
* key: asResourceKey("start-place"),
|
|
1956
|
+
* kind: "place",
|
|
1957
|
+
* placeId: asRobloxAssetId("4711"),
|
|
1958
|
+
* serverSize: 50,
|
|
1959
|
+
* };
|
|
1960
|
+
*
|
|
1961
|
+
* expect(input.kind).toBe("place");
|
|
1962
|
+
* expect(input.displayName).toBe("Start Place");
|
|
1963
|
+
* ```
|
|
1964
|
+
*/
|
|
1965
|
+
interface PlaceDesiredInput {
|
|
1966
|
+
/** User-supplied handle, already validated against the `ResourceKey` brand. */
|
|
1967
|
+
readonly key: ResourceKey;
|
|
1968
|
+
/** User-facing place description; `undefined` leaves the server value untouched. */
|
|
1969
|
+
readonly description: string | undefined;
|
|
1970
|
+
/** User-facing place name; `undefined` leaves the server value untouched. */
|
|
1971
|
+
readonly displayName: string | undefined;
|
|
1972
|
+
/** Path to the `.rbxl` or `.rbxlx` file; read by `buildDesired`. */
|
|
1973
|
+
readonly filePath: string;
|
|
1974
|
+
/** Discriminator tag for the `ResourceDesiredInput` union. */
|
|
1975
|
+
readonly kind: "place";
|
|
1976
|
+
/** Existing Roblox place ID, validated and branded at flatten time. */
|
|
1977
|
+
readonly placeId: RobloxAssetId;
|
|
1978
|
+
/** Maximum players per server; `undefined` leaves the server value untouched. */
|
|
1979
|
+
readonly serverSize: number | undefined;
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Pre-I/O universe input the flattener emits. Carries the fixed singleton
|
|
1983
|
+
* key (`"main"`) and the branded `universeId` so `buildDesired` can hand a
|
|
1984
|
+
* single tagged record downstream without a shape divergence for the
|
|
1985
|
+
* singleton kind.
|
|
1986
|
+
*
|
|
1987
|
+
* @example
|
|
1988
|
+
*
|
|
1989
|
+
* ```ts
|
|
1990
|
+
* import { asRobloxAssetId, UNIVERSE_SINGLETON_KEY, type UniverseDesiredInput } from "@bedrock-rbx/core";
|
|
1991
|
+
*
|
|
1992
|
+
* const input: UniverseDesiredInput = {
|
|
1993
|
+
* consoleEnabled: undefined,
|
|
1994
|
+
* desktopEnabled: true,
|
|
1995
|
+
* displayName: undefined,
|
|
1996
|
+
* key: UNIVERSE_SINGLETON_KEY,
|
|
1997
|
+
* kind: "universe",
|
|
1998
|
+
* mobileEnabled: undefined,
|
|
1999
|
+
* tabletEnabled: undefined,
|
|
2000
|
+
* universeId: asRobloxAssetId("1234567890"),
|
|
2001
|
+
* voiceChatEnabled: true,
|
|
2002
|
+
* vrEnabled: undefined,
|
|
2003
|
+
* };
|
|
2004
|
+
*
|
|
2005
|
+
* expect(input.kind).toBe("universe");
|
|
2006
|
+
* expect(input.key).toBe("main");
|
|
2007
|
+
* expect(input.desktopEnabled).toBeTrue();
|
|
2008
|
+
* ```
|
|
2009
|
+
*/
|
|
2010
|
+
interface UniverseDesiredInput {
|
|
2011
|
+
/** Synthesized singleton key (`"main"`), already validated against the `ResourceKey` brand. */
|
|
2012
|
+
readonly key: ResourceKey;
|
|
2013
|
+
/** Whether console players can join; `undefined` leaves the server value untouched. */
|
|
2014
|
+
readonly consoleEnabled: boolean | undefined;
|
|
2015
|
+
/** Whether desktop players can join; `undefined` leaves the server value untouched. */
|
|
2016
|
+
readonly desktopEnabled: boolean | undefined;
|
|
2017
|
+
/** Discord social link; tri-state (absent/undefined/set) — see `UniverseDesiredState`. */
|
|
2018
|
+
readonly discordSocialLink?: SocialLink$1 | undefined;
|
|
2019
|
+
/**
|
|
2020
|
+
* Display name for the universe. `undefined` leaves the server value
|
|
2021
|
+
* untouched. The driver routes declared updates through
|
|
2022
|
+
* `PlacesClient.update` because the universe PATCH endpoint treats
|
|
2023
|
+
* `displayName` as read-only.
|
|
2024
|
+
*/
|
|
2025
|
+
readonly displayName: string | undefined;
|
|
2026
|
+
/** Facebook social link; tri-state (absent/undefined/set) — see `UniverseDesiredState`. */
|
|
2027
|
+
readonly facebookSocialLink?: SocialLink$1 | undefined;
|
|
2028
|
+
/** Guilded social link; tri-state (absent/undefined/set) — see `UniverseDesiredState`. */
|
|
2029
|
+
readonly guildedSocialLink?: SocialLink$1 | undefined;
|
|
2030
|
+
/**
|
|
2031
|
+
* Locale-keyed experience-icon paths copied from the user-supplied
|
|
2032
|
+
* `UniverseEntry`. Absent when the user did not declare an icon block.
|
|
2033
|
+
*/
|
|
2034
|
+
readonly icon?: Record<"en-us", string>;
|
|
2035
|
+
/** Discriminator tag for the `ResourceDesiredInput` union. */
|
|
2036
|
+
readonly kind: "universe";
|
|
2037
|
+
/** Whether mobile players can join; `undefined` leaves the server value untouched. */
|
|
2038
|
+
readonly mobileEnabled: boolean | undefined;
|
|
2039
|
+
/**
|
|
2040
|
+
* Private-server price in Robux. A present key with `undefined`
|
|
2041
|
+
* clears the server value (ocale emits JSON `null`); an absent key
|
|
2042
|
+
* leaves the server value untouched.
|
|
2043
|
+
*/
|
|
2044
|
+
readonly privateServerPriceRobux?: number | undefined;
|
|
2045
|
+
/** Roblox Group social link; tri-state (absent/undefined/set) — see `UniverseDesiredState`. */
|
|
2046
|
+
readonly robloxGroupSocialLink?: SocialLink$1 | undefined;
|
|
2047
|
+
/** Whether tablet players can join; `undefined` leaves the server value untouched. */
|
|
2048
|
+
readonly tabletEnabled: boolean | undefined;
|
|
2049
|
+
/** Twitch social link; tri-state (absent/undefined/set) — see `UniverseDesiredState`. */
|
|
2050
|
+
readonly twitchSocialLink?: SocialLink$1 | undefined;
|
|
2051
|
+
/** Twitter social link; tri-state (absent/undefined/set) — see `UniverseDesiredState`. */
|
|
2052
|
+
readonly twitterSocialLink?: SocialLink$1 | undefined;
|
|
2053
|
+
/** Existing Roblox universe ID, validated and branded at flatten time. */
|
|
2054
|
+
readonly universeId: RobloxAssetId;
|
|
2055
|
+
/** Whether voice chat is enabled; `undefined` leaves the server value untouched. */
|
|
2056
|
+
readonly voiceChatEnabled: boolean | undefined;
|
|
2057
|
+
/** Whether VR players can join; `undefined` leaves the server value untouched. */
|
|
2058
|
+
readonly vrEnabled: boolean | undefined;
|
|
2059
|
+
/** YouTube social link; tri-state (absent/undefined/set) — see `UniverseDesiredState`. */
|
|
2060
|
+
readonly youtubeSocialLink?: SocialLink$1 | undefined;
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* Pre-I/O developer-product input the flattener emits. Extends the authored
|
|
2064
|
+
* `DeveloperProductEntry` with the tag discriminator and the
|
|
2065
|
+
* `ResourceKey`-branded key so `buildDesired` can consume a flat tagged
|
|
2066
|
+
* list.
|
|
2067
|
+
*
|
|
2068
|
+
* @example
|
|
2069
|
+
*
|
|
2070
|
+
* ```ts
|
|
2071
|
+
* import { asResourceKey, type DeveloperProductDesiredInput } from "@bedrock-rbx/core";
|
|
2072
|
+
*
|
|
2073
|
+
* const input: DeveloperProductDesiredInput = {
|
|
2074
|
+
* description: "Stocks the player up with 1,000 premium gems.",
|
|
2075
|
+
* key: asResourceKey("gem-pack"),
|
|
2076
|
+
* kind: "developerProduct",
|
|
2077
|
+
* name: "Gem Pack",
|
|
2078
|
+
* };
|
|
2079
|
+
*
|
|
2080
|
+
* expect(input.kind).toBe("developerProduct");
|
|
2081
|
+
* ```
|
|
2082
|
+
*/
|
|
2083
|
+
interface DeveloperProductDesiredInput extends Readonly<DeveloperProductEntry> {
|
|
2084
|
+
/** User-supplied handle, already validated against the `ResourceKey` brand. */
|
|
2085
|
+
readonly key: ResourceKey;
|
|
2086
|
+
/** Discriminator tag for the `ResourceDesiredInput` union. */
|
|
2087
|
+
readonly kind: "developerProduct";
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* Flat tagged input for `buildDesired`. One member per resource kind; future
|
|
2091
|
+
* kinds widen this union as they land.
|
|
2092
|
+
*/
|
|
2093
|
+
type ResourceDesiredInput = DeveloperProductDesiredInput | GamePassDesiredInput | PlaceDesiredInput | UniverseDesiredInput;
|
|
2094
|
+
/**
|
|
2095
|
+
* Turn a resolved `Config` into a flat, tagged list of resource inputs.
|
|
2096
|
+
*
|
|
2097
|
+
* Pure and infallible: validation and per-environment overlay merging
|
|
2098
|
+
* have already happened upstream (typically via `selectEnvironment`), so
|
|
2099
|
+
* every invariant this function relies on is guaranteed by the input
|
|
2100
|
+
* shape. Entries appear in the insertion order of each collection;
|
|
2101
|
+
* `passes` are emitted before `places`.
|
|
2102
|
+
*
|
|
2103
|
+
* @param config - Resolved config returned by `selectEnvironment`.
|
|
2104
|
+
* @returns Flat tagged list ready for `buildDesired`.
|
|
2105
|
+
* @example
|
|
2106
|
+
*
|
|
2107
|
+
* ```ts
|
|
2108
|
+
* import { flattenConfig, selectEnvironment, type Config } from "@bedrock-rbx/core";
|
|
2109
|
+
*
|
|
2110
|
+
* const config: Config = {
|
|
2111
|
+
* environments: {
|
|
2112
|
+
* production: { places: { "start-place": { placeId: "4711" } } },
|
|
2113
|
+
* },
|
|
2114
|
+
* passes: {
|
|
2115
|
+
* "vip-pass": {
|
|
2116
|
+
* description: "Grants VIP perks.",
|
|
2117
|
+
* icon: { "en-us": "assets/vip-icon.png" },
|
|
2118
|
+
* name: "VIP Pass",
|
|
2119
|
+
* price: 500,
|
|
2120
|
+
* },
|
|
2121
|
+
* },
|
|
2122
|
+
* places: { "start-place": { filePath: "places/start.rbxl" } },
|
|
2123
|
+
* };
|
|
2124
|
+
*
|
|
2125
|
+
* const resolved = selectEnvironment(config, "production");
|
|
2126
|
+
* expect(resolved.success).toBeTrue();
|
|
2127
|
+
* if (resolved.success) {
|
|
2128
|
+
* const inputs = flattenConfig(resolved.data);
|
|
2129
|
+
* expect(inputs.map((input) => input.kind)).toEqual(["gamePass", "place"]);
|
|
2130
|
+
* expect(inputs.map((input) => input.key)).toEqual(["vip-pass", "start-place"]);
|
|
2131
|
+
* }
|
|
2132
|
+
* ```
|
|
2133
|
+
*/
|
|
2134
|
+
declare function flattenConfig(config: ResolvedConfig): ReadonlyArray<ResourceDesiredInput>;
|
|
2135
|
+
//#endregion
|
|
2136
|
+
//#region src/core/get-environment.d.ts
|
|
2137
|
+
/**
|
|
2138
|
+
* Failure modes returned by {@link getEnvironment}.
|
|
2139
|
+
*/
|
|
2140
|
+
type GetEnvironmentError = {
|
|
2141
|
+
readonly kind: "missingEnvironment";
|
|
2142
|
+
} | {
|
|
2143
|
+
readonly kind: "multipleEnvironments";
|
|
2144
|
+
readonly values: ReadonlyArray<string>;
|
|
2145
|
+
};
|
|
2146
|
+
/**
|
|
2147
|
+
* Resolve the deploy environment for an override script invocation.
|
|
2148
|
+
*
|
|
2149
|
+
* Reads `--env <name>` from the supplied argv first, falls back to
|
|
2150
|
+
* `BEDROCK_ENVIRONMENT` from the supplied env reader. Returns
|
|
2151
|
+
* `missingEnvironment` when neither is present and `multipleEnvironments`
|
|
2152
|
+
* (with every offending value) when argv contains more than one `--env`
|
|
2153
|
+
* flag. Both inputs default to the running process so override scripts
|
|
2154
|
+
* under `.bedrock/` can call `getEnvironment()` with no arguments.
|
|
2155
|
+
*
|
|
2156
|
+
* @param argv - Argument list to scan for `--env <name>` flags. Defaults to
|
|
2157
|
+
* `process.argv.slice(2)` when omitted.
|
|
2158
|
+
* @param readEnvironment - Reads an environment variable; consulted as a
|
|
2159
|
+
* fallback when no `--env` flag is present. Defaults to a `process.env`
|
|
2160
|
+
* reader when omitted.
|
|
2161
|
+
* @returns `Ok(environment)` on success, `Err(GetEnvironmentError)` otherwise.
|
|
2162
|
+
* @example
|
|
2163
|
+
*
|
|
2164
|
+
* ```ts
|
|
2165
|
+
* import { getEnvironment } from "@bedrock-rbx/core";
|
|
2166
|
+
*
|
|
2167
|
+
* const result = getEnvironment(["--env", "production"], () => undefined);
|
|
2168
|
+
*
|
|
2169
|
+
* expect(result.success).toBeTrue();
|
|
2170
|
+
* if (result.success) {
|
|
2171
|
+
* expect(result.data).toBe("production");
|
|
2172
|
+
* }
|
|
2173
|
+
* ```
|
|
2174
|
+
*/
|
|
2175
|
+
declare function getEnvironment(argv?: ReadonlyArray<string>, readEnvironment?: (name: string) => string | undefined): Result$1<string, GetEnvironmentError>;
|
|
2176
|
+
//#endregion
|
|
2177
|
+
//#region src/core/kinds/module.d.ts
|
|
2178
|
+
/**
|
|
2179
|
+
* Failure surfaced during desired-state preparation. Two variants today:
|
|
2180
|
+
*
|
|
2181
|
+
* - `fileReadFailed`: a kind module's `normalize` could not read a file
|
|
2182
|
+
* the input declared (e.g. An icon path that is missing on disk).
|
|
2183
|
+
* - `iconRemovalRejected`: `validatePlan` saw a kind whose prior current
|
|
2184
|
+
* state recorded an icon that the desired state no longer declares,
|
|
2185
|
+
* and the kind has no documented unset path on the upstream API.
|
|
2186
|
+
*
|
|
2187
|
+
* Both variants carry the offending `key` so the CLI can attribute the
|
|
2188
|
+
* failure to a single resource entry.
|
|
2189
|
+
*
|
|
2190
|
+
* @example
|
|
2191
|
+
*
|
|
2192
|
+
* ```ts
|
|
2193
|
+
* import { asResourceKey, type BuildDesiredError } from "@bedrock-rbx/core";
|
|
2194
|
+
*
|
|
2195
|
+
* const err: BuildDesiredError = {
|
|
2196
|
+
* filePath: "assets/vip-icon.png",
|
|
2197
|
+
* key: asResourceKey("vip-pass"),
|
|
2198
|
+
* kind: "fileReadFailed",
|
|
2199
|
+
* reason: "ENOENT",
|
|
2200
|
+
* };
|
|
2201
|
+
*
|
|
2202
|
+
* expect(err.kind).toBe("fileReadFailed");
|
|
2203
|
+
* ```
|
|
2204
|
+
*/
|
|
2205
|
+
type BuildDesiredError = {
|
|
2206
|
+
/** Path of the file that failed to read. */readonly filePath: string; /** ResourceKey of the input whose file failed to read. */
|
|
2207
|
+
readonly key: ResourceKey; /** Literal discriminator for narrowing. */
|
|
2208
|
+
readonly kind: "fileReadFailed"; /** Human-readable explanation; typically the caught error message. */
|
|
2209
|
+
readonly reason: string;
|
|
2210
|
+
} | {
|
|
2211
|
+
/** ResourceKey of the entry whose icon is being removed. */readonly key: ResourceKey; /** Literal discriminator for narrowing. */
|
|
2212
|
+
readonly kind: "iconRemovalRejected"; /** Human-readable explanation naming the resource and the invariant. */
|
|
2213
|
+
readonly message: string;
|
|
2214
|
+
};
|
|
2215
|
+
/**
|
|
2216
|
+
* I/O surface the shell injects into kind-module `normalize` calls. Carries
|
|
2217
|
+
* only file-reading capability today; new capabilities widen this shape
|
|
2218
|
+
* when a kind module needs them.
|
|
2219
|
+
*
|
|
2220
|
+
* @example
|
|
2221
|
+
*
|
|
2222
|
+
* ```ts
|
|
2223
|
+
* import type { KindIo } from "@bedrock-rbx/core";
|
|
2224
|
+
*
|
|
2225
|
+
* const io: KindIo = {
|
|
2226
|
+
* readFile: async () => new Uint8Array([1, 2, 3]),
|
|
2227
|
+
* };
|
|
2228
|
+
*
|
|
2229
|
+
* expect(io.readFile).toBeFunction();
|
|
2230
|
+
* ```
|
|
2231
|
+
*/
|
|
2232
|
+
interface KindIo {
|
|
2233
|
+
/** Reads file bytes for a given path; rejection becomes a `fileReadFailed` Err. */
|
|
2234
|
+
readonly readFile: (path: string) => Promise<Uint8Array>;
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* Plugin contract for a resource kind: concentrates the per-kind domain
|
|
2238
|
+
* surface (authored entry schema, flatten, pre-I/O normalize, drift
|
|
2239
|
+
* equality) behind one interface that the core `diff` and shell
|
|
2240
|
+
* `buildDesired` functions dispatch through at runtime. Composes with the
|
|
2241
|
+
* `ResourceDriver<K>` port, which stays the I/O half of the kind.
|
|
2242
|
+
*
|
|
2243
|
+
* @template K - The {@link ResourceKind} discriminator this module handles.
|
|
2244
|
+
*
|
|
2245
|
+
* @example
|
|
2246
|
+
*
|
|
2247
|
+
* ```ts
|
|
2248
|
+
* import { type } from "arktype";
|
|
2249
|
+
*
|
|
2250
|
+
* import { asResourceKey, asSha256Hex, type ResourceKindModule } from "@bedrock-rbx/core";
|
|
2251
|
+
*
|
|
2252
|
+
* const stubKind: ResourceKindModule<"gamePass"> = {
|
|
2253
|
+
* kind: "gamePass",
|
|
2254
|
+
* entrySchema: type({
|
|
2255
|
+
* description: "string",
|
|
2256
|
+
* icon: type({ "en-us": "string" }).onUndeclaredKey("reject"),
|
|
2257
|
+
* name: "string",
|
|
2258
|
+
* "price?": "number | undefined",
|
|
2259
|
+
* }),
|
|
2260
|
+
* flatten: () => [],
|
|
2261
|
+
* normalize: async (input) => ({
|
|
2262
|
+
* data: {
|
|
2263
|
+
* description: input.description,
|
|
2264
|
+
* icon: input.icon,
|
|
2265
|
+
* iconFileHashes: {
|
|
2266
|
+
* "en-us": asSha256Hex(
|
|
2267
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
2268
|
+
* ),
|
|
2269
|
+
* },
|
|
2270
|
+
* key: input.key,
|
|
2271
|
+
* kind: "gamePass",
|
|
2272
|
+
* name: input.name,
|
|
2273
|
+
* price: input.price,
|
|
2274
|
+
* },
|
|
2275
|
+
* success: true,
|
|
2276
|
+
* }),
|
|
2277
|
+
* fieldsEqual: (desired, current) => desired.name === current.name,
|
|
2278
|
+
* };
|
|
2279
|
+
*
|
|
2280
|
+
* expect(stubKind.kind).toBe("gamePass");
|
|
2281
|
+
* ```
|
|
2282
|
+
*/
|
|
2283
|
+
interface ResourceKindModule<K extends ResourceKind> {
|
|
2284
|
+
/**
|
|
2285
|
+
* Optional plan-time invariant check called by `validatePlan` for every
|
|
2286
|
+
* `(kind, key)` pair that exists on both sides. Surfaces kind-specific
|
|
2287
|
+
* rejections (e.g. Removing a developer-product icon, which the upstream
|
|
2288
|
+
* API has no documented unset path for) before `diff` runs and before
|
|
2289
|
+
* any apply-side driver I/O is attempted. Kinds without plan-level
|
|
2290
|
+
* invariants omit this hook.
|
|
2291
|
+
*/
|
|
2292
|
+
readonly assertReconcilable?: (current: ResourceCurrentState<K>, desired: DesiredFor<K>) => Result$1<undefined, BuildDesiredError>;
|
|
2293
|
+
/** ArkType schema for the authored entry body of this kind. */
|
|
2294
|
+
readonly entrySchema: Type$1<ResourceEntryByKind[K]>;
|
|
2295
|
+
/**
|
|
2296
|
+
* Managed-field equality. Identity fields (`key`, `kind`, and kind-specific
|
|
2297
|
+
* inputs like `placeId` or `universeId`) are excluded by the
|
|
2298
|
+
* implementation; `diff` treats `true` as "no drift, emit noop".
|
|
2299
|
+
*/
|
|
2300
|
+
fieldsEqual(desired: DesiredFor<K>, current: ResourceCurrentState<K>): boolean;
|
|
2301
|
+
/**
|
|
2302
|
+
* Project a resolved `Config` into a flat, tagged list of this kind's
|
|
2303
|
+
* pre-I/O inputs. Pure and infallible: validation and per-environment
|
|
2304
|
+
* overlay merging have already happened upstream, so every invariant
|
|
2305
|
+
* this function relies on is guaranteed by the input shape.
|
|
2306
|
+
*/
|
|
2307
|
+
flatten(config: ResolvedConfig): ReadonlyArray<InputFor<K>>;
|
|
2308
|
+
/** Discriminator literal for this kind. */
|
|
2309
|
+
readonly kind: K;
|
|
2310
|
+
/**
|
|
2311
|
+
* Layer pre-I/O work (file reads, hashing) onto an input to produce a
|
|
2312
|
+
* branded desired-state record. Rejections from `io.readFile` are caught
|
|
2313
|
+
* and surfaced as `fileReadFailed`.
|
|
2314
|
+
*/
|
|
2315
|
+
normalize(input: InputFor<K>, io: KindIo): Promise<Result$1<DesiredFor<K>, BuildDesiredError>>;
|
|
2316
|
+
}
|
|
2317
|
+
/**
|
|
2318
|
+
* Polymorphic dispatch table keyed by {@link ResourceKind}, mapping each
|
|
2319
|
+
* kind to the {@link ResourceKindModule} that handles its domain surface.
|
|
2320
|
+
* Adding a new kind to `ResourceKind` is a compile error at `KindRegistry`
|
|
2321
|
+
* until a matching entry is supplied, matching how `DriverRegistry`
|
|
2322
|
+
* enforces the same invariant on its I/O half.
|
|
2323
|
+
*
|
|
2324
|
+
* @example
|
|
2325
|
+
*
|
|
2326
|
+
* ```ts
|
|
2327
|
+
* import { defaultKindRegistry, type KindRegistry } from "@bedrock-rbx/core";
|
|
2328
|
+
*
|
|
2329
|
+
* const registry: KindRegistry = defaultKindRegistry;
|
|
2330
|
+
* expect(registry.gamePass.kind).toBe("gamePass");
|
|
2331
|
+
* ```
|
|
2332
|
+
*/
|
|
2333
|
+
type KindRegistry = { [K in ResourceKind]: ResourceKindModule<K> };
|
|
2334
|
+
/**
|
|
2335
|
+
* Desired-state narrowed to a single resource kind.
|
|
2336
|
+
*
|
|
2337
|
+
* @template K - The resource-kind discriminator.
|
|
2338
|
+
*/
|
|
2339
|
+
type DesiredFor<K extends ResourceKind> = Extract<ResourceDesiredState, {
|
|
2340
|
+
readonly kind: K;
|
|
2341
|
+
}>;
|
|
2342
|
+
/**
|
|
2343
|
+
* Flat pre-I/O input narrowed to a single resource kind.
|
|
2344
|
+
*
|
|
2345
|
+
* @template K - The resource-kind discriminator.
|
|
2346
|
+
*/
|
|
2347
|
+
type InputFor<K extends ResourceKind> = Extract<ResourceDesiredInput, {
|
|
2348
|
+
readonly kind: K;
|
|
2349
|
+
}>;
|
|
2350
|
+
//#endregion
|
|
2351
|
+
//#region src/core/icons.d.ts
|
|
2352
|
+
/**
|
|
2353
|
+
* Cost-gate for icon re-uploads. Returns `true` when the locally-hashed
|
|
2354
|
+
* desired icon differs from the hash recorded on the prior current-state
|
|
2355
|
+
* entry, signalling that the driver must re-upload before reconciling.
|
|
2356
|
+
* Returns `false` when the hashes match (no re-upload needed) and when
|
|
2357
|
+
* both sides report no icon.
|
|
2358
|
+
*
|
|
2359
|
+
* The signature takes hash maps directly (not whole-state) so the helper
|
|
2360
|
+
* is independent of any specific resource-kind shape; every icon-bearing
|
|
2361
|
+
* driver projects its own `iconFileHashes` and `outputs.iconFileHashes`
|
|
2362
|
+
* fields before calling.
|
|
2363
|
+
*
|
|
2364
|
+
* @param currentHashes - Hashes recorded on the prior current-state entry.
|
|
2365
|
+
* @param desiredHashes - Hashes layered onto the desired-state entry by
|
|
2366
|
+
* `normalize` from the local icon file's bytes.
|
|
2367
|
+
* @returns `true` when the driver should re-upload the icon.
|
|
2368
|
+
*
|
|
2369
|
+
* @example
|
|
2370
|
+
*
|
|
2371
|
+
* ```ts
|
|
2372
|
+
* import { asSha256Hex, shouldReuploadIcon } from "@bedrock-rbx/core";
|
|
2373
|
+
*
|
|
2374
|
+
* const previous = asSha256Hex(
|
|
2375
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
2376
|
+
* );
|
|
2377
|
+
* const fresh = asSha256Hex(
|
|
2378
|
+
* "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
|
|
2379
|
+
* );
|
|
2380
|
+
*
|
|
2381
|
+
* expect(shouldReuploadIcon({ "en-us": previous }, { "en-us": previous })).toBe(false);
|
|
2382
|
+
* expect(shouldReuploadIcon({ "en-us": previous }, { "en-us": fresh })).toBe(true);
|
|
2383
|
+
* ```
|
|
2384
|
+
*/
|
|
2385
|
+
declare function shouldReuploadIcon(currentHashes: Record<"en-us", Sha256Hex> | undefined, desiredHashes: Record<"en-us", Sha256Hex> | undefined): boolean;
|
|
2386
|
+
//#endregion
|
|
2387
|
+
//#region src/core/kinds/index.d.ts
|
|
2388
|
+
/**
|
|
2389
|
+
* Default {@link KindRegistry} composing every resource kind bedrock ships
|
|
2390
|
+
* out of the box. Iteration order (`gamePass`, `place`, `universe`,
|
|
2391
|
+
* `developerProduct`) matches the order `flattenConfig` emits entries
|
|
2392
|
+
* today, preserving the observable order of generated operations.
|
|
2393
|
+
*
|
|
2394
|
+
* @example
|
|
2395
|
+
*
|
|
2396
|
+
* ```ts
|
|
2397
|
+
* import { defaultKindRegistry } from "@bedrock-rbx/core";
|
|
2398
|
+
*
|
|
2399
|
+
* expect(defaultKindRegistry.gamePass.kind).toBe("gamePass");
|
|
2400
|
+
* expect(defaultKindRegistry.place.kind).toBe("place");
|
|
2401
|
+
* expect(defaultKindRegistry.universe.kind).toBe("universe");
|
|
2402
|
+
* expect(defaultKindRegistry.developerProduct.kind).toBe("developerProduct");
|
|
2403
|
+
* ```
|
|
2404
|
+
*/
|
|
2405
|
+
declare const defaultKindRegistry: KindRegistry;
|
|
2406
|
+
//#endregion
|
|
2407
|
+
//#region src/core/migrate/migration-report.d.ts
|
|
2408
|
+
/**
|
|
2409
|
+
* Per-environment in-memory state snapshot map keyed by environment name.
|
|
2410
|
+
*
|
|
2411
|
+
* `Record` rather than the PRD-suggested `Map` so the field survives
|
|
2412
|
+
* `JSON.stringify` for downstream logging and parallel-iterates cleanly
|
|
2413
|
+
* with `Config.environments` (which is itself a `Record`).
|
|
2414
|
+
*/
|
|
2415
|
+
type StatesByEnvironment = Readonly<Record<string, BedrockState>>;
|
|
2416
|
+
/**
|
|
2417
|
+
* Aggregate counts for the four `MigrationWarning` kinds. Computed by
|
|
2418
|
+
* folding `MigrationReport.warnings`; lets a CI gate skim totals without
|
|
2419
|
+
* iterating every entry. All fields are zero on a clean migration.
|
|
2420
|
+
*/
|
|
2421
|
+
interface MigrationSummary {
|
|
2422
|
+
/** Number of `ambiguous` warnings emitted. */
|
|
2423
|
+
readonly ambiguousCount: number;
|
|
2424
|
+
/** Number of `blocked` warnings emitted. */
|
|
2425
|
+
readonly blockedCount: number;
|
|
2426
|
+
/** Number of `deferred` warnings emitted. */
|
|
2427
|
+
readonly deferredCount: number;
|
|
2428
|
+
/** Number of `interpretive` warnings emitted. */
|
|
2429
|
+
readonly interpretiveCount: number;
|
|
2430
|
+
}
|
|
2431
|
+
/**
|
|
2432
|
+
* Discriminated union describing one observation the migrator made about a
|
|
2433
|
+
* Mantle field that did not flow straight into bedrock config or state.
|
|
2434
|
+
*
|
|
2435
|
+
* - `deferred` - bedrock plans to support the field once the matching
|
|
2436
|
+
* resource kind ships; the migration is non-destructive.
|
|
2437
|
+
* - `blocked` - no Open Cloud writable endpoint exists; Mantle was using a
|
|
2438
|
+
* cookie or legacy API that bedrock cannot call.
|
|
2439
|
+
* - `interpretive` - the migrator applied a documented mapping rule
|
|
2440
|
+
* (cross-field fold, list-to-flag rewrite, URL-domain dispatch). Each
|
|
2441
|
+
* rule names the bedrock-side path it produced and the rule it followed
|
|
2442
|
+
* so the user can audit.
|
|
2443
|
+
* - `ambiguous` - the field is mappable but unsafe to act on without
|
|
2444
|
+
* user input; the migrator carries the hint forward instead of guessing.
|
|
2445
|
+
*
|
|
2446
|
+
* Every variant carries `mantlePath` rooted at the environment so the
|
|
2447
|
+
* report is searchable (for example
|
|
2448
|
+
* `production.experienceConfiguration_singleton.genre`).
|
|
2449
|
+
*/
|
|
2450
|
+
type MigrationWarning = {
|
|
2451
|
+
readonly bedrockPath: string;
|
|
2452
|
+
readonly kind: "interpretive";
|
|
2453
|
+
readonly mantlePath: string;
|
|
2454
|
+
readonly rule: string;
|
|
2455
|
+
} | {
|
|
2456
|
+
readonly hint: string;
|
|
2457
|
+
readonly kind: "ambiguous";
|
|
2458
|
+
readonly mantlePath: string;
|
|
2459
|
+
} | {
|
|
2460
|
+
readonly kind: "blocked";
|
|
2461
|
+
readonly mantlePath: string;
|
|
2462
|
+
readonly reason: string;
|
|
2463
|
+
} | {
|
|
2464
|
+
readonly kind: "deferred";
|
|
2465
|
+
readonly mantlePath: string;
|
|
2466
|
+
readonly reason: string;
|
|
2467
|
+
};
|
|
2468
|
+
/**
|
|
2469
|
+
* Failure surfaced by `migrateMantleState`. Plain-data discriminated
|
|
2470
|
+
* union; narrow on `kind` rather than using `instanceof`.
|
|
2471
|
+
*
|
|
2472
|
+
* - `stateFileNotFound` - `deps.readFile` threw with `code: "ENOENT"`;
|
|
2473
|
+
* the file does not exist at the supplied path. Permission failures
|
|
2474
|
+
* (`EACCES`, `EPERM`) and other I/O errors are re-thrown rather than
|
|
2475
|
+
* wrapped here, so callers see the original code on the rejection.
|
|
2476
|
+
* - `stateParseFailed` - the YAML parser refused the file's contents.
|
|
2477
|
+
* - `unsupportedMantleStateVersion` - the parsed file's `version` field is
|
|
2478
|
+
* not one of the values in `supported`. V0.1 supports `"6"` only; older
|
|
2479
|
+
* versions need to be upgraded with any recent Mantle release first.
|
|
2480
|
+
* - `primaryEnvironmentRequired` - the input has more than one environment
|
|
2481
|
+
* and `deps.primaryEnvironment` was not supplied. The migrator refuses
|
|
2482
|
+
* to silently pick a winner.
|
|
2483
|
+
* - `primaryEnvironmentNotFound` - `deps.primaryEnvironment` does not match
|
|
2484
|
+
* any environment in the input.
|
|
2485
|
+
* - `internalError` - the migrator's own emitted config failed
|
|
2486
|
+
* `validateConfig`; `cause` carries the `ConfigError` so callers can
|
|
2487
|
+
* inspect each `validationFailed` issue. Defensive bug catcher that
|
|
2488
|
+
* callers should never see in practice.
|
|
2489
|
+
*/
|
|
2490
|
+
type MigrateError = {
|
|
2491
|
+
readonly available: ReadonlyArray<string>;
|
|
2492
|
+
readonly kind: "primaryEnvironmentNotFound";
|
|
2493
|
+
readonly primary: string;
|
|
2494
|
+
} | {
|
|
2495
|
+
readonly available: ReadonlyArray<string>;
|
|
2496
|
+
readonly kind: "primaryEnvironmentRequired";
|
|
2497
|
+
} | {
|
|
2498
|
+
readonly cause: ConfigError;
|
|
2499
|
+
readonly kind: "internalError";
|
|
2500
|
+
readonly reason: string;
|
|
2501
|
+
} | {
|
|
2502
|
+
readonly found: string;
|
|
2503
|
+
readonly kind: "unsupportedMantleStateVersion";
|
|
2504
|
+
readonly supported: ReadonlyArray<string>;
|
|
2505
|
+
} | {
|
|
2506
|
+
readonly kind: "stateFileNotFound";
|
|
2507
|
+
readonly path: string;
|
|
2508
|
+
} | {
|
|
2509
|
+
readonly kind: "stateParseFailed";
|
|
2510
|
+
readonly path: string;
|
|
2511
|
+
readonly reason: string;
|
|
2512
|
+
};
|
|
2513
|
+
/**
|
|
2514
|
+
* Result returned by a successful `migrateMantleState` call.
|
|
2515
|
+
*
|
|
2516
|
+
* `config` is the bedrock-shape projection of the Mantle state file,
|
|
2517
|
+
* already validated against the runtime schema (a failure to validate
|
|
2518
|
+
* surfaces as `MigrateError.internalError`, not as a returned report).
|
|
2519
|
+
*
|
|
2520
|
+
* `configFileContent` is the same data rendered as TypeScript source
|
|
2521
|
+
* (`defineConfig({...})`) so the caller can write it straight to disk
|
|
2522
|
+
* without re-serializing. `loadConfig` round-trips it cleanly.
|
|
2523
|
+
*
|
|
2524
|
+
* `statesByEnvironment` carries one in-memory `BedrockState` per
|
|
2525
|
+
* environment from the input. Truthful per environment (no factorization)
|
|
2526
|
+
* so `bedrock deploy --env=<env>` produces zero ops on first run.
|
|
2527
|
+
*
|
|
2528
|
+
* `warnings` and `summary` describe what the migrator did *not* migrate
|
|
2529
|
+
* verbatim, classified for triage. The skeleton emits no warnings.
|
|
2530
|
+
*/
|
|
2531
|
+
interface MigrationReport {
|
|
2532
|
+
/** Validated bedrock config built from the Mantle state file. */
|
|
2533
|
+
readonly config: Config;
|
|
2534
|
+
/** Same `config` rendered as TypeScript source the caller can write to disk. */
|
|
2535
|
+
readonly configFileContent: string;
|
|
2536
|
+
/** One `BedrockState` per environment in the input, keyed by environment name. */
|
|
2537
|
+
readonly statesByEnvironment: StatesByEnvironment;
|
|
2538
|
+
/** Aggregate counts of `warnings` by kind. */
|
|
2539
|
+
readonly summary: MigrationSummary;
|
|
2540
|
+
/** One entry per non-trivial mapping or skipped Mantle field. */
|
|
2541
|
+
readonly warnings: ReadonlyArray<MigrationWarning>;
|
|
2542
|
+
}
|
|
2543
|
+
//#endregion
|
|
2544
|
+
//#region src/core/resolve-state-config.d.ts
|
|
2545
|
+
/**
|
|
2546
|
+
* Failure surfaced when no `StateConfig` is configured for the requested
|
|
2547
|
+
* environment. The shell layer wraps this in a `DeployError` when default
|
|
2548
|
+
* state-port construction is requested but the project has not declared
|
|
2549
|
+
* where state should live.
|
|
2550
|
+
*/
|
|
2551
|
+
interface StateNotConfiguredError {
|
|
2552
|
+
/** Environment that the resolver was called against. */
|
|
2553
|
+
readonly environment: string;
|
|
2554
|
+
/** Literal discriminator for narrowing. */
|
|
2555
|
+
readonly kind: "stateNotConfigured";
|
|
2556
|
+
}
|
|
2557
|
+
/**
|
|
2558
|
+
* Minimal structural input the state resolver needs. Both `Config`
|
|
2559
|
+
* (pre-merge, discriminated XOR union) and `ResolvedConfig` (post-merge)
|
|
2560
|
+
* satisfy this shape, so callers can route either in without coupling
|
|
2561
|
+
* the resolver to the discriminated-union arms.
|
|
2562
|
+
*/
|
|
2563
|
+
interface StateResolutionInputs {
|
|
2564
|
+
readonly environments: Record<string, undefined | {
|
|
2565
|
+
readonly state?: StateConfig;
|
|
2566
|
+
}>;
|
|
2567
|
+
readonly state?: StateConfig;
|
|
2568
|
+
}
|
|
2569
|
+
/**
|
|
2570
|
+
* Pick the `StateConfig` that applies to `environment`. Per-environment
|
|
2571
|
+
* overrides win over the root block; if neither is present, returns
|
|
2572
|
+
* `Err(stateNotConfigured)` so the deploy boundary can surface a typed
|
|
2573
|
+
* error instead of silently falling back.
|
|
2574
|
+
*
|
|
2575
|
+
* @param config - Validated project config.
|
|
2576
|
+
* @param environment - Target environment name.
|
|
2577
|
+
* @returns The resolved `StateConfig`, or `Err(stateNotConfigured)` when
|
|
2578
|
+
* neither the environment override nor the root block is set.
|
|
2579
|
+
* @example
|
|
2580
|
+
*
|
|
2581
|
+
* ```ts
|
|
2582
|
+
* import { resolveStateConfig } from "@bedrock-rbx/core";
|
|
2583
|
+
*
|
|
2584
|
+
* const result = resolveStateConfig(
|
|
2585
|
+
* {
|
|
2586
|
+
* state: { backend: "gist", gistId: "root-gist" },
|
|
2587
|
+
* environments: {
|
|
2588
|
+
* production: { state: { backend: "gist", gistId: "prod-gist" } },
|
|
2589
|
+
* },
|
|
2590
|
+
* },
|
|
2591
|
+
* "production",
|
|
2592
|
+
* );
|
|
2593
|
+
*
|
|
2594
|
+
* expect(result.success).toBeTrue();
|
|
2595
|
+
* if (result.success) {
|
|
2596
|
+
* expect(result.data).toContainEntry(["gistId", "prod-gist"]);
|
|
2597
|
+
* }
|
|
2598
|
+
* ```
|
|
2599
|
+
*/
|
|
2600
|
+
declare function resolveStateConfig(config: StateResolutionInputs, environment: string): Result$1<StateConfig, StateNotConfiguredError>;
|
|
2601
|
+
//#endregion
|
|
2602
|
+
//#region src/core/select-environment.d.ts
|
|
2603
|
+
/**
|
|
2604
|
+
* Failure surfaced when `selectEnvironment` is asked for an environment
|
|
2605
|
+
* name that is not a key of `config.environments`. Carries the list of
|
|
2606
|
+
* declared names so callers can render a "did you mean?" hint or a
|
|
2607
|
+
* close-match suggestion.
|
|
2608
|
+
*/
|
|
2609
|
+
interface UnknownEnvironmentError {
|
|
2610
|
+
/** Environment names that the config actually declared. */
|
|
2611
|
+
readonly declared: ReadonlyArray<string>;
|
|
2612
|
+
/** Environment name the caller asked for. */
|
|
2613
|
+
readonly environment: string;
|
|
2614
|
+
/** Literal discriminator for narrowing. */
|
|
2615
|
+
readonly kind: "unknownEnvironment";
|
|
2616
|
+
}
|
|
2617
|
+
/**
|
|
2618
|
+
* Failure surfaced when a merged place entry is missing a required field.
|
|
2619
|
+
* Two paths reach this error: a root place declared without a matching
|
|
2620
|
+
* per-environment overlay supplying `placeId`, and an overlay-only place
|
|
2621
|
+
* declared under `environments.X.places` with no matching root entry to
|
|
2622
|
+
* supply `filePath`. Surfacing both at the resolution boundary attributes
|
|
2623
|
+
* the missing field to the offending entry's key instead of letting
|
|
2624
|
+
* `buildDesired` crash with a generic `fileReadFailed` later on.
|
|
2625
|
+
*/
|
|
2626
|
+
interface IncompletePlaceEntryError {
|
|
2627
|
+
/** ResourceKey of the place entry that is missing a required field. */
|
|
2628
|
+
readonly key: string;
|
|
2629
|
+
/** Environment whose overlay was projected onto the config. */
|
|
2630
|
+
readonly environment: string;
|
|
2631
|
+
/** Literal discriminator for narrowing. */
|
|
2632
|
+
readonly kind: "incompletePlaceEntry";
|
|
2633
|
+
/** Field that the merged entry lacks. */
|
|
2634
|
+
readonly missingField: "filePath" | "placeId";
|
|
2635
|
+
}
|
|
2636
|
+
/**
|
|
2637
|
+
* Failure surfaced when a merged `universe` block lacks `universeId`.
|
|
2638
|
+
* The schema-level XOR rule normally prevents this by requiring
|
|
2639
|
+
* `universeId` either at the root or on every per-environment overlay;
|
|
2640
|
+
* this error remains as a typed safety net for callers that bypass
|
|
2641
|
+
* `validateConfig` and hand a `Config` to `selectEnvironment` directly.
|
|
2642
|
+
*/
|
|
2643
|
+
interface IncompleteUniverseEntryError {
|
|
2644
|
+
/** Environment whose overlay was projected onto the config. */
|
|
2645
|
+
readonly environment: string;
|
|
2646
|
+
/** Literal discriminator for narrowing. */
|
|
2647
|
+
readonly kind: "incompleteUniverseEntry";
|
|
2648
|
+
/** Field that the merged entry lacks. V1 only surfaces `"universeId"`. */
|
|
2649
|
+
readonly missingField: "universeId";
|
|
2650
|
+
}
|
|
2651
|
+
/** Failure modes returned by {@link selectEnvironment}. */
|
|
2652
|
+
type SelectEnvironmentError = IncompletePlaceEntryError | IncompleteUniverseEntryError | UnknownEnvironmentError;
|
|
2653
|
+
/**
|
|
2654
|
+
* Project a validated `Config` onto a single environment. Looks up the
|
|
2655
|
+
* matching `environments[environment]` entry, deep-merges its resource
|
|
2656
|
+
* overlay (`passes`, `places`, `universe`) over the root config via defu,
|
|
2657
|
+
* and applies the env-level state override when present (the env entry's
|
|
2658
|
+
* `state` field wins; otherwise the root `state` flows through).
|
|
2659
|
+
*
|
|
2660
|
+
* Pure: no I/O. Returns a `ResolvedConfig` ready to feed into downstream
|
|
2661
|
+
* functions (`flattenConfig`, `buildDefaultRegistry`, `resolveStateConfig`).
|
|
2662
|
+
* The post-merge view promotes `places` from `Record<string, PlaceEntry>`
|
|
2663
|
+
* (root: file-paths only) to `Record<string, ResolvedPlaceEntry>` (root +
|
|
2664
|
+
* overlay merged). `environments` and `extends` are passed through
|
|
2665
|
+
* unchanged because they preserve the shape relationship to `Config`;
|
|
2666
|
+
* downstream consumers do not read them post-merge.
|
|
2667
|
+
*
|
|
2668
|
+
* Defu's merge semantics are deliberate: keyed-map collections merge by
|
|
2669
|
+
* key (so a place declared in both root and overlay produces a single
|
|
2670
|
+
* entry whose overlay-supplied fields win), and `null` / `undefined` in
|
|
2671
|
+
* the overlay are skipped (so the overlay never deletes a root field).
|
|
2672
|
+
* State has its own resolution path (a single replacement, not a
|
|
2673
|
+
* deep-merge) because it is a tagged union: a deep-merge of
|
|
2674
|
+
* `{ backend: "s3" }` over `{ backend: "gist", gistId }` would produce
|
|
2675
|
+
* a malformed `{ backend: "s3", gistId }`.
|
|
2676
|
+
*
|
|
2677
|
+
* State is left absent when neither the env override nor the root block
|
|
2678
|
+
* provides one. Callers that require a resolved `StateConfig` should
|
|
2679
|
+
* route through `resolveStateConfig` or `buildStatePort`; the absent
|
|
2680
|
+
* case surfaces as a typed `stateNotConfigured` there.
|
|
2681
|
+
*
|
|
2682
|
+
* Limitation in v1: a per-environment universe overlay that introduces a
|
|
2683
|
+
* brand-new universe block may still have optional fields missing, since
|
|
2684
|
+
* the overlay type only requires the identity-bearing key. The resolver
|
|
2685
|
+
* surfaces the entry as-is; the universe driver reports the missing
|
|
2686
|
+
* field when it tries to consume the entry. Universe is a singleton with
|
|
2687
|
+
* 20+ optional fields, so the same `incompletePlaceEntry`-style validation
|
|
2688
|
+
* is deferred to a separate follow-up.
|
|
2689
|
+
*
|
|
2690
|
+
* When the project sets a `displayNamePrefix` (or omits it, in which case
|
|
2691
|
+
* prefixing defaults to enabled) and the chosen environment declares a
|
|
2692
|
+
* non-empty `label`, the resolver renders the configured template via
|
|
2693
|
+
* `renderDisplayNamePrefix` and prepends the result to `universe.displayName`
|
|
2694
|
+
* and every declared place `displayName`. An undeclared `displayName`, an
|
|
2695
|
+
* empty/absent label, or an explicit `displayNamePrefix.enabled: false` all
|
|
2696
|
+
* skip prefixing for the affected fields.
|
|
2697
|
+
*
|
|
2698
|
+
* @example
|
|
2699
|
+
*
|
|
2700
|
+
* ```ts
|
|
2701
|
+
* import { selectEnvironment } from "@bedrock-rbx/core";
|
|
2702
|
+
* import type { Config } from "@bedrock-rbx/core/config";
|
|
2703
|
+
*
|
|
2704
|
+
* const config: Config = {
|
|
2705
|
+
* environments: {
|
|
2706
|
+
* production: { universe: { universeId: "999" } },
|
|
2707
|
+
* },
|
|
2708
|
+
* state: { backend: "gist", gistId: "abc123" },
|
|
2709
|
+
* universe: { voiceChatEnabled: true },
|
|
2710
|
+
* };
|
|
2711
|
+
*
|
|
2712
|
+
* const result = selectEnvironment(config, "production");
|
|
2713
|
+
*
|
|
2714
|
+
* expect(result.success).toBeTrue();
|
|
2715
|
+
* if (result.success) {
|
|
2716
|
+
* expect(result.data.universe?.universeId).toBe("999");
|
|
2717
|
+
* expect(result.data.universe?.voiceChatEnabled).toBeTrue();
|
|
2718
|
+
* expect(result.data.state?.backend).toBe("gist");
|
|
2719
|
+
* }
|
|
2720
|
+
* ```
|
|
2721
|
+
*
|
|
2722
|
+
* @param config - Validated project config carrying at least one
|
|
2723
|
+
* environment under `environments`.
|
|
2724
|
+
* @param environment - Environment name to project onto. Must be a key
|
|
2725
|
+
* of `config.environments`.
|
|
2726
|
+
* @returns `Ok(ResolvedConfig)` with the merged resource fields and the
|
|
2727
|
+
* resolved state, or `Err(SelectEnvironmentError)` describing why the
|
|
2728
|
+
* projection failed.
|
|
2729
|
+
*/
|
|
2730
|
+
declare function selectEnvironment(config: Config, environment: string): Result$1<ResolvedConfig, SelectEnvironmentError>;
|
|
2731
|
+
//#endregion
|
|
2732
|
+
//#region src/core/state-file.d.ts
|
|
2733
|
+
/**
|
|
2734
|
+
* Serialize a {@link BedrockState} to the on-disk JSON representation used by
|
|
2735
|
+
* state-port adapters.
|
|
2736
|
+
*
|
|
2737
|
+
* The on-disk shape wraps the in-memory state with a
|
|
2738
|
+
* `$bedrock: { version: N }` envelope so that a future breaking change to the
|
|
2739
|
+
* schema can be detected and rejected at parse time rather than silently
|
|
2740
|
+
* accepted. The top-level `version` field is not duplicated on disk.
|
|
2741
|
+
*
|
|
2742
|
+
* @example
|
|
2743
|
+
*
|
|
2744
|
+
* ```ts
|
|
2745
|
+
* import { serializeStateFile, type BedrockState } from "@bedrock-rbx/core";
|
|
2746
|
+
*
|
|
2747
|
+
* const state: BedrockState = {
|
|
2748
|
+
* environment: "production",
|
|
2749
|
+
* resources: [],
|
|
2750
|
+
* version: 1,
|
|
2751
|
+
* };
|
|
2752
|
+
*
|
|
2753
|
+
* const wire = serializeStateFile(state);
|
|
2754
|
+
* expect(JSON.parse(wire)).toStrictEqual({
|
|
2755
|
+
* $bedrock: { version: 1 },
|
|
2756
|
+
* environment: "production",
|
|
2757
|
+
* resources: [],
|
|
2758
|
+
* });
|
|
2759
|
+
* ```
|
|
2760
|
+
*
|
|
2761
|
+
* @param state - The in-memory state snapshot to serialize.
|
|
2762
|
+
* @returns A pretty-printed JSON string ready to hand to a state adapter's write method.
|
|
2763
|
+
*/
|
|
2764
|
+
declare function serializeStateFile(state: BedrockState): string;
|
|
2765
|
+
/**
|
|
2766
|
+
* Parse a raw on-disk state file into a {@link BedrockState}.
|
|
2767
|
+
*
|
|
2768
|
+
* A backend that reports "no state file for this environment yet" must pass
|
|
2769
|
+
* `undefined`: that distinguishes a legitimate first deploy from a file that
|
|
2770
|
+
* exists but cannot be trusted.
|
|
2771
|
+
*
|
|
2772
|
+
* @example
|
|
2773
|
+
*
|
|
2774
|
+
* ```ts
|
|
2775
|
+
* import { parseStateFile } from "@bedrock-rbx/core";
|
|
2776
|
+
*
|
|
2777
|
+
* const freshStart = parseStateFile(undefined, "gist:abc123/state.production.json");
|
|
2778
|
+
* expect(freshStart.success).toBeTrue();
|
|
2779
|
+
* if (freshStart.success) {
|
|
2780
|
+
* expect(freshStart.data).toBeUndefined();
|
|
2781
|
+
* }
|
|
2782
|
+
* ```
|
|
2783
|
+
*
|
|
2784
|
+
* @param raw - Raw file contents as a string, or `undefined` when the
|
|
2785
|
+
* backend reports no file exists yet.
|
|
2786
|
+
* @param file - Adapter-specific identifier included in any `StateError`
|
|
2787
|
+
* surfaced during parsing.
|
|
2788
|
+
* @returns `Ok(undefined)` for a missing file, `Ok(state)` for a parseable
|
|
2789
|
+
* file, or `Err(StateError)` for anything that cannot be trusted.
|
|
2790
|
+
*/
|
|
2791
|
+
declare function parseStateFile(raw: string | undefined, file: string): Result$1<BedrockState | undefined, StateError>;
|
|
2792
|
+
//#endregion
|
|
2793
|
+
//#region src/core/validate-plan.d.ts
|
|
2794
|
+
/**
|
|
2795
|
+
* Plan-time invariant check that runs after `buildDesired` and before
|
|
2796
|
+
* `diff`. Walks paired `(kind, key)` entries and dispatches to each
|
|
2797
|
+
* kind module's optional `assertReconcilable` hook so kind-specific
|
|
2798
|
+
* rejections (e.g. Removing a developer-product icon, which the upstream
|
|
2799
|
+
* API has no documented unset path for) surface as typed errors before
|
|
2800
|
+
* `diff` runs and before any apply-side driver I/O is attempted.
|
|
2801
|
+
*
|
|
2802
|
+
* Pure and synchronous. Current-only entries (no matching desired) are
|
|
2803
|
+
* ignored: their reconciliation is `diff`'s concern, not this seam's.
|
|
2804
|
+
*
|
|
2805
|
+
* @param desired - Desired state from `buildDesired`.
|
|
2806
|
+
* @param current - Prior current state from the state port.
|
|
2807
|
+
* @returns `Ok(undefined)` when every paired entry passes its kind-level
|
|
2808
|
+
* reconcilability check, or the first `Err` returned by a hook.
|
|
2809
|
+
*
|
|
2810
|
+
* @example
|
|
2811
|
+
*
|
|
2812
|
+
* ```ts
|
|
2813
|
+
* import { asResourceKey, asRobloxAssetId, asSha256Hex, validatePlan } from "@bedrock-rbx/core";
|
|
2814
|
+
*
|
|
2815
|
+
* const result = validatePlan(
|
|
2816
|
+
* [
|
|
2817
|
+
* {
|
|
2818
|
+
* description: "Stocks the player up with 1,000 premium gems.",
|
|
2819
|
+
* isRegionalPricingEnabled: undefined,
|
|
2820
|
+
* key: asResourceKey("gem-pack"),
|
|
2821
|
+
* kind: "developerProduct",
|
|
2822
|
+
* name: "Gem Pack",
|
|
2823
|
+
* price: undefined,
|
|
2824
|
+
* storePageEnabled: undefined,
|
|
2825
|
+
* },
|
|
2826
|
+
* ],
|
|
2827
|
+
* [
|
|
2828
|
+
* {
|
|
2829
|
+
* description: "Stocks the player up with 1,000 premium gems.",
|
|
2830
|
+
* icon: { "en-us": "assets/gem-pack.png" },
|
|
2831
|
+
* iconFileHashes: {
|
|
2832
|
+
* "en-us": asSha256Hex(
|
|
2833
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
2834
|
+
* ),
|
|
2835
|
+
* },
|
|
2836
|
+
* isRegionalPricingEnabled: undefined,
|
|
2837
|
+
* key: asResourceKey("gem-pack"),
|
|
2838
|
+
* kind: "developerProduct",
|
|
2839
|
+
* name: "Gem Pack",
|
|
2840
|
+
* outputs: { productId: asRobloxAssetId("9876543210") },
|
|
2841
|
+
* price: undefined,
|
|
2842
|
+
* storePageEnabled: undefined,
|
|
2843
|
+
* },
|
|
2844
|
+
* ],
|
|
2845
|
+
* );
|
|
2846
|
+
*
|
|
2847
|
+
* expect(result.success).toBeFalse();
|
|
2848
|
+
* if (!result.success) {
|
|
2849
|
+
* expect(result.err.kind).toBe("iconRemovalRejected");
|
|
2850
|
+
* }
|
|
2851
|
+
* ```
|
|
2852
|
+
*/
|
|
2853
|
+
declare function validatePlan(desired: ReadonlyArray<ResourceDesiredState>, current: ReadonlyArray<ResourceCurrentState>): Result$1<undefined, BuildDesiredError>;
|
|
2854
|
+
//#endregion
|
|
2855
|
+
//#region src/shell/apply-ops.d.ts
|
|
2856
|
+
/**
|
|
2857
|
+
* Failure surfaced by `applyOps` when an operation cannot be applied.
|
|
2858
|
+
* Plain-data discriminated union; narrow on `kind`, do not `instanceof` it.
|
|
2859
|
+
*
|
|
2860
|
+
* `appliedSoFar` carries the driver outputs from operations that succeeded
|
|
2861
|
+
* before the failing one, in dispatched order. Callers persist this so a
|
|
2862
|
+
* follow-up reconcile does not duplicate Roblox-side resources that have
|
|
2863
|
+
* already been created or updated.
|
|
2864
|
+
*
|
|
2865
|
+
* @example
|
|
2866
|
+
*
|
|
2867
|
+
* ```ts
|
|
2868
|
+
* import { asResourceKey, type ApplyError } from "@bedrock-rbx/core";
|
|
2869
|
+
*
|
|
2870
|
+
* function describe(err: ApplyError): string {
|
|
2871
|
+
* switch (err.kind) {
|
|
2872
|
+
* case "driverFailure": {
|
|
2873
|
+
* return `driver failed for ${err.key}: ${err.cause.message}`;
|
|
2874
|
+
* }
|
|
2875
|
+
* case "updateUnsupported": {
|
|
2876
|
+
* return `update not supported for ${err.key}`;
|
|
2877
|
+
* }
|
|
2878
|
+
* }
|
|
2879
|
+
* }
|
|
2880
|
+
*
|
|
2881
|
+
* const err: ApplyError = {
|
|
2882
|
+
* key: asResourceKey("vip-pass"),
|
|
2883
|
+
* appliedSoFar: [],
|
|
2884
|
+
* kind: "updateUnsupported",
|
|
2885
|
+
* };
|
|
2886
|
+
*
|
|
2887
|
+
* expect(describe(err)).toBe("update not supported for vip-pass");
|
|
2888
|
+
* ```
|
|
2889
|
+
*/
|
|
2890
|
+
type ApplyError = {
|
|
2891
|
+
readonly appliedSoFar: ReadonlyArray<ResourceCurrentState>;
|
|
2892
|
+
readonly cause: OpenCloudError$1;
|
|
2893
|
+
readonly key: ResourceKey;
|
|
2894
|
+
readonly kind: "driverFailure";
|
|
2895
|
+
} | {
|
|
2896
|
+
readonly appliedSoFar: ReadonlyArray<ResourceCurrentState>;
|
|
2897
|
+
readonly key: ResourceKey;
|
|
2898
|
+
readonly kind: "updateUnsupported";
|
|
2899
|
+
};
|
|
2900
|
+
/**
|
|
2901
|
+
* Dispatch each reconciliation operation to the matching resource driver
|
|
2902
|
+
* with first-fail semantics: on the first `Err` (driver failure or
|
|
2903
|
+
* `updateUnsupported`), the remaining operations are skipped and the error
|
|
2904
|
+
* is returned verbatim.
|
|
2905
|
+
*
|
|
2906
|
+
* Behaviour:
|
|
2907
|
+
* - `create` operations are routed to `registry[op.desired.kind].create`.
|
|
2908
|
+
* - `update` operations are routed to `registry[op.desired.kind].update`
|
|
2909
|
+
* when the driver exposes it; otherwise they short-circuit to an
|
|
2910
|
+
* `updateUnsupported` Err without invoking the driver.
|
|
2911
|
+
* - `noop` operations are skipped entirely (no I/O, no dispatch).
|
|
2912
|
+
*
|
|
2913
|
+
* On success the returned array carries the driver outputs for every
|
|
2914
|
+
* non-noop op, in dispatched order. Noops are not represented; callers
|
|
2915
|
+
* needing a full post-apply snapshot merge with the pre-apply current
|
|
2916
|
+
* state keyed by `ResourceKey`.
|
|
2917
|
+
*
|
|
2918
|
+
* @param ops - Reconciliation operations produced by `diff`, applied in order.
|
|
2919
|
+
* @param registry - Per-kind driver table; dispatch uses `op.desired.kind` as the index.
|
|
2920
|
+
* @returns `Ok(state)` when every operation succeeds, where `state` holds
|
|
2921
|
+
* driver outputs for each non-noop op in dispatched order; or the first
|
|
2922
|
+
* failure encountered.
|
|
2923
|
+
* @throws Whatever the dispatched driver rejects with outside its `Result`
|
|
2924
|
+
* return. A driver whose injected I/O (file reads, network calls, etc.)
|
|
2925
|
+
* throws will surface that rejection here rather than translating it into
|
|
2926
|
+
* a `Result` failure; wrap the call site in a try/catch when drivers are
|
|
2927
|
+
* not trusted to contain their own rejections.
|
|
2928
|
+
* @example
|
|
2929
|
+
*
|
|
2930
|
+
* ```ts
|
|
2931
|
+
* import {
|
|
2932
|
+
* applyOps,
|
|
2933
|
+
* asResourceKey,
|
|
2934
|
+
* asRobloxAssetId,
|
|
2935
|
+
* asSha256Hex,
|
|
2936
|
+
* type DriverRegistry,
|
|
2937
|
+
* type Operation,
|
|
2938
|
+
* } from "@bedrock-rbx/core";
|
|
2939
|
+
*
|
|
2940
|
+
* const registry: DriverRegistry = {
|
|
2941
|
+
* gamePass: {
|
|
2942
|
+
* async create(desired) {
|
|
2943
|
+
* return {
|
|
2944
|
+
* data: {
|
|
2945
|
+
* ...desired,
|
|
2946
|
+
* outputs: {
|
|
2947
|
+
* assetId: asRobloxAssetId("9876543210"),
|
|
2948
|
+
* iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
|
|
2949
|
+
* },
|
|
2950
|
+
* },
|
|
2951
|
+
* success: true,
|
|
2952
|
+
* };
|
|
2953
|
+
* },
|
|
2954
|
+
* },
|
|
2955
|
+
* place: {
|
|
2956
|
+
* async create(desired) {
|
|
2957
|
+
* return {
|
|
2958
|
+
* data: { ...desired, outputs: { versionNumber: 1 } },
|
|
2959
|
+
* success: true,
|
|
2960
|
+
* };
|
|
2961
|
+
* },
|
|
2962
|
+
* },
|
|
2963
|
+
* universe: {
|
|
2964
|
+
* async create(desired) {
|
|
2965
|
+
* return {
|
|
2966
|
+
* data: { ...desired, outputs: { rootPlaceId: asRobloxAssetId("4711") } },
|
|
2967
|
+
* success: true,
|
|
2968
|
+
* };
|
|
2969
|
+
* },
|
|
2970
|
+
* },
|
|
2971
|
+
* developerProduct: {
|
|
2972
|
+
* async create(desired) {
|
|
2973
|
+
* return {
|
|
2974
|
+
* data: {
|
|
2975
|
+
* ...desired,
|
|
2976
|
+
* outputs: { productId: asRobloxAssetId("8172635495") },
|
|
2977
|
+
* },
|
|
2978
|
+
* success: true,
|
|
2979
|
+
* };
|
|
2980
|
+
* },
|
|
2981
|
+
* },
|
|
2982
|
+
* };
|
|
2983
|
+
*
|
|
2984
|
+
* const ops: ReadonlyArray<Operation> = [
|
|
2985
|
+
* {
|
|
2986
|
+
* key: asResourceKey("vip-pass"),
|
|
2987
|
+
* type: "create",
|
|
2988
|
+
* desired: {
|
|
2989
|
+
* key: asResourceKey("vip-pass"),
|
|
2990
|
+
* name: "VIP Pass",
|
|
2991
|
+
* description: "Grants VIP perks.",
|
|
2992
|
+
* icon: { "en-us": "assets/vip-icon.png" },
|
|
2993
|
+
* iconFileHashes: {
|
|
2994
|
+
* "en-us": asSha256Hex(
|
|
2995
|
+
* "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
2996
|
+
* ),
|
|
2997
|
+
* },
|
|
2998
|
+
* kind: "gamePass",
|
|
2999
|
+
* price: 500,
|
|
3000
|
+
* },
|
|
3001
|
+
* },
|
|
3002
|
+
* ];
|
|
3003
|
+
*
|
|
3004
|
+
* return applyOps(ops, registry).then((result) => {
|
|
3005
|
+
* expect(result.success).toBe(true);
|
|
3006
|
+
* expect(result.success && result.data).toHaveLength(1);
|
|
3007
|
+
* });
|
|
3008
|
+
* ```
|
|
3009
|
+
*/
|
|
3010
|
+
declare function applyOps(ops: ReadonlyArray<Operation>, registry: DriverRegistry): Promise<Result$1<ReadonlyArray<ResourceCurrentState>, ApplyError>>;
|
|
3011
|
+
//#endregion
|
|
3012
|
+
//#region src/shell/build-state-port.d.ts
|
|
3013
|
+
/**
|
|
3014
|
+
* Failure surfaced when a default-constructed adapter cannot find a
|
|
3015
|
+
* required environment variable. The deploy boundary wraps this in a
|
|
3016
|
+
* `DeployError` so the caller sees a typed Result instead of an
|
|
3017
|
+
* exception or a confusing downstream HTTP error.
|
|
3018
|
+
*/
|
|
3019
|
+
interface MissingCredentialError {
|
|
3020
|
+
/** Literal discriminator for narrowing. */
|
|
3021
|
+
readonly kind: "missingCredential";
|
|
3022
|
+
/** Whether the credential was needed for the state backend or the driver registry. */
|
|
3023
|
+
readonly purpose: "registry" | "stateBackend";
|
|
3024
|
+
/** Environment variable name the default-construction path tried to read. */
|
|
3025
|
+
readonly variable: string;
|
|
3026
|
+
}
|
|
3027
|
+
/**
|
|
3028
|
+
* Failure surfaced when the dispatch helper sees a `state.backend` value
|
|
3029
|
+
* it does not recognize. The hint points at `opts.statePort` so the
|
|
3030
|
+
* caller can pass a custom adapter as an escape hatch.
|
|
3031
|
+
*/
|
|
3032
|
+
interface UnsupportedBackendError {
|
|
3033
|
+
/** Backend name read from `state.backend`. */
|
|
3034
|
+
readonly backend: string;
|
|
3035
|
+
/** Suggested escape hatch routed back to the caller. */
|
|
3036
|
+
readonly hint: string;
|
|
3037
|
+
/** Literal discriminator for narrowing. */
|
|
3038
|
+
readonly kind: "unsupportedBackend";
|
|
3039
|
+
}
|
|
3040
|
+
/** Inputs for {@link buildStatePort}. */
|
|
3041
|
+
interface BuildStatePortDeps {
|
|
3042
|
+
/** Optional `fetch` seam plumbed through to the gist adapter for tests. */
|
|
3043
|
+
readonly fetch?: GistFetch | undefined;
|
|
3044
|
+
/** Reads an environment variable; injected so tests stay free of `process.env`. */
|
|
3045
|
+
readonly getEnv: (name: string) => string | undefined;
|
|
3046
|
+
/** Resolved state configuration for the target environment. */
|
|
3047
|
+
readonly stateConfig: StateConfig;
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* Construct a `StatePort` from a resolved `StateConfig`. Dispatches on
|
|
3051
|
+
* `stateConfig.backend` to the matching builtin adapter; reads the
|
|
3052
|
+
* required credential from `getEnv` and surfaces `missingCredential` or
|
|
3053
|
+
* `unsupportedBackend` as typed Results.
|
|
3054
|
+
*
|
|
3055
|
+
* @example
|
|
3056
|
+
*
|
|
3057
|
+
* ```ts
|
|
3058
|
+
* import { buildStatePort } from "@bedrock-rbx/core";
|
|
3059
|
+
*
|
|
3060
|
+
* const port = buildStatePort({
|
|
3061
|
+
* fetch: async () =>
|
|
3062
|
+
* new Response(JSON.stringify({ files: {} }), { status: 200 }),
|
|
3063
|
+
* getEnv: (name) => (name === "GITHUB_TOKEN" ? "ghp_example" : undefined),
|
|
3064
|
+
* stateConfig: { backend: "gist", gistId: "abc123" },
|
|
3065
|
+
* });
|
|
3066
|
+
*
|
|
3067
|
+
* expect(port.success).toBeTrue();
|
|
3068
|
+
* ```
|
|
3069
|
+
*
|
|
3070
|
+
* @param deps - Resolved state config plus credential-injection seams.
|
|
3071
|
+
* @returns A `StatePort` on success, or a typed Err describing the
|
|
3072
|
+
* missing credential or the unsupported backend.
|
|
3073
|
+
*/
|
|
3074
|
+
declare function buildStatePort(deps: BuildStatePortDeps): Result$1<StatePort, MissingCredentialError | UnsupportedBackendError>;
|
|
3075
|
+
//#endregion
|
|
3076
|
+
//#region src/shell/build-default-registry.d.ts
|
|
3077
|
+
/**
|
|
3078
|
+
* Failure surfaced when default-constructing a registry needs a config
|
|
3079
|
+
* field that is not present. The deploy boundary wraps this in a
|
|
3080
|
+
* `DeployError` so the caller sees a typed Result instead of a downstream
|
|
3081
|
+
* driver error.
|
|
3082
|
+
*/
|
|
3083
|
+
interface RegistryConfigError {
|
|
3084
|
+
/** Suggested fix routed back to the caller. */
|
|
3085
|
+
readonly hint: string;
|
|
3086
|
+
/** Literal discriminator for narrowing. */
|
|
3087
|
+
readonly kind: "registryConfigMissing";
|
|
3088
|
+
/** Which config field was missing. */
|
|
3089
|
+
readonly missing: "universeId";
|
|
3090
|
+
}
|
|
3091
|
+
/** Inputs for {@link buildDefaultRegistry}. */
|
|
3092
|
+
interface BuildDefaultRegistryDeps {
|
|
3093
|
+
/** Resolved project config; supplies `universe.universeId` and is read for nothing else. */
|
|
3094
|
+
readonly config: ResolvedConfig;
|
|
3095
|
+
/** Reads an environment variable; injected so tests stay free of `process.env`. */
|
|
3096
|
+
readonly getEnv: (name: string) => string | undefined;
|
|
3097
|
+
/** Reader plumbed into kind-specific drivers that ingest file bytes. */
|
|
3098
|
+
readonly readFile: (path: string) => Promise<Uint8Array>;
|
|
3099
|
+
}
|
|
3100
|
+
/**
|
|
3101
|
+
* Construct the default `DriverRegistry` from `config.universe.universeId`
|
|
3102
|
+
* and `ROBLOX_API_KEY`. Reads the API key via the injected `getEnv` seam
|
|
3103
|
+
* and surfaces `missingCredential` or `registryConfigMissing` as typed
|
|
3104
|
+
* Results instead of throwing.
|
|
3105
|
+
*
|
|
3106
|
+
* @example
|
|
3107
|
+
*
|
|
3108
|
+
* ```ts
|
|
3109
|
+
* import { buildDefaultRegistry } from "@bedrock-rbx/core";
|
|
3110
|
+
*
|
|
3111
|
+
* const registry = buildDefaultRegistry({
|
|
3112
|
+
* config: {
|
|
3113
|
+
* environments: { production: {} },
|
|
3114
|
+
* state: { backend: "gist", gistId: "abc" },
|
|
3115
|
+
* universe: { universeId: "1234567890" },
|
|
3116
|
+
* },
|
|
3117
|
+
* getEnv: () => "rbx-test",
|
|
3118
|
+
* readFile: async () => new Uint8Array(),
|
|
3119
|
+
* });
|
|
3120
|
+
*
|
|
3121
|
+
* expect(registry.success).toBeTrue();
|
|
3122
|
+
* ```
|
|
3123
|
+
*
|
|
3124
|
+
* @param deps - Validated config plus credential and file-reader seams.
|
|
3125
|
+
* @returns A `DriverRegistry` on success, or a typed Err describing the
|
|
3126
|
+
* missing API key or the missing universe declaration.
|
|
3127
|
+
*/
|
|
3128
|
+
declare function buildDefaultRegistry(deps: BuildDefaultRegistryDeps): Result$1<DriverRegistry, MissingCredentialError | RegistryConfigError>;
|
|
3129
|
+
//#endregion
|
|
3130
|
+
//#region src/shell/build-desired.d.ts
|
|
3131
|
+
/**
|
|
3132
|
+
* Layer file I/O onto a flat tagged list of resource inputs to produce
|
|
3133
|
+
* `ResourceDesiredState`.
|
|
3134
|
+
*
|
|
3135
|
+
* For each input, reads the file bytes via the injected `readFile`, computes
|
|
3136
|
+
* the SHA-256 hex digest, and assembles the branded desired-state record
|
|
3137
|
+
* that `diff` consumes. Entries are processed sequentially so the first
|
|
3138
|
+
* failure's attribution is deterministic.
|
|
3139
|
+
*
|
|
3140
|
+
* @param inputs - Flat tagged resource inputs from `flattenConfig`.
|
|
3141
|
+
* @param readFile - Reads file bytes for a given path; rejection becomes a
|
|
3142
|
+
* `fileReadFailed` Err.
|
|
3143
|
+
* @returns `Ok` with the desired-state array (same length and order as
|
|
3144
|
+
* `inputs`), or `Err` with the first I/O failure.
|
|
3145
|
+
* @example
|
|
3146
|
+
*
|
|
3147
|
+
* ```ts
|
|
3148
|
+
* import { asResourceKey, buildDesired } from "@bedrock-rbx/core";
|
|
3149
|
+
*
|
|
3150
|
+
* async function readFile(): Promise<Uint8Array> {
|
|
3151
|
+
* return new Uint8Array([1, 2, 3]);
|
|
3152
|
+
* }
|
|
3153
|
+
*
|
|
3154
|
+
* return buildDesired(
|
|
3155
|
+
* [
|
|
3156
|
+
* {
|
|
3157
|
+
* description: "Grants VIP perks.",
|
|
3158
|
+
* icon: { "en-us": "assets/vip-icon.png" },
|
|
3159
|
+
* key: asResourceKey("vip-pass"),
|
|
3160
|
+
* kind: "gamePass",
|
|
3161
|
+
* name: "VIP Pass",
|
|
3162
|
+
* price: 500,
|
|
3163
|
+
* },
|
|
3164
|
+
* ],
|
|
3165
|
+
* readFile,
|
|
3166
|
+
* ).then((result) => {
|
|
3167
|
+
* expect(result.success).toBeTrue();
|
|
3168
|
+
* if (result.success) {
|
|
3169
|
+
* expect(result.data).toHaveLength(1);
|
|
3170
|
+
* expect(result.data[0]!.kind).toBe("gamePass");
|
|
3171
|
+
* }
|
|
3172
|
+
* });
|
|
3173
|
+
* ```
|
|
3174
|
+
*/
|
|
3175
|
+
declare function buildDesired(inputs: ReadonlyArray<ResourceDesiredInput>, readFile: (path: string) => Promise<Uint8Array>): Promise<Result$1<ReadonlyArray<ResourceDesiredState>, BuildDesiredError>>;
|
|
3176
|
+
//#endregion
|
|
3177
|
+
//#region src/shell/load-config.d.ts
|
|
3178
|
+
/**
|
|
3179
|
+
* Options for {@link loadConfig}. Matches a subset of c12's loader options;
|
|
3180
|
+
* additional fields land with the issues that introduce each flow.
|
|
3181
|
+
*/
|
|
3182
|
+
interface LoadConfigOptions {
|
|
3183
|
+
/**
|
|
3184
|
+
* Path to a specific config file to load, including its extension.
|
|
3185
|
+
* Resolved relative to `cwd` when not absolute. Loaded as-is with no
|
|
3186
|
+
* extension search; if the file does not exist at the given path,
|
|
3187
|
+
* `loadConfig` returns `fileNotFound`. When omitted, `loadConfig`
|
|
3188
|
+
* discovers `bedrock.config.{ts,js,...}` from `cwd`.
|
|
3189
|
+
*/
|
|
3190
|
+
readonly configFile?: string;
|
|
3191
|
+
/**
|
|
3192
|
+
* Directory to search from. Defaults to `process.cwd()` at call time, so
|
|
3193
|
+
* each invocation sees the current working directory.
|
|
3194
|
+
*/
|
|
3195
|
+
readonly cwd?: string;
|
|
3196
|
+
}
|
|
3197
|
+
/**
|
|
3198
|
+
* Discover, parse, and validate the project config.
|
|
3199
|
+
*
|
|
3200
|
+
* Looks for `bedrock.config.{ts,js,mjs,cjs,yaml,yml,json}`, `.bedrockrc*`,
|
|
3201
|
+
* and `package.json#bedrock` starting at `options.cwd` (or the current
|
|
3202
|
+
* working directory). Returns a fresh, mutable `Config` on every call so
|
|
3203
|
+
* long-running scripts see up-to-date values.
|
|
3204
|
+
*
|
|
3205
|
+
* When the exported default is a function (sync or async), `loadConfig`
|
|
3206
|
+
* invokes it with an empty `ConfigContext` and awaits the result before
|
|
3207
|
+
* validating.
|
|
3208
|
+
*
|
|
3209
|
+
* Errors return via `Result`:
|
|
3210
|
+
* - `fileNotFound` - no config file was discovered under the search path.
|
|
3211
|
+
* - `parseFailed` - a config file was found but could not be parsed (for
|
|
3212
|
+
* example, malformed YAML or JSON).
|
|
3213
|
+
* - `validationFailed` - a file was found and parsed, but its content did
|
|
3214
|
+
* not satisfy the runtime schema.
|
|
3215
|
+
* - `configFunctionFailed` - a function-form config threw or its returned
|
|
3216
|
+
* promise rejected while being invoked.
|
|
3217
|
+
*
|
|
3218
|
+
* @param options - Loader options.
|
|
3219
|
+
* @returns `Ok` with the validated `Config`, or `Err` with a `ConfigError`.
|
|
3220
|
+
* @example
|
|
3221
|
+
*
|
|
3222
|
+
* ```ts
|
|
3223
|
+
* import { loadConfig } from "@bedrock-rbx/core";
|
|
3224
|
+
*
|
|
3225
|
+
* return loadConfig({
|
|
3226
|
+
* configFile: "bedrock.staging.config.yaml",
|
|
3227
|
+
* cwd: "/path/that/does/not/have/a/config",
|
|
3228
|
+
* }).then((result) => {
|
|
3229
|
+
* expect(result.success).toBeFalse();
|
|
3230
|
+
* if (!result.success) {
|
|
3231
|
+
* expect(result.err.kind).toBe("fileNotFound");
|
|
3232
|
+
* }
|
|
3233
|
+
* });
|
|
3234
|
+
* ```
|
|
3235
|
+
*/
|
|
3236
|
+
declare function loadConfig(options?: LoadConfigOptions): Promise<Result$1<Config, ConfigError>>;
|
|
3237
|
+
//#endregion
|
|
3238
|
+
//#region src/shell/deploy.d.ts
|
|
3239
|
+
/**
|
|
3240
|
+
* Inputs for `deploy`. Every field except `environment` is optional;
|
|
3241
|
+
* omitted dependencies are default-constructed from the project config
|
|
3242
|
+
* and the environment variables `GITHUB_TOKEN` and `ROBLOX_API_KEY`.
|
|
3243
|
+
*/
|
|
3244
|
+
interface DeployOptions {
|
|
3245
|
+
/** Pre-loaded, optionally-mutated project config. Omit to call `loadConfig()` automatically. */
|
|
3246
|
+
readonly config?: Config;
|
|
3247
|
+
/** Environment name; threaded into `StatePort.read` and the persisted snapshot. */
|
|
3248
|
+
readonly environment: string;
|
|
3249
|
+
/** `fetch` override plumbed into the default-constructed gist adapter when `statePort` is omitted. */
|
|
3250
|
+
readonly fetch?: GistFetch;
|
|
3251
|
+
/** Reads an environment variable; defaults to `(name) => process.env[name]`. */
|
|
3252
|
+
readonly getEnv?: (name: string) => string | undefined;
|
|
3253
|
+
/** Loader invoked when `config` is omitted; defaults to `loadConfig` from this package. */
|
|
3254
|
+
readonly loadConfig?: (options?: LoadConfigOptions) => Promise<Result$1<Config, ConfigError>>;
|
|
3255
|
+
/** Reads file bytes for resources that have file-backed inputs. Defaults to `node:fs/promises.readFile`. */
|
|
3256
|
+
readonly readFile?: (path: string) => Promise<Uint8Array>;
|
|
3257
|
+
/** Per-kind driver table consulted for create / update dispatch. Default-constructed from `ROBLOX_API_KEY` when omitted. */
|
|
3258
|
+
readonly registry?: DriverRegistry;
|
|
3259
|
+
/** Backend used to read the prior snapshot and persist the new one. Default-constructed from `config.state` and `GITHUB_TOKEN` when omitted. */
|
|
3260
|
+
readonly statePort?: StatePort;
|
|
3261
|
+
}
|
|
3262
|
+
/**
|
|
3263
|
+
* Failure surfaced by `deploy`. Stage-tagged so callers can branch on
|
|
3264
|
+
* `kind` to distinguish reconciliation failures (`stateReadFailed`,
|
|
3265
|
+
* `applyFailed`, ...) from default-construction failures
|
|
3266
|
+
* (`configLoadFailed`, `stateNotConfigured`, `unknownEnvironment`,
|
|
3267
|
+
* `incompletePlaceEntry`, `incompleteUniverseEntry`, `missingCredential`,
|
|
3268
|
+
* `unsupportedBackend`, `registryConfigMissing`).
|
|
3269
|
+
*/
|
|
3270
|
+
type DeployError = IncompletePlaceEntryError | IncompleteUniverseEntryError | MissingCredentialError | RegistryConfigError | StateNotConfiguredError | UnknownEnvironmentError | UnsupportedBackendError | {
|
|
3271
|
+
readonly cause: ApplyError;
|
|
3272
|
+
readonly kind: "applyFailed";
|
|
3273
|
+
} | {
|
|
3274
|
+
readonly cause: BuildDesiredError;
|
|
3275
|
+
readonly kind: "buildDesiredFailed";
|
|
3276
|
+
} | {
|
|
3277
|
+
readonly cause: ConfigError;
|
|
3278
|
+
readonly kind: "configLoadFailed";
|
|
3279
|
+
} | {
|
|
3280
|
+
readonly cause: StateError;
|
|
3281
|
+
readonly kind: "stateReadFailed";
|
|
3282
|
+
} | {
|
|
3283
|
+
readonly cause: StateError;
|
|
3284
|
+
readonly kind: "stateWriteFailed";
|
|
3285
|
+
readonly unsavedState: BedrockState;
|
|
3286
|
+
};
|
|
3287
|
+
/**
|
|
3288
|
+
* Run a full reconcile end-to-end. Default-constructs missing deps from
|
|
3289
|
+
* the project config and the environment variables `GITHUB_TOKEN` and
|
|
3290
|
+
* `ROBLOX_API_KEY`; never reads `process.env` when `statePort`,
|
|
3291
|
+
* `registry`, and `config` are all supplied explicitly.
|
|
3292
|
+
*
|
|
3293
|
+
* @param options - Target environment plus optional overrides.
|
|
3294
|
+
* @returns The persisted `BedrockState` on success, or a stage-tagged
|
|
3295
|
+
* `DeployError` on failure.
|
|
3296
|
+
* @example
|
|
3297
|
+
*
|
|
3298
|
+
* ```ts
|
|
3299
|
+
* import { deploy } from "@bedrock-rbx/core";
|
|
3300
|
+
*
|
|
3301
|
+
* return deploy({ environment: "production" }).then((result) => {
|
|
3302
|
+
* expect(result.success).toBeFalse();
|
|
3303
|
+
* if (!result.success) {
|
|
3304
|
+
* expect(["configLoadFailed", "stateNotConfigured"]).toContain(result.err.kind);
|
|
3305
|
+
* }
|
|
3306
|
+
* });
|
|
3307
|
+
* ```
|
|
3308
|
+
*
|
|
3309
|
+
* @example
|
|
3310
|
+
*
|
|
3311
|
+
* ```ts
|
|
3312
|
+
* import { deploy, type BedrockState, type DriverRegistry, type StatePort } from "@bedrock-rbx/core";
|
|
3313
|
+
*
|
|
3314
|
+
* const store = new Map<string, BedrockState>();
|
|
3315
|
+
* const statePort: StatePort = {
|
|
3316
|
+
* async read(environment) {
|
|
3317
|
+
* return { data: store.get(environment), success: true };
|
|
3318
|
+
* },
|
|
3319
|
+
* async write(state) {
|
|
3320
|
+
* store.set(state.environment, state);
|
|
3321
|
+
* return { data: undefined, success: true };
|
|
3322
|
+
* },
|
|
3323
|
+
* };
|
|
3324
|
+
* const registry: DriverRegistry = {
|
|
3325
|
+
* developerProduct: {
|
|
3326
|
+
* create: async () => { throw new Error("unreachable: empty config"); },
|
|
3327
|
+
* },
|
|
3328
|
+
* gamePass: { create: async () => { throw new Error("unreachable: empty config"); } },
|
|
3329
|
+
* place: { create: async () => { throw new Error("unreachable: empty config"); } },
|
|
3330
|
+
* universe: { create: async () => { throw new Error("unreachable: empty config"); } },
|
|
3331
|
+
* };
|
|
3332
|
+
*
|
|
3333
|
+
* return deploy({
|
|
3334
|
+
* config: {
|
|
3335
|
+
* environments: { production: {} },
|
|
3336
|
+
* state: { backend: "gist", gistId: "abc" },
|
|
3337
|
+
* passes: {},
|
|
3338
|
+
* },
|
|
3339
|
+
* environment: "production",
|
|
3340
|
+
* registry,
|
|
3341
|
+
* statePort,
|
|
3342
|
+
* }).then((result) => {
|
|
3343
|
+
* expect(result.success).toBeTrue();
|
|
3344
|
+
* if (result.success) {
|
|
3345
|
+
* expect(result.data.environment).toBe("production");
|
|
3346
|
+
* expect(result.data.resources).toBeEmpty();
|
|
3347
|
+
* }
|
|
3348
|
+
* });
|
|
3349
|
+
* ```
|
|
3350
|
+
*/
|
|
3351
|
+
declare function deploy(options: DeployOptions): Promise<Result$1<BedrockState, DeployError>>;
|
|
3352
|
+
//#endregion
|
|
3353
|
+
//#region src/shell/migrate-mantle-state.d.ts
|
|
3354
|
+
type ConfigFormat = "typescript" | "yaml";
|
|
3355
|
+
/**
|
|
3356
|
+
* Inputs for `migrateMantleState`. The state file is read via
|
|
3357
|
+
* `readFile` (defaults to `node:fs/promises.readFile`) so callers can
|
|
3358
|
+
* inject in-memory fixtures from tests and the JSDoc `@example` block
|
|
3359
|
+
* stays self-contained.
|
|
3360
|
+
*
|
|
3361
|
+
* `configFormat` selects the output shape: `"typescript"` emits a
|
|
3362
|
+
* `bedrock.config.ts` with `defineConfig({...})`; `"yaml"` emits a
|
|
3363
|
+
* `bedrock.config.yaml` body. Both shapes round-trip through
|
|
3364
|
+
* `loadConfig` cleanly.
|
|
3365
|
+
*/
|
|
3366
|
+
interface MigrateMantleStateDeps {
|
|
3367
|
+
/**
|
|
3368
|
+
* Output format for the emitted bedrock config file. `"typescript"`
|
|
3369
|
+
* produces a `defineConfig({...})` module; `"yaml"` produces a YAML
|
|
3370
|
+
* body whose keys match the `Config` schema.
|
|
3371
|
+
*/
|
|
3372
|
+
readonly configFormat: ConfigFormat;
|
|
3373
|
+
/**
|
|
3374
|
+
* Environment in the input state file whose resolved values seed
|
|
3375
|
+
* the root config. Required when the state file declares more than
|
|
3376
|
+
* one environment; ignored when only one environment is present.
|
|
3377
|
+
*/
|
|
3378
|
+
readonly primaryEnvironment?: string;
|
|
3379
|
+
/**
|
|
3380
|
+
* Reads file bytes; defaults to `node:fs/promises.readFile`. Kept
|
|
3381
|
+
* `Uint8Array`-typed to match `deploy`, `buildDesired`, and
|
|
3382
|
+
* `buildDefaultRegistry`. UTF-8 decoding happens inside the migrator
|
|
3383
|
+
* before YAML parsing.
|
|
3384
|
+
*/
|
|
3385
|
+
readonly readFile?: (path: string) => Promise<Uint8Array>;
|
|
3386
|
+
/** Absolute path to the `.mantle-state.yml` file to migrate. */
|
|
3387
|
+
readonly stateFilePath: string;
|
|
3388
|
+
}
|
|
3389
|
+
/**
|
|
3390
|
+
* Read a Mantle state file and produce a `MigrationReport` containing a
|
|
3391
|
+
* bedrock config, per-environment `BedrockState`s, and a structured list
|
|
3392
|
+
* of fields that did not migrate verbatim.
|
|
3393
|
+
*
|
|
3394
|
+
* Skeleton: handles single-environment or multi-environment states with
|
|
3395
|
+
* universe, place, and game-pass resources. The primary environment
|
|
3396
|
+
* auto-picks when only one environment is present; multi-environment
|
|
3397
|
+
* inputs without an explicit `primaryEnvironment` return
|
|
3398
|
+
* `Err({ kind: "primaryEnvironmentRequired", available })` so the
|
|
3399
|
+
* migrator never silently picks a winner. Future slices add social
|
|
3400
|
+
* links and the deferred / blocked warning categories.
|
|
3401
|
+
*
|
|
3402
|
+
* @param deps - Inputs for the migration.
|
|
3403
|
+
* @returns `Ok` with a `MigrationReport` on success, or `Err` with a
|
|
3404
|
+
* discriminated `MigrateError` on failure.
|
|
3405
|
+
* @rejects Re-thrown `readFile` failure when the underlying error code is
|
|
3406
|
+
* not in the recognized "missing file" set; surfaced so callers see
|
|
3407
|
+
* permission or filesystem outages instead of having them coerced to
|
|
3408
|
+
* `stateFileNotFound`.
|
|
3409
|
+
* @example
|
|
3410
|
+
*
|
|
3411
|
+
* ```ts
|
|
3412
|
+
* import { migrateMantleState } from "@bedrock-rbx/core";
|
|
3413
|
+
*
|
|
3414
|
+
* const yaml = [
|
|
3415
|
+
* 'version: "6"',
|
|
3416
|
+
* "environments:",
|
|
3417
|
+
* " production:",
|
|
3418
|
+
* " - id: experience_singleton",
|
|
3419
|
+
* " inputs:",
|
|
3420
|
+
* " experience:",
|
|
3421
|
+
* " groupId: ~",
|
|
3422
|
+
* " outputs:",
|
|
3423
|
+
* " experience:",
|
|
3424
|
+
* " assetId: 6031475575",
|
|
3425
|
+
* " startPlaceId: 17613681043",
|
|
3426
|
+
* " dependencies: []",
|
|
3427
|
+
* "",
|
|
3428
|
+
* ].join("\n");
|
|
3429
|
+
*
|
|
3430
|
+
* async function readFile(): Promise<Uint8Array> {
|
|
3431
|
+
* return new TextEncoder().encode(yaml);
|
|
3432
|
+
* }
|
|
3433
|
+
*
|
|
3434
|
+
* return migrateMantleState({
|
|
3435
|
+
* configFormat: "typescript",
|
|
3436
|
+
* readFile,
|
|
3437
|
+
* stateFilePath: ".mantle-state.yml",
|
|
3438
|
+
* }).then((result) => {
|
|
3439
|
+
* expect(result.success).toBeTrue();
|
|
3440
|
+
* if (result.success) {
|
|
3441
|
+
* expect(result.data.config.universe?.universeId).toBe("6031475575");
|
|
3442
|
+
* }
|
|
3443
|
+
* });
|
|
3444
|
+
* ```
|
|
3445
|
+
*/
|
|
3446
|
+
declare function migrateMantleState(deps: MigrateMantleStateDeps): Promise<Result$1<MigrationReport, MigrateError>>;
|
|
3447
|
+
//#endregion
|
|
3448
|
+
export { type ApplyError, type BaseOperation, type BedrockState, type BuildDesiredError, type Config, type ConfigContext, type ConfigEnvironmentUniverseId, type ConfigError, type ConfigInput, type ConfigRootUniverseId, type ConfigValidationIssue, type CreateOperation, DEFAULT_PREFIX_FORMAT, type DeployError, type DeployOptions, type DeveloperProductDesiredInput, type DeveloperProductDesiredState, type DeveloperProductDriverDeps, type DeveloperProductEntry, type DeveloperProductOutputs, type DisplayNamePrefixConfig, type DriverRegistry, type EnvironmentEntry, type GamePassDesiredInput, type GamePassDesiredState, type GamePassDriverDeps, type GamePassEntry, type GamePassOutputs, type GetEnvironmentError, type GistStateAdapterDeps, type GistStateConfig, type IncompletePlaceEntryError, type IncompleteUniverseEntryError, type KindIo, type KindRegistry, type LoadConfigOptions, type MigrateError, type MigrateMantleStateDeps, type MigrationReport, type MigrationSummary, type MigrationWarning, type MissingCredentialError, type NoopOperation, OpenCloudError, type Operation, type PlaceDesiredInput, type PlaceDesiredState, type PlaceDriverDeps, type PlaceEntry, type PlaceOutputs, type PriceFields, type RegistryConfigError, type ResolvedConfig, type ResolvedPlaceEntry, type ResolvedUniverseEntry, type ResourceCurrentState, type ResourceDesiredInput, type ResourceDesiredState, type ResourceDriver, type ResourceEntryByKind, type ResourceKey, type ResourceKind, type ResourceKindModule, type ResourceOutputs, type ResourceOutputsByKind, type Result, type RobloxAssetId, SOCIAL_LINK_FIELDS, type SelectEnvironmentError, type Sha256Hex, type SocialLink, type SocialLinkField, type StateConfig, type StateError, type StateNotConfiguredError, type StatePort, type StatesByEnvironment, UNIVERSE_SINGLETON_KEY, type UniverseDesiredInput, type UniverseDesiredState, type UniverseDriverDeps, type UniverseEntry, type UniverseOutputs, type UniverseOverlayWithId, type UniverseOverlayWithoutId, type UnknownEnvironmentError, type UnsupportedBackendError, type UpdateOperation, applyOps, asResourceKey, asRobloxAssetId, asSha256Hex, buildDefaultRegistry, buildDesired, buildStatePort, createDeveloperProductDriver, createGamePassDriver, createGistStateAdapter, createPlaceDriver, createUniverseDriver, defaultKindRegistry, defineConfig, deploy, derivePriceFields, diff, flattenConfig, getEnvironment, isGistStateConfig, isResourceKey, isRobloxAssetId, isSha256Hex, loadConfig, migrateMantleState, parseStateFile, renderDisplayNamePrefix, resolveStateConfig, selectEnvironment, serializeStateFile, shouldReuploadIcon, validateConfig, validateEnvironmentName, validatePlan };
|
|
3449
|
+
//# sourceMappingURL=index.d.mts.map
|