@bedrock-rbx/core 0.1.0-beta.13 → 0.1.0-beta.14

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.
@@ -3,6 +3,7 @@ import { ArkErrors, type } from "arktype";
3
3
  import { cancel, intro, log, outro } from "@clack/prompts";
4
4
  import process from "node:process";
5
5
  import { defu } from "defu";
6
+ import { createHash } from "node:crypto";
6
7
  import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
7
8
  import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
8
9
  import { PlacesClient } from "@bedrock-rbx/ocale/places";
@@ -16,16 +17,22 @@ import { tmpdir } from "node:os";
16
17
  import { parseYAML, stringifyYAML } from "confbox";
17
18
  //#region src/cli/render.ts
18
19
  /**
19
- * Render a `DeployError` to the supplied `ClackPort` as a single error line.
20
- * Each variant produces a distinct, terse diagnostic; wrapped variants
21
- * (`applyFailed`, `buildDesiredFailed`, `configLoadFailed`, `stateReadFailed`,
22
- * `stateWriteFailed`) surface the inner cause's actionable detail (file path,
23
- * resource key, parser message, HTTP failure, validator issue) so the reader
24
- * does not have to inspect the full cause to act.
20
+ * Render a `DeployError` to the supplied `ClackPort`. Most variants emit a
21
+ * single error line; `applyFailed` emits one line per failing op in the
22
+ * aggregate (in Phase 1 then Phase 2 input order). Wrapped variants
23
+ * (`applyFailed`, `buildDesiredFailed`, `configLoadFailed`,
24
+ * `stateReadFailed`, `stateWriteFailed`) surface the inner cause's
25
+ * actionable detail (file path, resource key, parser message, HTTP failure,
26
+ * validator issue) so the reader does not have to inspect the full cause to
27
+ * act.
25
28
  * @param err - The deploy error to describe.
26
29
  * @param port - The output port the diagnostic is written to.
27
30
  */
28
31
  function renderDeployError(err, port) {
32
+ if (err.kind === "applyFailed") {
33
+ for (const failure of err.cause.failures) port.logError(`apply failed for '${failure.key}': ${applyCauseDetail(failure)}`);
34
+ return;
35
+ }
29
36
  port.logError(deployErrorMessage(err));
30
37
  }
31
38
  /**
@@ -110,18 +117,31 @@ function permissionDetail(err) {
110
117
  const scopeList = err.requiredScopes.map((scope) => `'${scope}'`).join(", ");
111
118
  return `${err.message} on ${err.operationKey}: missing required ${label} ${scopeList}. Grant ${pronoun} on the API key at https://create.roblox.com/credentials`;
112
119
  }
120
+ function safeStringify(value) {
121
+ if (value instanceof Error) return value.message;
122
+ try {
123
+ return String(value);
124
+ } catch {
125
+ return "<unprintable cause>";
126
+ }
127
+ }
113
128
  function applyCauseDetail(cause) {
114
129
  switch (cause.kind) {
115
130
  case "driverFailure":
116
131
  if (cause.cause instanceof PermissionError) return permissionDetail(cause.cause);
117
132
  return cause.cause.message;
133
+ case "unexpectedThrow": return `unexpected error: ${safeStringify(cause.cause)}`;
118
134
  case "updateUnsupported": return "update not supported";
119
135
  }
120
136
  }
121
137
  function buildDesiredDetail(cause) {
122
138
  switch (cause.kind) {
123
- case "fileReadFailed": return `(${cause.filePath}): ${cause.reason}`;
124
- case "iconRemovalRejected": return `: ${cause.message}`;
139
+ case "fileReadFailed": return `for '${cause.key}' (${cause.filePath}): ${cause.reason}`;
140
+ case "iconRemovalRejected": return `for '${cause.key}': ${cause.message}`;
141
+ case "redactedNameCollision": {
142
+ const [first, second] = cause.keys;
143
+ return `for '${first}' and '${second}': ${cause.message}`;
144
+ }
125
145
  }
126
146
  }
127
147
  function configErrorDetail(err) {
@@ -141,8 +161,7 @@ function stateErrorDetail(cause) {
141
161
  }
142
162
  function deployErrorMessage(err) {
143
163
  switch (err.kind) {
144
- case "applyFailed": return `apply failed for '${err.cause.key}': ${applyCauseDetail(err.cause)}`;
145
- case "buildDesiredFailed": return `build desired state failed for '${err.cause.key}' ${buildDesiredDetail(err.cause)}`;
164
+ case "buildDesiredFailed": return `build desired state failed ${buildDesiredDetail(err.cause)}`;
146
165
  case "configLoadFailed": return `config load failed: ${configErrorDetail(err.cause)}`;
147
166
  case "incompletePassEntry": return `pass '${err.key}' is missing '${err.missingField}' under environment '${err.environment}'`;
148
167
  case "incompletePlaceEntry": return `place '${err.key}' is missing '${err.missingField}' under environment '${err.environment}'`;
@@ -184,77 +203,54 @@ function buildStatePortErrorMessage(err) {
184
203
  }
185
204
  }
186
205
  //#endregion
187
- //#region src/adapters/clack-progress-adapter.ts
206
+ //#region src/core/resolve-state-config.ts
188
207
  /**
189
- * Build a {@link ProgressPort} that renders events through a `ClackPort`.
190
- * Pattern-matches on the event `kind`: `deploySuccess` becomes a single
191
- * success line and `deployFailure` delegates to the package's deploy-error
192
- * rendering helper.
208
+ * Pick the `StateConfig` that applies to `environment`. Per-environment
209
+ * overrides win over the root block; if neither is present, returns
210
+ * `Err(stateNotConfigured)` so the deploy boundary can surface a typed
211
+ * error instead of silently falling back.
193
212
  *
213
+ * @param config - Validated project config.
214
+ * @param environment - Target environment name.
215
+ * @returns The resolved `StateConfig`, or `Err(stateNotConfigured)` when
216
+ * neither the environment override nor the root block is set.
194
217
  * @example
195
218
  *
196
219
  * ```ts
197
- * import { createClackProgressAdapter, type ClackPort } from "@bedrock-rbx/core";
198
- *
199
- * const lines: Array<string> = [];
200
- * const clack: ClackPort = {
201
- * cancel: (message) => lines.push(`cancel: ${message}`),
202
- * intro: (message) => lines.push(`intro: ${message}`),
203
- * logError: (message) => lines.push(`error: ${message}`),
204
- * logMessage: (message) => lines.push(`log: ${message}`),
205
- * logSuccess: (message) => lines.push(`ok: ${message}`),
206
- * outro: (message) => lines.push(`outro: ${message}`),
207
- * };
208
- *
209
- * const port = createClackProgressAdapter({ clack });
210
- *
211
- * port.emit({ environment: "production", kind: "deploySuccess", resourceCount: 3 });
212
- *
213
- * expect(lines).toEqual(["ok: production: 3 resources reconciled"]);
214
- * ```
215
- *
216
- * @param deps - The clack port the adapter renders through.
217
- * @returns A `ProgressPort` that renders via clack.
218
- */
219
- function createClackProgressAdapter(deps) {
220
- const { clack } = deps;
221
- return { emit(event) {
222
- switch (event.kind) {
223
- case "deployFailure":
224
- renderDeployError(event.error, clack);
225
- return;
226
- case "deploySuccess": clack.logSuccess(`${event.environment}: ${event.resourceCount} resources reconciled`);
227
- }
228
- } };
229
- }
230
- //#endregion
231
- //#region src/core/derive-price-fields.ts
232
- /**
233
- * Translate a Mantle-style optional price into the Open Cloud wire shape.
234
- *
235
- * `desired.price === undefined` (no price declared) becomes
236
- * `{ isForSale: false }` and the `price` key is omitted entirely. A defined
237
- * price (including `0`) becomes `{ isForSale: true, price }`. Both
238
- * `developerProduct` create and update paths share this helper so the
239
- * "absent ⇒ off-sale" semantics live in exactly one place.
240
- *
241
- * @param desired - Object carrying the user-declared `price`.
242
- * @returns The wire-shape `{ isForSale, price? }` fragment.
243
- *
244
- * @example
220
+ * import { resolveStateConfig } from "@bedrock-rbx/core";
245
221
  *
246
- * ```ts
247
- * import { derivePriceFields } from "@bedrock-rbx/core";
222
+ * const result = resolveStateConfig(
223
+ * {
224
+ * state: { backend: "gist", gistId: "root-gist" },
225
+ * environments: {
226
+ * production: { state: { backend: "gist", gistId: "prod-gist" } },
227
+ * },
228
+ * },
229
+ * "production",
230
+ * );
248
231
  *
249
- * expect(derivePriceFields({ price: undefined })).toStrictEqual({ isForSale: false });
250
- * expect(derivePriceFields({ price: 250 })).toStrictEqual({ isForSale: true, price: 250 });
232
+ * expect(result.success).toBeTrue();
233
+ * if (result.success) {
234
+ * expect(result.data).toContainEntry(["gistId", "prod-gist"]);
235
+ * }
251
236
  * ```
252
237
  */
253
- function derivePriceFields(desired) {
254
- if (desired.price === void 0) return { isForSale: false };
238
+ function resolveStateConfig(config, environment) {
239
+ const override = config.environments[environment]?.state;
240
+ if (override !== void 0) return {
241
+ data: override,
242
+ success: true
243
+ };
244
+ if (config.state !== void 0) return {
245
+ data: config.state,
246
+ success: true
247
+ };
255
248
  return {
256
- isForSale: true,
257
- price: desired.price
249
+ err: {
250
+ environment,
251
+ kind: "stateNotConfigured"
252
+ },
253
+ success: false
258
254
  };
259
255
  }
260
256
  //#endregion
@@ -442,6 +438,62 @@ function asSha256Hex(raw) {
442
438
  return raw;
443
439
  }
444
440
  //#endregion
441
+ //#region src/core/environment.ts
442
+ /**
443
+ * Source pattern for environment names, including `^` and `$` anchors.
444
+ * Letters, digits, `-`, `_`, length 1-64.
445
+ *
446
+ * Exported so the config schema can validate `environments` keys against
447
+ * the same alphabet and length cap that adapters enforce on storage
448
+ * identifiers. Single source of truth: changing the alphabet here changes
449
+ * both the runtime check and the schema-level key constraint.
450
+ *
451
+ * Anchors are embedded so callers do not have to re-add them, matching
452
+ * the `RESOURCE_KEY_PATTERN_SOURCE` convention in `types/ids.ts`.
453
+ */
454
+ const ENV_NAME_PATTERN_SOURCE = "^[A-Za-z0-9_-]{1,64}$";
455
+ const ENVIRONMENT_NAME_PATTERN = new RegExp(ENV_NAME_PATTERN_SOURCE);
456
+ /**
457
+ * Validate an environment name at a state-adapter boundary.
458
+ *
459
+ * Adapters that map environment names onto filesystem-like identifiers
460
+ * (gist filenames, S3 keys) must reject names that could collide or escape
461
+ * their storage layout. This helper accepts letters, digits, `-`, and `_`
462
+ * only, with length between 1 and 64, and returns a `StateError` for
463
+ * anything outside that set so the adapter can fail loudly instead of
464
+ * silently stripping characters.
465
+ *
466
+ * @example
467
+ *
468
+ * ```ts
469
+ * import { validateEnvironmentName } from "@bedrock-rbx/core";
470
+ *
471
+ * const ok = validateEnvironmentName("production");
472
+ * expect(ok.success).toBeTrue();
473
+ *
474
+ * const bad = validateEnvironmentName("prod/staging");
475
+ * expect(bad.success).toBeFalse();
476
+ * ```
477
+ *
478
+ * @param environment - Raw environment name supplied by a caller.
479
+ * @returns `Ok(environment)` when the name is safe to use, or
480
+ * `Err(StateError)` with a descriptive reason when it is not.
481
+ */
482
+ function validateEnvironmentName(environment) {
483
+ if (!ENVIRONMENT_NAME_PATTERN.test(environment)) return {
484
+ err: {
485
+ file: environment,
486
+ kind: "stateError",
487
+ reason: `invalid environment name: must match ${String(ENVIRONMENT_NAME_PATTERN)}`
488
+ },
489
+ success: false
490
+ };
491
+ return {
492
+ data: environment,
493
+ success: true
494
+ };
495
+ }
496
+ //#endregion
445
497
  //#region src/core/kinds/hash.ts
446
498
  /**
447
499
  * Compute the SHA-256 hex digest of a byte sequence. Shared by kind modules
@@ -838,1521 +890,1626 @@ function shouldReuploadIcon(currentHashes, desiredHashes) {
838
890
  return !iconHashesEqual(currentHashes, desiredHashes);
839
891
  }
840
892
  //#endregion
841
- //#region src/core/plan-follow-up-patch.ts
893
+ //#region src/core/validate-universe-xor.ts
842
894
  /**
843
- * Plan the optional follow-up PATCH body needed after a developer-product
844
- * create POST. Returns `undefined` when no PATCH is required: either the
845
- * user did not declare `storePageEnabled`, or the create response already
846
- * matches the desired value.
895
+ * Walk the loose authored-shape and surface every place the
896
+ * universeId-XOR-between-root-and-env rule is violated. Pure: returns
897
+ * the issue list; the caller hands it to arktype's `ctx.reject` so each
898
+ * one lands at the offending config path. The schema's runtime narrow
899
+ * uses this to enforce the rule at validation time before the validated
900
+ * value is cast to the strict `Config` discriminated union.
847
901
  *
848
- * @param desired - Desired state for the developer product being created.
849
- * @param createResponse - The `storePageEnabled` value reported by the create POST response.
850
- * @returns The PATCH body to issue, or `undefined` when no follow-up is needed.
902
+ * @param value - Parsed config the schema is validating.
903
+ * @returns Zero or more issues. Empty when the config satisfies the rule.
851
904
  */
852
- function planFollowUpPatch(desired, createResponse) {
853
- if (desired.storePageEnabled === void 0) return;
854
- if (desired.storePageEnabled === createResponse.storePageEnabled) return;
855
- return { storePageEnabled: desired.storePageEnabled };
905
+ function collectUniverseIdIssues(value) {
906
+ const rootUniverseId = value.universe?.universeId;
907
+ const hasRootUniverseBlock = value.universe !== void 0;
908
+ const environmentEntries = Object.entries(value.environments);
909
+ const hasEnvironmentUniverseId = environmentEntries.some(([, environment]) => environment.universe?.universeId !== void 0);
910
+ const environmentIssues = collectEnvironmentIssues(rootUniverseId, environmentEntries);
911
+ const rootIssues = hasRootUniverseBlock && rootUniverseId === void 0 && !hasEnvironmentUniverseId ? [{
912
+ message: "universeId must be declared on the root universe block, or on every environment that declares its own universe overlay.",
913
+ path: ["universe", "universeId"]
914
+ }] : [];
915
+ return [...environmentIssues, ...rootIssues];
916
+ }
917
+ function collectEnvironmentIssues(rootUniverseId, environmentEntries) {
918
+ return environmentEntries.flatMap(([environmentName, environment]) => {
919
+ if (environment.universe === void 0) return [];
920
+ if (rootUniverseId !== void 0 && environment.universe.universeId !== void 0) return [{
921
+ message: "universeId is declared at the root universe block; remove it from this environment overlay (root is authoritative) or remove it from the root and declare it on every environment.",
922
+ path: [
923
+ "environments",
924
+ environmentName,
925
+ "universe",
926
+ "universeId"
927
+ ]
928
+ }];
929
+ if (rootUniverseId === void 0 && environment.universe.universeId === void 0) return [{
930
+ message: "universeId must be declared on this environment overlay because the root universe block does not provide one.",
931
+ path: [
932
+ "environments",
933
+ environmentName,
934
+ "universe",
935
+ "universeId"
936
+ ]
937
+ }];
938
+ return [];
939
+ });
856
940
  }
857
941
  //#endregion
858
- //#region src/adapters/developer-product-driver.ts
942
+ //#region src/core/schema.ts
859
943
  /**
860
- * Wraps {@link DeveloperProductsClient} as a `ResourceDriver<"developerProduct">`
861
- * that maps a desired-state entry to an ocale create or update call and the
862
- * response back to a `ResourceCurrentState<"developerProduct">`. The
863
- * `update` path consumes the upstream `204 No Content` response and
864
- * synthesizes the post-update `ResourceCurrentState` from `desired` plus
865
- * the existing `current.outputs`, carrying `iconImageAssetId` forward when
866
- * present.
867
- *
868
- * Upstream `OpenCloudError` results pass through as `Result` failures.
869
- *
870
- * @param deps - Injected ocale client and owning universe.
871
- * @returns A driver indexable by `"developerProduct"` in a `DriverRegistry`.
944
+ * Narrow a `StateConfig` to the `GistStateConfig` arm. The `(string & {})`
945
+ * autocomplete idiom prevents TypeScript from narrowing on
946
+ * `backend === "gist"` alone, so dispatch sites use this guard to
947
+ * preserve the `gistId` field shape.
872
948
  *
873
949
  * @example
874
950
  *
875
951
  * ```ts
876
- * import type { HttpClient } from "@bedrock-rbx/ocale";
877
- * import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
878
- * import {
879
- * asResourceKey,
880
- * asRobloxAssetId,
881
- * createDeveloperProductDriver,
882
- * } from "@bedrock-rbx/core";
952
+ * import { isGistStateConfig } from "@bedrock-rbx/core";
953
+ * import type { StateConfig } from "@bedrock-rbx/core/config";
883
954
  *
884
- * const httpClient: HttpClient = {
885
- * async request() {
886
- * return {
887
- * data: {
888
- * body: {
889
- * createdTimestamp: "2024-01-15T10:30:00.000Z",
890
- * description: "Stocks the player up with 1,000 premium gems.",
891
- * iconImageAssetId: null,
892
- * isForSale: false,
893
- * isImmutable: false,
894
- * name: "Gem Pack",
895
- * priceInformation: null,
896
- * productId: 9_876_543_210,
897
- * storePageEnabled: false,
898
- * universeId: 1_234_567_890,
899
- * updatedTimestamp: "2024-01-15T10:30:00.000Z",
900
- * },
901
- * headers: {},
902
- * status: 200,
903
- * },
904
- * success: true,
905
- * };
906
- * },
907
- * };
908
- *
909
- * const driver = createDeveloperProductDriver({
910
- * client: new DeveloperProductsClient({
911
- * apiKey: "rbx-your-key",
912
- * httpClient,
913
- * sleep: async () => {},
914
- * }),
915
- * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
916
- * universeId: asRobloxAssetId("1234567890"),
917
- * });
955
+ * const config: StateConfig = { backend: "gist", gistId: "abc" };
918
956
  *
919
- * return driver
920
- * .create({
921
- * description: "Stocks the player up with 1,000 premium gems.",
922
- * isRegionalPricingEnabled: undefined,
923
- * key: asResourceKey("gem-pack"),
924
- * kind: "developerProduct",
925
- * name: "Gem Pack",
926
- * price: undefined,
927
- * storePageEnabled: undefined,
928
- * })
929
- * .then((result) => {
930
- * expect(result.success).toBeTrue();
931
- * if (result.success) {
932
- * expect(result.data.outputs.productId).toBe("9876543210");
933
- * }
934
- * });
957
+ * expect(isGistStateConfig(config)).toBeTrue();
958
+ * if (isGistStateConfig(config)) {
959
+ * expect(config.gistId).toBe("abc");
960
+ * }
935
961
  * ```
962
+ *
963
+ * @param config - Resolved state config to inspect.
964
+ * @returns `true` when `config.backend === "gist"`; otherwise `false`.
936
965
  */
937
- function createDeveloperProductDriver(deps) {
938
- const effective = {
939
- ...deps,
940
- readFile: withRedactedIcon(deps.readFile)
941
- };
942
- return {
943
- async create(desired) {
944
- return createOne(effective, desired);
945
- },
946
- async update(current, desired) {
947
- return updateOne(effective, {
948
- current,
949
- desired
950
- });
951
- }
952
- };
953
- }
954
- function toCurrentState$2(desired, data) {
955
- const iconImageAssetId = data.iconImageAssetId === void 0 ? void 0 : asRobloxAssetId(data.iconImageAssetId);
956
- return {
957
- data: {
958
- ...desired,
959
- outputs: {
960
- productId: asRobloxAssetId(data.id),
961
- ...iconImageAssetId === void 0 ? {} : { iconImageAssetId }
962
- }
963
- },
964
- success: true
965
- };
966
- }
967
- async function applyFollowUpPatch(deps, { created, desired }) {
968
- const followUp = planFollowUpPatch(desired, created);
969
- if (followUp === void 0) return toCurrentState$2(desired, created);
970
- if ((await deps.client.update({
971
- productId: asRobloxAssetId(created.id),
972
- universeId: deps.universeId,
973
- ...followUp
974
- })).success) return toCurrentState$2(desired, created);
975
- return toCurrentState$2({
976
- ...desired,
977
- storePageEnabled: created.storePageEnabled
978
- }, created);
979
- }
980
- async function createOne(deps, desired) {
981
- const imageFile = desired.icon === void 0 ? void 0 : await deps.readFile(desired.icon["en-us"]);
982
- const created = await deps.client.create({
983
- name: desired.name,
984
- description: desired.description,
985
- universeId: deps.universeId,
986
- ...imageFile === void 0 ? {} : { imageFile },
987
- ...derivePriceFields(desired),
988
- ...desired.isRegionalPricingEnabled === void 0 ? {} : { isRegionalPricingEnabled: desired.isRegionalPricingEnabled }
989
- });
990
- if (!created.success) return created;
991
- return applyFollowUpPatch(deps, {
992
- created: created.data,
993
- desired
994
- });
995
- }
996
- async function updateOne(deps, { current, desired }) {
997
- const imageFile = desired.icon !== void 0 && shouldReuploadIcon(current.iconFileHashes, desired.iconFileHashes) ? await deps.readFile(desired.icon["en-us"]) : void 0;
998
- const result = await deps.client.update({
999
- name: desired.name,
1000
- description: desired.description,
1001
- productId: current.outputs.productId,
1002
- universeId: deps.universeId,
1003
- ...imageFile === void 0 ? {} : { imageFile },
1004
- ...derivePriceFields(desired),
1005
- ...desired.isRegionalPricingEnabled === void 0 ? {} : { isRegionalPricingEnabled: desired.isRegionalPricingEnabled },
1006
- ...desired.storePageEnabled === void 0 ? {} : { storePageEnabled: desired.storePageEnabled }
1007
- });
1008
- if (!result.success) return result;
1009
- return {
1010
- data: {
1011
- ...desired,
1012
- outputs: current.outputs
1013
- },
1014
- success: true
1015
- };
966
+ function isGistStateConfig(config) {
967
+ return config.backend === "gist";
1016
968
  }
1017
- //#endregion
1018
- //#region src/adapters/game-pass-driver.ts
969
+ const OPTIONAL_BOOLEAN$2 = "boolean | undefined";
970
+ const OPTIONAL_STRING = "string | undefined";
971
+ const REDACTED_KEY = "redacted?";
972
+ const NON_EMPTY_OVERRIDE_MESSAGE = "a non-empty override object; use `redacted: true` for default placeholders";
1019
973
  /**
1020
- * Wraps {@link GamePassesClient} as a `ResourceDriver<"gamePass">` that maps
1021
- * a desired-state entry to an ocale create call and the response back to a
1022
- * `ResourceCurrentState<"gamePass">`.
1023
- *
1024
- * Upstream `OpenCloudError` results pass through as `Result` failures.
1025
- * Filesystem errors from `deps.readFile` do not fit the `OpenCloudError`
1026
- * shape and propagate as promise rejections; shell callers are expected to
1027
- * translate them if a unified error surface is required.
1028
- *
1029
- * @param deps - Injected ocale client, file reader, and owning universe.
1030
- * @returns A driver indexable by `"gamePass"` in a `DriverRegistry`.
1031
- * @throws Whatever `deps.readFile` rejects with.
1032
- *
1033
- * @example
1034
- *
1035
- * ```ts
1036
- * import type { HttpClient } from "@bedrock-rbx/ocale";
1037
- * import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
1038
- * import {
1039
- * asResourceKey,
1040
- * asRobloxAssetId,
1041
- * asSha256Hex,
1042
- * createGamePassDriver,
1043
- * } from "@bedrock-rbx/core";
1044
- *
1045
- * const httpClient: HttpClient = {
1046
- * async request() {
1047
- * return {
1048
- * data: {
1049
- * body: {
1050
- * createdTimestamp: "2024-01-15T10:30:00.000Z",
1051
- * description: "Grants VIP perks.",
1052
- * gamePassId: 9_876_543_210,
1053
- * iconAssetId: 1_122_334_455,
1054
- * isForSale: true,
1055
- * name: "VIP Pass",
1056
- * updatedTimestamp: "2024-01-15T10:30:00.000Z",
1057
- * },
1058
- * headers: {},
1059
- * status: 200,
1060
- * },
1061
- * success: true,
1062
- * };
1063
- * },
1064
- * };
1065
- *
1066
- * const driver = createGamePassDriver({
1067
- * client: new GamePassesClient({
1068
- * apiKey: "rbx-your-key",
1069
- * httpClient,
1070
- * sleep: async () => {},
1071
- * }),
1072
- * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
1073
- * universeId: asRobloxAssetId("1234567890"),
1074
- * });
1075
- *
1076
- * return driver
1077
- * .create({
1078
- * description: "Grants VIP perks.",
1079
- * icon: { "en-us": "assets/vip-icon.png" },
1080
- * iconFileHashes: {
1081
- * "en-us": asSha256Hex(
1082
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1083
- * ),
1084
- * },
1085
- * key: asResourceKey("vip-pass"),
1086
- * kind: "gamePass",
1087
- * name: "VIP Pass",
1088
- * price: 500,
1089
- * })
1090
- * .then((result) => {
1091
- * expect(result.success).toBeTrue();
1092
- * if (result.success) {
1093
- * expect(result.data.outputs.assetId).toBe("9876543210");
1094
- * }
1095
- * });
1096
- * ```
974
+ * Shared arktype constraint for any optional positive-integer field.
975
+ * Reused by per-kind entry schemas so positive-integer fields validate
976
+ * identically.
1097
977
  */
1098
- function createGamePassDriver(deps) {
1099
- const effective = {
1100
- ...deps,
1101
- readFile: withRedactedIcon(deps.readFile)
1102
- };
1103
- return {
1104
- async create(desired) {
1105
- return createGamePass(effective, desired);
1106
- },
1107
- async update(current, desired) {
1108
- return updateGamePass(effective, {
1109
- current,
1110
- desired
1111
- });
1112
- }
1113
- };
1114
- }
1115
- function toCurrentState$1(desired, data) {
1116
- const { id, iconAssetId } = data;
1117
- if (iconAssetId === void 0) return {
1118
- err: new ApiError("Malformed game pass response: iconAssetId missing after icon upload", { statusCode: 200 }),
1119
- success: false
1120
- };
1121
- return {
1122
- data: {
1123
- ...desired,
1124
- outputs: {
1125
- assetId: asRobloxAssetId(id),
1126
- iconAssetIds: { "en-us": asRobloxAssetId(iconAssetId) }
1127
- }
1128
- },
1129
- success: true
1130
- };
1131
- }
1132
- async function createGamePass(deps, desired) {
1133
- const imageFile = await deps.readFile(desired.icon["en-us"]);
1134
- const result = await deps.client.create({
1135
- name: desired.name,
1136
- description: desired.description,
1137
- imageFile,
1138
- universeId: deps.universeId,
1139
- ...desired.price !== void 0 ? { price: desired.price } : {}
1140
- });
1141
- if (!result.success) return result;
1142
- return toCurrentState$1(desired, result.data);
1143
- }
1144
- async function resolveUpdatedState(deps, context) {
1145
- const { current, desired, hasIconChanged } = context;
1146
- if (!hasIconChanged) return {
1147
- data: {
1148
- ...desired,
1149
- outputs: current.outputs
1150
- },
1151
- success: true
1152
- };
1153
- const fetched = await deps.client.get({
1154
- gamePassId: current.outputs.assetId,
1155
- universeId: deps.universeId
1156
- });
1157
- if (!fetched.success) return fetched;
1158
- return toCurrentState$1(desired, fetched.data);
1159
- }
1160
- async function updateGamePass(deps, states) {
1161
- const { current, desired } = states;
1162
- const hasIconChanged = shouldReuploadIcon(current.iconFileHashes, desired.iconFileHashes);
1163
- const imageFile = hasIconChanged ? await deps.readFile(desired.icon["en-us"]) : void 0;
1164
- const result = await deps.client.update({
1165
- name: desired.name,
1166
- description: desired.description,
1167
- gamePassId: current.outputs.assetId,
1168
- universeId: deps.universeId,
1169
- ...derivePriceFields(desired),
1170
- ...imageFile !== void 0 ? { imageFile } : {}
1171
- });
1172
- if (!result.success) return result;
1173
- return resolveUpdatedState(deps, {
1174
- current,
1175
- desired,
1176
- hasIconChanged
1177
- });
1178
- }
1179
- //#endregion
1180
- //#region src/core/environment.ts
1181
- /**
1182
- * Source pattern for environment names, including `^` and `$` anchors.
1183
- * Letters, digits, `-`, `_`, length 1-64.
1184
- *
1185
- * Exported so the config schema can validate `environments` keys against
1186
- * the same alphabet and length cap that adapters enforce on storage
1187
- * identifiers. Single source of truth: changing the alphabet here changes
1188
- * both the runtime check and the schema-level key constraint.
1189
- *
1190
- * Anchors are embedded so callers do not have to re-add them, matching
1191
- * the `RESOURCE_KEY_PATTERN_SOURCE` convention in `types/ids.ts`.
1192
- */
1193
- const ENV_NAME_PATTERN_SOURCE = "^[A-Za-z0-9_-]{1,64}$";
1194
- const ENVIRONMENT_NAME_PATTERN = new RegExp(ENV_NAME_PATTERN_SOURCE);
978
+ const OPTIONAL_POSITIVE_INTEGER = "(number.integer >= 1) | undefined";
1195
979
  /**
1196
- * Validate an environment name at a state-adapter boundary.
1197
- *
1198
- * Adapters that map environment names onto filesystem-like identifiers
1199
- * (gist filenames, S3 keys) must reject names that could collide or escape
1200
- * their storage layout. This helper accepts letters, digits, `-`, and `_`
1201
- * only, with length between 1 and 64, and returns a `StateError` for
1202
- * anything outside that set so the adapter can fail loudly instead of
1203
- * silently stripping characters.
1204
- *
1205
- * @example
1206
- *
1207
- * ```ts
1208
- * import { validateEnvironmentName } from "@bedrock-rbx/core";
1209
- *
1210
- * const ok = validateEnvironmentName("production");
1211
- * expect(ok.success).toBeTrue();
1212
- *
1213
- * const bad = validateEnvironmentName("prod/staging");
1214
- * expect(bad.success).toBeFalse();
1215
- * ```
1216
- *
1217
- * @param environment - Raw environment name supplied by a caller.
1218
- * @returns `Ok(environment)` when the name is safe to use, or
1219
- * `Err(StateError)` with a descriptive reason when it is not.
980
+ * Shared arktype constraint for any optional Robux-price field. The schema
981
+ * rejects negatives, fractional values, `NaN`, and `Infinity` at config
982
+ * validation time so a malformed price surfaces with a path attributing the
983
+ * failure to the offending field, rather than slipping through to the
984
+ * Roblox API and surfacing as an opaque error at apply time. Per-kind entry
985
+ * schemas reuse this constant so all Robux-price fields validate
986
+ * identically.
1220
987
  */
1221
- function validateEnvironmentName(environment) {
1222
- if (!ENVIRONMENT_NAME_PATTERN.test(environment)) return {
1223
- err: {
1224
- file: environment,
1225
- kind: "stateError",
1226
- reason: `invalid environment name: must match ${String(ENVIRONMENT_NAME_PATTERN)}`
1227
- },
1228
- success: false
1229
- };
1230
- return {
1231
- data: environment,
1232
- success: true
1233
- };
1234
- }
1235
- //#endregion
1236
- //#region src/core/state-file.ts
1237
- const envelopeSchema = type({
1238
- $bedrock: { version: "1" },
1239
- environment: "string",
1240
- resources: type({
1241
- "key": "string",
1242
- "[string]": "unknown",
1243
- "kind": "'developerProduct' | 'gamePass' | 'place' | 'universe'",
1244
- "outputs": "object"
1245
- }).array()
988
+ const OPTIONAL_ROBUX_PRICE = "number.integer >= 0 | undefined";
989
+ const gamePassRedacted = type({
990
+ "description?": "string",
991
+ "icon?": iconMap,
992
+ "name?": "string",
993
+ "price?": OPTIONAL_ROBUX_PRICE
994
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
995
+ if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
996
+ return true;
997
+ }).or(OPTIONAL_BOOLEAN$2);
998
+ const placeRedacted = type({
999
+ "description?": "string",
1000
+ "displayName?": "string"
1001
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
1002
+ if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
1003
+ return true;
1004
+ }).or(OPTIONAL_BOOLEAN$2);
1005
+ const productRedacted = type({
1006
+ "description?": "string",
1007
+ "icon?": iconMap,
1008
+ "name?": "string",
1009
+ "price?": OPTIONAL_ROBUX_PRICE
1010
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
1011
+ if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
1012
+ return true;
1013
+ }).or(OPTIONAL_BOOLEAN$2);
1014
+ const environmentRedacted = type({
1015
+ "description?": "string",
1016
+ "displayName?": "string",
1017
+ "icon?": iconMap,
1018
+ "name?": "string",
1019
+ "price?": OPTIONAL_ROBUX_PRICE
1020
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
1021
+ if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
1022
+ return true;
1023
+ }).or(OPTIONAL_BOOLEAN$2);
1024
+ const gamePassEntry = type({
1025
+ "name": "string",
1026
+ "description": "string",
1027
+ "icon": iconMap,
1028
+ "price?": OPTIONAL_ROBUX_PRICE,
1029
+ [REDACTED_KEY]: gamePassRedacted
1246
1030
  });
1247
- /**
1248
- * Serialize a {@link BedrockState} to the on-disk JSON representation used by
1249
- * state-port adapters.
1250
- *
1251
- * The on-disk shape wraps the in-memory state with a
1252
- * `$bedrock: { version: N }` envelope so that a future breaking change to the
1253
- * schema can be detected and rejected at parse time rather than silently
1254
- * accepted. The top-level `version` field is not duplicated on disk.
1255
- *
1256
- * @example
1257
- *
1258
- * ```ts
1259
- * import { serializeStateFile, type BedrockState } from "@bedrock-rbx/core";
1260
- *
1261
- * const state: BedrockState = {
1262
- * environment: "production",
1263
- * resources: [],
1264
- * version: 1,
1265
- * };
1266
- *
1267
- * const wire = serializeStateFile(state);
1268
- * expect(JSON.parse(wire)).toStrictEqual({
1269
- * $bedrock: { version: 1 },
1270
- * environment: "production",
1271
- * resources: [],
1272
- * });
1273
- * ```
1274
- *
1275
- * @param state - The in-memory state snapshot to serialize.
1276
- * @returns A pretty-printed JSON string ready to hand to a state adapter's write method.
1277
- */
1278
- function serializeStateFile(state) {
1279
- const envelope = {
1280
- $bedrock: { version: state.version },
1281
- environment: state.environment,
1282
- resources: state.resources
1283
- };
1284
- return JSON.stringify(envelope, void 0, 2);
1285
- }
1286
- /**
1287
- * Parse a raw on-disk state file into a {@link BedrockState}.
1288
- *
1289
- * A backend that reports "no state file for this environment yet" must pass
1290
- * `undefined`: that distinguishes a legitimate first deploy from a file that
1291
- * exists but cannot be trusted.
1292
- *
1293
- * @example
1294
- *
1295
- * ```ts
1296
- * import { parseStateFile } from "@bedrock-rbx/core";
1297
- *
1298
- * const freshStart = parseStateFile(undefined, "gist:abc123/state.production.json");
1299
- * expect(freshStart.success).toBeTrue();
1300
- * if (freshStart.success) {
1301
- * expect(freshStart.data).toBeUndefined();
1302
- * }
1303
- * ```
1304
- *
1305
- * @param raw - Raw file contents as a string, or `undefined` when the
1306
- * backend reports no file exists yet.
1307
- * @param file - Adapter-specific identifier included in any `StateError`
1308
- * surfaced during parsing.
1309
- * @returns `Ok(undefined)` for a missing file, `Ok(state)` for a parseable
1310
- * file, or `Err(StateError)` for anything that cannot be trusted.
1311
- */
1312
- function parseStateFile(raw, file) {
1313
- if (raw === void 0) return {
1314
- data: void 0,
1315
- success: true
1316
- };
1317
- const parsed = parseJson(raw, file);
1318
- if (!parsed.success) return parsed;
1319
- const validated = envelopeSchema(parsed.data);
1320
- if (validated instanceof ArkErrors) return errState(file, `invalid state file: ${validated.summary}`);
1321
- const resources = validated.resources;
1322
- return {
1323
- data: {
1324
- environment: validated.environment,
1325
- resources,
1326
- version: 1
1327
- },
1328
- success: true
1329
- };
1330
- }
1331
- function parseJson(raw, file) {
1332
- try {
1333
- return {
1334
- data: JSON.parse(raw),
1335
- success: true
1336
- };
1337
- } catch (err) {
1338
- return {
1339
- err: {
1340
- file,
1341
- kind: "stateError",
1342
- reason: `malformed JSON: ${err instanceof Error ? err.message : String(err)}`
1343
- },
1344
- success: false
1345
- };
1346
- }
1347
- }
1348
- function errState(file, reason) {
1349
- return {
1350
- err: {
1351
- file,
1352
- kind: "stateError",
1353
- reason
1354
- },
1355
- success: false
1356
- };
1357
- }
1358
- //#endregion
1359
- //#region src/adapters/gist-state-adapter.ts
1360
- const GITHUB_API_BASE = "https://api.github.com";
1361
- const GITHUB_API_VERSION = "2026-03-10";
1362
- const USER_AGENT = "bedrock";
1363
- const MAX_INLINE_BYTES = 1e7;
1364
- const MAX_RETRIES = 3;
1365
- const RETRYABLE_STATUSES = new Set([
1366
- 409,
1367
- 502,
1368
- 503,
1369
- 504
1370
- ]);
1371
- const MAX_VISIBILITY_ATTEMPTS = 5;
1372
- const VISIBILITY_BASE_DELAY_MS = 250;
1373
- /**
1374
- * Build a `StatePort` that persists Bedrock state in a GitHub Gist.
1375
- *
1376
- * One gist holds one file per environment, named `state.<env>.json`. The
1377
- * adapter authenticates with a user-supplied token and speaks the GitHub
1378
- * REST API directly; no SDK dependency.
1379
- *
1380
- * @example
1381
- *
1382
- * ```ts
1383
- * import { createGistStateAdapter } from "@bedrock-rbx/core";
1384
- *
1385
- * const port = createGistStateAdapter({
1386
- * fetch: async () =>
1387
- * new Response(JSON.stringify({ files: {} }), { status: 200 }),
1388
- * gistId: "abc123def456",
1389
- * token: "ghp_example",
1390
- * });
1391
- *
1392
- * return port.read("production").then((result) => {
1393
- * expect(result.success).toBeTrue();
1394
- * if (result.success) {
1395
- * expect(result.data).toBeUndefined();
1396
- * }
1397
- * });
1398
- * ```
1399
- *
1400
- * @param deps - Gist ID, GitHub token, and optional fetch override.
1401
- * @returns A `StatePort` ready to be passed to `deploy()`.
1402
- */
1403
- function createGistStateAdapter(deps) {
1404
- const ctx = {
1405
- fetchFn: deps.fetch ?? globalThis.fetch.bind(globalThis),
1406
- gistId: deps.gistId,
1407
- sleep: deps.sleep ?? defaultSleep,
1408
- token: deps.token
1409
- };
1410
- return {
1411
- async read(environment) {
1412
- const safe = validateEnvironmentName(environment);
1413
- if (!safe.success) return safe;
1414
- return readPath(ctx, safe.data);
1415
- },
1416
- async write(state) {
1417
- const safe = validateEnvironmentName(state.environment);
1418
- if (!safe.success) return safe;
1419
- return writePath(ctx, state);
1420
- }
1421
- };
1422
- }
1423
- async function defaultSleep(ms) {
1424
- await new Promise((resolve) => {
1425
- setTimeout(resolve, ms);
1426
- });
1427
- }
1428
- function fileLabel(gistId, environment) {
1429
- return `gist:${gistId}/state.${environment}.json`;
1430
- }
1431
- function fileName(environment) {
1432
- return `state.${environment}.json`;
1433
- }
1434
- function toGistFile(entry) {
1435
- if (typeof entry !== "object" || entry === null) return;
1436
- const record = entry;
1437
- const content = typeof record["content"] === "string" ? record["content"] : void 0;
1438
- const rawUrl = typeof record["raw_url"] === "string" ? record["raw_url"] : void 0;
1439
- const size = typeof record["size"] === "number" ? record["size"] : 0;
1440
- return {
1441
- content,
1442
- isTruncated: record["truncated"] === true,
1443
- rawUrl,
1444
- size
1445
- };
1446
- }
1447
- function mapHttpError({ file, gistId, status }) {
1448
- if (status === 404) return {
1449
- file,
1450
- kind: "stateError",
1451
- reason: `gist ${gistId} not found: check gistId`
1452
- };
1453
- if (status === 401 || status === 403) return {
1454
- file,
1455
- kind: "stateError",
1456
- reason: `auth failed (${status}): check token scopes`
1457
- };
1458
- return {
1459
- file,
1460
- kind: "stateError",
1461
- reason: `github returned ${status}`
1462
- };
1463
- }
1464
- function networkError(error, file) {
1465
- return {
1466
- file,
1467
- kind: "stateError",
1468
- reason: `network error: ${error instanceof Error ? error.message : String(error)}`
1469
- };
1470
- }
1471
- function buildHeaders(token) {
1472
- const headers = new Headers();
1473
- headers.set("Accept", "application/vnd.github+json");
1474
- headers.set("Authorization", `Bearer ${token}`);
1475
- headers.set("User-Agent", USER_AGENT);
1476
- headers.set("X-GitHub-Api-Version", GITHUB_API_VERSION);
1477
- return headers;
1478
- }
1479
- async function sendGet(ctx) {
1480
- return ctx.fetchFn(`${GITHUB_API_BASE}/gists/${ctx.gistId}`, {
1481
- headers: buildHeaders(ctx.token),
1482
- method: "GET"
1483
- });
1484
- }
1485
- function isRetryableStatus(status) {
1486
- return RETRYABLE_STATUSES.has(status);
1487
- }
1488
- function backoffMs(attempt) {
1489
- return 1e3 * 2 ** attempt;
1490
- }
1491
- async function withRetry(sleep, operation) {
1492
- let response = await operation();
1493
- for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
1494
- if (response.ok || !isRetryableStatus(response.status)) return response;
1495
- await sleep(backoffMs(attempt));
1496
- response = await operation();
1497
- }
1498
- return response;
1499
- }
1500
- async function fetchGistBody(ctx, file) {
1501
- let response;
1502
- try {
1503
- response = await withRetry(ctx.sleep, async () => sendGet(ctx));
1504
- } catch (err) {
1505
- return {
1506
- err: networkError(err, file),
1507
- success: false
1508
- };
1509
- }
1510
- if (!response.ok) return {
1511
- err: mapHttpError({
1512
- file,
1513
- gistId: ctx.gistId,
1514
- status: response.status
1515
- }),
1516
- success: false
1517
- };
1518
- return {
1519
- data: await response.json(),
1520
- success: true
1521
- };
1522
- }
1523
- function stateErr(file, reason) {
1524
- return {
1031
+ const passesCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: gamePassEntry }).onUndeclaredKey("reject");
1032
+ const developerProductEntry = type({
1033
+ "name": "string",
1034
+ "description": "string",
1035
+ "icon?": iconMap,
1036
+ "isRegionalPricingEnabled?": OPTIONAL_BOOLEAN$2,
1037
+ "price?": OPTIONAL_ROBUX_PRICE,
1038
+ [REDACTED_KEY]: productRedacted,
1039
+ "storePageEnabled?": OPTIONAL_BOOLEAN$2
1040
+ }).onUndeclaredKey("reject");
1041
+ const productsCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: developerProductEntry }).onUndeclaredKey("reject");
1042
+ const ROBLOX_ID_DIGITS = "string.digits";
1043
+ const placeEntry = type({
1044
+ "description?": OPTIONAL_STRING,
1045
+ "displayName?": OPTIONAL_STRING,
1046
+ "filePath": "string",
1047
+ [REDACTED_KEY]: placeRedacted,
1048
+ "serverSize?": OPTIONAL_POSITIVE_INTEGER
1049
+ }).onUndeclaredKey("reject");
1050
+ const placesCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: placeEntry }).onUndeclaredKey("reject");
1051
+ const socialLinkOrUndefined$1 = type({
1052
+ title: "string",
1053
+ uri: "string"
1054
+ }).onUndeclaredKey("reject").or("undefined");
1055
+ const universeEntry = type({
1056
+ "consoleEnabled?": OPTIONAL_BOOLEAN$2,
1057
+ "desktopEnabled?": OPTIONAL_BOOLEAN$2,
1058
+ "discordSocialLink?": socialLinkOrUndefined$1,
1059
+ "displayName?": OPTIONAL_STRING,
1060
+ "facebookSocialLink?": socialLinkOrUndefined$1,
1061
+ "guildedSocialLink?": socialLinkOrUndefined$1,
1062
+ "mobileEnabled?": OPTIONAL_BOOLEAN$2,
1063
+ "privateServerPriceRobux?": OPTIONAL_ROBUX_PRICE,
1064
+ "robloxGroupSocialLink?": socialLinkOrUndefined$1,
1065
+ "tabletEnabled?": OPTIONAL_BOOLEAN$2,
1066
+ "twitchSocialLink?": socialLinkOrUndefined$1,
1067
+ "twitterSocialLink?": socialLinkOrUndefined$1,
1068
+ "universeId?": ROBLOX_ID_DIGITS,
1069
+ "voiceChatEnabled?": OPTIONAL_BOOLEAN$2,
1070
+ "vrEnabled?": OPTIONAL_BOOLEAN$2,
1071
+ "youtubeSocialLink?": socialLinkOrUndefined$1
1072
+ }).onUndeclaredKey("reject");
1073
+ const stateConfig = type({
1074
+ "backend": "string",
1075
+ "gistId?": "string > 0"
1076
+ }).onUndeclaredKey("reject");
1077
+ const gamePassOverlay = type({
1078
+ "description?": "string",
1079
+ "icon?": iconMap,
1080
+ "name?": "string",
1081
+ "price?": OPTIONAL_ROBUX_PRICE,
1082
+ [REDACTED_KEY]: gamePassRedacted
1083
+ }).onUndeclaredKey("reject");
1084
+ const passesOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: gamePassOverlay }).onUndeclaredKey("reject");
1085
+ const developerProductOverlay = type({
1086
+ "description?": "string",
1087
+ "icon?": iconMap,
1088
+ "isRegionalPricingEnabled?": OPTIONAL_BOOLEAN$2,
1089
+ "name?": "string",
1090
+ "price?": OPTIONAL_ROBUX_PRICE,
1091
+ [REDACTED_KEY]: productRedacted,
1092
+ "storePageEnabled?": OPTIONAL_BOOLEAN$2
1093
+ }).onUndeclaredKey("reject");
1094
+ const productsOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: developerProductOverlay }).onUndeclaredKey("reject");
1095
+ const placeOverlay = type({
1096
+ "description?": OPTIONAL_STRING,
1097
+ "displayName?": OPTIONAL_STRING,
1098
+ "filePath?": "string",
1099
+ "placeId": ROBLOX_ID_DIGITS,
1100
+ [REDACTED_KEY]: placeRedacted,
1101
+ "serverSize?": OPTIONAL_POSITIVE_INTEGER
1102
+ }).onUndeclaredKey("reject");
1103
+ const placesOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: placeOverlay }).onUndeclaredKey("reject");
1104
+ const universeOverlay = universeEntry;
1105
+ const environmentEntry = type({
1106
+ "label?": OPTIONAL_STRING,
1107
+ "passes?": passesOverlayCollection,
1108
+ "places?": placesOverlayCollection,
1109
+ "products?": productsOverlayCollection,
1110
+ [REDACTED_KEY]: environmentRedacted,
1111
+ "state?": stateConfig,
1112
+ "universe?": universeOverlay
1113
+ }).onUndeclaredKey("reject");
1114
+ const rootSchema = type({
1115
+ "displayNamePrefix?": type({
1116
+ "enabled?": OPTIONAL_BOOLEAN$2,
1117
+ "format?": OPTIONAL_STRING
1118
+ }).onUndeclaredKey("reject"),
1119
+ "environments": type({ [`[/${ENV_NAME_PATTERN_SOURCE}/]`]: environmentEntry }).onUndeclaredKey("reject").narrow((value, ctx) => {
1120
+ if (Object.keys(value).length === 0) return ctx.mustBe("an environments record with at least one declared environment");
1121
+ return true;
1122
+ }),
1123
+ "extends?": "unknown",
1124
+ "passes?": passesCollection,
1125
+ "places?": placesCollection,
1126
+ "products?": productsCollection,
1127
+ "state?": stateConfig,
1128
+ "universe?": universeEntry
1129
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
1130
+ return collectUniverseIdIssues(value).reduce((_accumulator, issue) => {
1131
+ return ctx.reject({
1132
+ message: issue.message,
1133
+ path: [...issue.path]
1134
+ });
1135
+ }, true);
1136
+ });
1137
+ /**
1138
+ * Validate a parsed config value against the runtime schema. Returns the
1139
+ * validated `Config` on success or a `validationFailed` `ConfigError` with
1140
+ * one issue per problem, each attributed to a field path. `sourceFile`
1141
+ * appears in the error so callers can point a human at the offending file.
1142
+ *
1143
+ * @param input - Parsed value from a config source (object tree from a
1144
+ * config loader, or a hand-built literal). Shape is checked, not assumed.
1145
+ * @param sourceFile - Path or identifier of the source file, used in the
1146
+ * `validationFailed` error.
1147
+ * @returns `Ok` with the validated `Config`, or `Err` with a
1148
+ * `validationFailed` error carrying each issue's field path.
1149
+ * @example
1150
+ *
1151
+ * ```ts
1152
+ * import { validateConfig } from "@bedrock-rbx/core";
1153
+ *
1154
+ * const ok = validateConfig(
1155
+ * {
1156
+ * environments: { production: {} },
1157
+ * passes: {
1158
+ * "vip-pass": {
1159
+ * description: "VIP perks.",
1160
+ * icon: { "en-us": "assets/vip.png" },
1161
+ * name: "VIP Pass",
1162
+ * price: 500,
1163
+ * },
1164
+ * },
1165
+ * },
1166
+ * "bedrock.config.ts",
1167
+ * );
1168
+ * expect(ok.success).toBeTrue();
1169
+ *
1170
+ * const err = validateConfig(
1171
+ * { environments: { production: {} }, passes: { "vip-pass": { name: "VIP" } } },
1172
+ * "bedrock.config.ts",
1173
+ * );
1174
+ * expect(err.success).toBeFalse();
1175
+ * if (!err.success) {
1176
+ * expect(err.err.kind).toBe("validationFailed");
1177
+ * }
1178
+ * ```
1179
+ */
1180
+ function validateConfig(input, sourceFile) {
1181
+ const validated = rootSchema(input);
1182
+ if (validated instanceof ArkErrors) return {
1525
1183
  err: {
1526
- file,
1527
- kind: "stateError",
1528
- reason
1184
+ issues: Array.from(validated, (issue) => {
1185
+ return {
1186
+ message: issue.message,
1187
+ path: [...issue.path].map((segment) => String(segment))
1188
+ };
1189
+ }),
1190
+ kind: "validationFailed",
1191
+ sourceFile
1529
1192
  },
1530
1193
  success: false
1531
1194
  };
1532
- }
1533
- async function readGistContent({ entry, fetchFn, file, sleep }) {
1534
- if (entry.size > MAX_INLINE_BYTES) return stateErr(file, `state file too large: ${entry.size} bytes`);
1535
- if (entry.isTruncated) {
1536
- if (entry.rawUrl === void 0) return stateErr(file, "truncated gist file missing raw_url");
1537
- const { rawUrl } = entry;
1538
- let rawResponse;
1539
- try {
1540
- rawResponse = await withRetry(sleep, async () => fetchFn(rawUrl));
1541
- } catch (err) {
1542
- return {
1543
- err: networkError(err, file),
1544
- success: false
1545
- };
1546
- }
1547
- if (!rawResponse.ok) return stateErr(file, `raw_url fetch returned ${rawResponse.status}`);
1548
- return parseStateFile(await rawResponse.text(), file);
1549
- }
1550
- return parseStateFile(entry.content, file);
1551
- }
1552
- async function readPath(ctx, environment) {
1553
- const file = fileLabel(ctx.gistId, environment);
1554
- const gist = await fetchGistBody(ctx, file);
1555
- if (!gist.success) return gist;
1556
- const files = gist.data["files"];
1557
- const entry = toGistFile(files?.[fileName(environment)]);
1558
- if (entry === void 0) return {
1559
- data: void 0,
1195
+ return {
1196
+ data: validated,
1560
1197
  success: true
1561
1198
  };
1562
- return readGistContent({
1563
- entry,
1564
- fetchFn: ctx.fetchFn,
1565
- file,
1566
- sleep: ctx.sleep
1567
- });
1568
- }
1569
- async function sendPatch(ctx, body) {
1570
- const headers = buildHeaders(ctx.token);
1571
- headers.set("Content-Type", "application/json");
1572
- return ctx.fetchFn(`${GITHUB_API_BASE}/gists/${ctx.gistId}`, {
1573
- body,
1574
- headers,
1575
- method: "PATCH"
1576
- });
1577
- }
1578
- async function isFileVisible(ctx, target) {
1579
- try {
1580
- const response = await sendGet(ctx);
1581
- const body = JSON.parse(await response.text());
1582
- const files = Reflect.get(body, "files");
1583
- return typeof files === "object" && files !== null && target in files;
1584
- } catch {
1585
- return false;
1586
- }
1587
1199
  }
1200
+ //#endregion
1201
+ //#region src/adapters/clack-progress-adapter.ts
1588
1202
  /**
1589
- * Polls the gist until the just-written environment file is visible on a
1590
- * GET, with bounded retries. GitHub's gist API does not guarantee
1591
- * read-your-write across replicas: a GET issued immediately after a
1592
- * successful PATCH can return a body that omits the new file. The poll
1593
- * pre-warms the cache the consumer's next read will hit, so a successful
1594
- * write honours read-after-write at the port boundary.
1203
+ * Build a {@link ProgressPort} that renders events through a `ClackPort`.
1204
+ * Pattern-matches on the event `kind`: per-resource events render one line each,
1205
+ * the aggregate `applySummary` becomes the deploy footer, and `stateWritten`
1206
+ * names the persistence backend resolved from the loaded `Config`.
1595
1207
  *
1596
- * Best-effort: resolves after exhausting the visibility budget regardless
1597
- * of whether the file became visible. The PATCH already committed; the
1598
- * poll only narrows the window in which subsequent reads can lag.
1208
+ * @example
1599
1209
  *
1600
- * @param ctx - Adapter context carrying the injected fetch and sleep seams.
1601
- * @param environment - Environment name whose file is being verified.
1210
+ * ```ts
1211
+ * import { createClackProgressAdapter, type ClackPort } from "@bedrock-rbx/core";
1212
+ *
1213
+ * const lines: Array<string> = [];
1214
+ * const clack: ClackPort = {
1215
+ * cancel: (message) => lines.push(`cancel: ${message}`),
1216
+ * intro: (message) => lines.push(`intro: ${message}`),
1217
+ * logError: (message) => lines.push(`error: ${message}`),
1218
+ * logMessage: (message) => lines.push(`log: ${message}`),
1219
+ * logSuccess: (message) => lines.push(`ok: ${message}`),
1220
+ * outro: (message) => lines.push(`outro: ${message}`),
1221
+ * };
1222
+ *
1223
+ * const port = createClackProgressAdapter({ clack });
1224
+ *
1225
+ * port.emit({ environment: "production", kind: "stateWritten" });
1226
+ *
1227
+ * expect(lines).toEqual(["log: State written to state"]);
1228
+ * ```
1229
+ *
1230
+ * @param deps - The clack port and optional config the adapter renders through.
1231
+ * @returns A `ProgressPort` that renders via clack.
1602
1232
  */
1603
- async function waitForFileVisibility(ctx, environment) {
1604
- const target = fileName(environment);
1605
- for (let attempt = 0; attempt < MAX_VISIBILITY_ATTEMPTS; attempt += 1) {
1606
- if (await isFileVisible(ctx, target)) return;
1607
- if (attempt < MAX_VISIBILITY_ATTEMPTS - 1) await ctx.sleep(VISIBILITY_BASE_DELAY_MS * 2 ** attempt);
1233
+ function createClackProgressAdapter(deps) {
1234
+ return { emit(event) {
1235
+ renderEvent(event, deps);
1236
+ } };
1237
+ }
1238
+ function applySummaryLine(event) {
1239
+ return `Succeeded in ${(event.durationMs / 1e3).toFixed(1)}s: ${[
1240
+ `${event.created} create`,
1241
+ `${event.updated} update`,
1242
+ `${event.noop} noop`,
1243
+ `${event.failed} failed`
1244
+ ].join(", ")}`;
1245
+ }
1246
+ function stateConfigLabel(state) {
1247
+ if (isGistStateConfig(state)) return `gist:${state.gistId}`;
1248
+ return state.backend;
1249
+ }
1250
+ function formatStateLabel(config, environment) {
1251
+ if (config === void 0) return "state";
1252
+ const resolved = resolveStateConfig(config, environment);
1253
+ if (!resolved.success) return "state";
1254
+ return stateConfigLabel(resolved.data);
1255
+ }
1256
+ function extractResourceId(event) {
1257
+ switch (event.resourceKind) {
1258
+ case "developerProduct": return event.outputs.productId;
1259
+ case "gamePass": return event.outputs.assetId;
1260
+ case "place": return;
1261
+ case "universe": return event.outputs.rootPlaceId;
1608
1262
  }
1609
1263
  }
1610
- async function writePath(ctx, state) {
1611
- const file = fileLabel(ctx.gistId, state.environment);
1612
- const body = JSON.stringify({ files: { [fileName(state.environment)]: { content: serializeStateFile(state) } } });
1613
- let response;
1614
- try {
1615
- response = await withRetry(ctx.sleep, async () => sendPatch(ctx, body));
1616
- } catch (err) {
1617
- return {
1618
- err: networkError(err, file),
1619
- success: false
1620
- };
1264
+ function renderResourceOpSucceeded(event, clack) {
1265
+ if (event.opType === "create") {
1266
+ const id = extractResourceId(event);
1267
+ const suffix = id === void 0 ? "" : ` (id ${id})`;
1268
+ clack.logSuccess(`${event.resourceKind}.${event.key} created${suffix}`);
1269
+ return;
1621
1270
  }
1622
- if (response.ok) {
1623
- try {
1624
- await waitForFileVisibility(ctx, state.environment);
1625
- } catch {}
1626
- return {
1627
- data: void 0,
1628
- success: true
1629
- };
1271
+ clack.logSuccess(`${event.resourceKind}.${event.key} ${event.changedFields.join(", ")} updated`);
1272
+ }
1273
+ function describeApplyError(error) {
1274
+ switch (error.kind) {
1275
+ case "driverFailure": return `failed: ${error.cause.message}`;
1276
+ case "unexpectedThrow": return "unexpected error";
1277
+ case "updateUnsupported": return "update not supported";
1278
+ }
1279
+ }
1280
+ function renderEvent(event, deps) {
1281
+ const { clack, config } = deps;
1282
+ switch (event.kind) {
1283
+ case "applySummary":
1284
+ clack.logMessage(applySummaryLine(event));
1285
+ return;
1286
+ case "deployFailure":
1287
+ renderDeployError(event.error, clack);
1288
+ return;
1289
+ case "deploySuccess":
1290
+ clack.logSuccess(`${event.environment}: ${event.resourceCount} resources reconciled`);
1291
+ return;
1292
+ case "resourceOpFailed":
1293
+ clack.logError(`${event.resourceKind}.${event.key} ${describeApplyError(event.error)}`);
1294
+ return;
1295
+ case "resourceOpNoop":
1296
+ clack.logMessage(`${event.resourceKind}.${event.key} unchanged`);
1297
+ return;
1298
+ case "resourceOpStarted": return;
1299
+ case "resourceOpSucceeded":
1300
+ renderResourceOpSucceeded(event, clack);
1301
+ return;
1302
+ case "stateWritten": clack.logMessage(`State written to ${formatStateLabel(config, event.environment)}`);
1630
1303
  }
1631
- if (response.status === 422) return stateErr(file, "invalid PATCH body sent to github");
1632
- return {
1633
- err: mapHttpError({
1634
- file,
1635
- gistId: ctx.gistId,
1636
- status: response.status
1637
- }),
1638
- success: false
1639
- };
1640
1304
  }
1641
1305
  //#endregion
1642
- //#region src/core/resources.ts
1643
- /**
1644
- * Ordered list of optional metadata fields the driver routes through
1645
- * `PlacesClient.update`. Iterated by `placeKind.fieldsEqual` and the place
1646
- * driver's parameter translator so drift detection and the constructed
1647
- * `updateMask` cannot drift apart.
1648
- */
1649
- const PLACE_MANAGED_METADATA_FIELDS = [
1650
- "displayName",
1651
- "description",
1652
- "serverSize"
1653
- ];
1306
+ //#region src/core/derive-price-fields.ts
1654
1307
  /**
1655
- * Ordered list of optional boolean managed fields on {@link UniverseDesiredState}.
1308
+ * Translate a Mantle-style optional price into the Open Cloud wire shape.
1656
1309
  *
1657
- * The driver translator and the diff's per-field equality guard both iterate
1658
- * this list so they cannot drift apart. Order drives `updateMask` sequence in
1659
- * generated requests.
1660
- */
1661
- const UNIVERSE_MANAGED_FLAGS = [
1662
- "desktopEnabled",
1663
- "mobileEnabled",
1664
- "tabletEnabled",
1665
- "consoleEnabled",
1666
- "vrEnabled",
1667
- "voiceChatEnabled"
1668
- ];
1669
- /**
1670
- * Tuple of every social link field name on {@link UniverseDesiredState}.
1671
- * Iterated by flatten, driver, and diff to handle the tri-state clearable
1672
- * semantics uniformly across all seven fields.
1310
+ * `desired.price === undefined` (no price declared) becomes
1311
+ * `{ isForSale: false }` and the `price` key is omitted entirely. A defined
1312
+ * price (including `0`) becomes `{ isForSale: true, price }`. Both
1313
+ * `developerProduct` create and update paths share this helper so the
1314
+ * "absent off-sale" semantics live in exactly one place.
1315
+ *
1316
+ * @param desired - Object carrying the user-declared `price`.
1317
+ * @returns The wire-shape `{ isForSale, price? }` fragment.
1318
+ *
1319
+ * @example
1320
+ *
1321
+ * ```ts
1322
+ * import { derivePriceFields } from "@bedrock-rbx/core";
1323
+ *
1324
+ * expect(derivePriceFields({ price: undefined })).toStrictEqual({ isForSale: false });
1325
+ * expect(derivePriceFields({ price: 250 })).toStrictEqual({ isForSale: true, price: 250 });
1326
+ * ```
1673
1327
  */
1674
- const SOCIAL_LINK_FIELDS = [
1675
- "discordSocialLink",
1676
- "facebookSocialLink",
1677
- "guildedSocialLink",
1678
- "robloxGroupSocialLink",
1679
- "twitchSocialLink",
1680
- "twitterSocialLink",
1681
- "youtubeSocialLink"
1682
- ];
1328
+ function derivePriceFields(desired) {
1329
+ if (desired.price === void 0) return { isForSale: false };
1330
+ return {
1331
+ isForSale: true,
1332
+ price: desired.price
1333
+ };
1334
+ }
1335
+ //#endregion
1336
+ //#region src/core/plan-follow-up-patch.ts
1683
1337
  /**
1684
- * Copy every social link field that is present as a key on `source`,
1685
- * preserving the tri-state distinction between "key absent" (unmanaged,
1686
- * omitted from result) and "key present with `undefined`" (cleared,
1687
- * forwarded as-is). Shared by flatten, build-desired, and the universe
1688
- * driver so all three layers propagate the same tri-state semantics.
1338
+ * Plan the optional follow-up PATCH body needed after a developer-product
1339
+ * create POST. Returns `undefined` when no PATCH is required: either the
1340
+ * user did not declare `storePageEnabled`, or the create response already
1341
+ * matches the desired value.
1689
1342
  *
1690
- * @param source - Object whose declared social link keys should be copied.
1691
- * @returns Partial record containing only the social link keys present on
1692
- * `source`; absent keys stay absent.
1343
+ * @param desired - Desired state for the developer product being created.
1344
+ * @param createResponse - The `storePageEnabled` value reported by the create POST response.
1345
+ * @returns The PATCH body to issue, or `undefined` when no follow-up is needed.
1693
1346
  */
1694
- function copyDeclaredSocialLinks(source) {
1695
- const copied = {};
1696
- for (const field of SOCIAL_LINK_FIELDS) if (field in source) copied[field] = source[field];
1697
- return copied;
1347
+ function planFollowUpPatch(desired, createResponse) {
1348
+ if (desired.storePageEnabled === void 0) return;
1349
+ if (desired.storePageEnabled === createResponse.storePageEnabled) return;
1350
+ return { storePageEnabled: desired.storePageEnabled };
1698
1351
  }
1352
+ //#endregion
1353
+ //#region src/adapters/developer-product-driver.ts
1699
1354
  /**
1700
- * Fixed stable key for the singleton universe resource. `flattenConfig`
1701
- * stamps this onto the sole `UniverseDesiredInput` it emits; fixtures and
1702
- * state adapters share the constant so the invariant is encoded once.
1355
+ * Wraps {@link DeveloperProductsClient} as a `ResourceDriver<"developerProduct">`
1356
+ * that maps a desired-state entry to an ocale create or update call and the
1357
+ * response back to a `ResourceCurrentState<"developerProduct">`. The
1358
+ * `update` path consumes the upstream `204 No Content` response and
1359
+ * synthesizes the post-update `ResourceCurrentState` from `desired` plus
1360
+ * the existing `current.outputs`, carrying `iconImageAssetId` forward when
1361
+ * present.
1362
+ *
1363
+ * Upstream `OpenCloudError` results pass through as `Result` failures.
1364
+ *
1365
+ * @param deps - Injected ocale client and owning universe.
1366
+ * @returns A driver indexable by `"developerProduct"` in a `DriverRegistry`.
1703
1367
  *
1704
1368
  * @example
1705
1369
  *
1706
1370
  * ```ts
1707
- * import { UNIVERSE_SINGLETON_KEY } from "@bedrock-rbx/core";
1371
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
1372
+ * import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
1373
+ * import {
1374
+ * asResourceKey,
1375
+ * asRobloxAssetId,
1376
+ * createDeveloperProductDriver,
1377
+ * } from "@bedrock-rbx/core";
1708
1378
  *
1709
- * expect(UNIVERSE_SINGLETON_KEY).toBe("main");
1379
+ * const httpClient: HttpClient = {
1380
+ * async request() {
1381
+ * return {
1382
+ * data: {
1383
+ * body: {
1384
+ * createdTimestamp: "2024-01-15T10:30:00.000Z",
1385
+ * description: "Stocks the player up with 1,000 premium gems.",
1386
+ * iconImageAssetId: null,
1387
+ * isForSale: false,
1388
+ * isImmutable: false,
1389
+ * name: "Gem Pack",
1390
+ * priceInformation: null,
1391
+ * productId: 9_876_543_210,
1392
+ * storePageEnabled: false,
1393
+ * universeId: 1_234_567_890,
1394
+ * updatedTimestamp: "2024-01-15T10:30:00.000Z",
1395
+ * },
1396
+ * headers: {},
1397
+ * status: 200,
1398
+ * },
1399
+ * success: true,
1400
+ * };
1401
+ * },
1402
+ * };
1403
+ *
1404
+ * const driver = createDeveloperProductDriver({
1405
+ * client: new DeveloperProductsClient({
1406
+ * apiKey: "rbx-your-key",
1407
+ * httpClient,
1408
+ * sleep: async () => {},
1409
+ * }),
1410
+ * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
1411
+ * universeId: asRobloxAssetId("1234567890"),
1412
+ * });
1413
+ *
1414
+ * return driver
1415
+ * .create({
1416
+ * description: "Stocks the player up with 1,000 premium gems.",
1417
+ * isRegionalPricingEnabled: undefined,
1418
+ * key: asResourceKey("gem-pack"),
1419
+ * kind: "developerProduct",
1420
+ * name: "Gem Pack",
1421
+ * price: undefined,
1422
+ * storePageEnabled: undefined,
1423
+ * })
1424
+ * .then((result) => {
1425
+ * expect(result.success).toBeTrue();
1426
+ * if (result.success) {
1427
+ * expect(result.data.outputs.productId).toBe("9876543210");
1428
+ * }
1429
+ * });
1710
1430
  * ```
1711
1431
  */
1712
- const UNIVERSE_SINGLETON_KEY = asResourceKey("main");
1432
+ function createDeveloperProductDriver(deps) {
1433
+ const effective = {
1434
+ ...deps,
1435
+ readFile: withRedactedIcon(deps.readFile)
1436
+ };
1437
+ return {
1438
+ async create(desired) {
1439
+ return createOne(effective, desired);
1440
+ },
1441
+ async update(current, desired) {
1442
+ return updateOne(effective, {
1443
+ current,
1444
+ desired
1445
+ });
1446
+ }
1447
+ };
1448
+ }
1449
+ function toCurrentState$2(desired, data) {
1450
+ const iconImageAssetId = data.iconImageAssetId === void 0 ? void 0 : asRobloxAssetId(data.iconImageAssetId);
1451
+ return {
1452
+ data: {
1453
+ ...desired,
1454
+ outputs: {
1455
+ productId: asRobloxAssetId(data.id),
1456
+ ...iconImageAssetId === void 0 ? {} : { iconImageAssetId }
1457
+ }
1458
+ },
1459
+ success: true
1460
+ };
1461
+ }
1462
+ async function applyFollowUpPatch(deps, { created, desired }) {
1463
+ const followUp = planFollowUpPatch(desired, created);
1464
+ if (followUp === void 0) return toCurrentState$2(desired, created);
1465
+ if ((await deps.client.update({
1466
+ productId: asRobloxAssetId(created.id),
1467
+ universeId: deps.universeId,
1468
+ ...followUp
1469
+ })).success) return toCurrentState$2(desired, created);
1470
+ return toCurrentState$2({
1471
+ ...desired,
1472
+ storePageEnabled: created.storePageEnabled
1473
+ }, created);
1474
+ }
1475
+ async function createOne(deps, desired) {
1476
+ const imageFile = desired.icon === void 0 ? void 0 : await deps.readFile(desired.icon["en-us"]);
1477
+ const created = await deps.client.create({
1478
+ name: desired.name,
1479
+ description: desired.description,
1480
+ universeId: deps.universeId,
1481
+ ...imageFile === void 0 ? {} : { imageFile },
1482
+ ...derivePriceFields(desired),
1483
+ ...desired.isRegionalPricingEnabled === void 0 ? {} : { isRegionalPricingEnabled: desired.isRegionalPricingEnabled }
1484
+ });
1485
+ if (!created.success) return created;
1486
+ return applyFollowUpPatch(deps, {
1487
+ created: created.data,
1488
+ desired
1489
+ });
1490
+ }
1491
+ async function updateOne(deps, { current, desired }) {
1492
+ const imageFile = desired.icon !== void 0 && shouldReuploadIcon(current.iconFileHashes, desired.iconFileHashes) ? await deps.readFile(desired.icon["en-us"]) : void 0;
1493
+ const result = await deps.client.update({
1494
+ name: desired.name,
1495
+ description: desired.description,
1496
+ productId: current.outputs.productId,
1497
+ universeId: deps.universeId,
1498
+ ...imageFile === void 0 ? {} : { imageFile },
1499
+ ...derivePriceFields(desired),
1500
+ ...desired.isRegionalPricingEnabled === void 0 ? {} : { isRegionalPricingEnabled: desired.isRegionalPricingEnabled },
1501
+ ...desired.storePageEnabled === void 0 ? {} : { storePageEnabled: desired.storePageEnabled }
1502
+ });
1503
+ if (!result.success) return result;
1504
+ return {
1505
+ data: {
1506
+ ...desired,
1507
+ outputs: current.outputs
1508
+ },
1509
+ success: true
1510
+ };
1511
+ }
1713
1512
  //#endregion
1714
- //#region src/adapters/place-driver.ts
1513
+ //#region src/adapters/game-pass-driver.ts
1715
1514
  /**
1716
- * Wraps {@link PlacesClient} as a `ResourceDriver<"place">`. `create` and
1717
- * `update` are both thin wrappers over a shared publish helper because the
1718
- * upstream Open Cloud call is identical either way: there is no "create
1719
- * place" endpoint (the place is user-supplied input), only "publish version".
1515
+ * Wraps {@link GamePassesClient} as a `ResourceDriver<"gamePass">` that maps
1516
+ * a desired-state entry to an ocale create call and the response back to a
1517
+ * `ResourceCurrentState<"gamePass">`.
1720
1518
  *
1721
- * Format is detected from the file extension (`.rbxl` → binary,
1722
- * `.rbxlx` XML); any other extension returns an `ApiError`-backed failure
1723
- * without hitting the network.
1519
+ * Upstream `OpenCloudError` results pass through as `Result` failures.
1520
+ * Filesystem errors from `deps.readFile` do not fit the `OpenCloudError`
1521
+ * shape and propagate as promise rejections; shell callers are expected to
1522
+ * translate them if a unified error surface is required.
1724
1523
  *
1725
1524
  * @param deps - Injected ocale client, file reader, and owning universe.
1726
- * @returns A driver indexable by `"place"` in a `DriverRegistry`.
1525
+ * @returns A driver indexable by `"gamePass"` in a `DriverRegistry`.
1727
1526
  * @throws Whatever `deps.readFile` rejects with.
1728
1527
  *
1729
1528
  * @example
1730
1529
  *
1731
1530
  * ```ts
1732
1531
  * import type { HttpClient } from "@bedrock-rbx/ocale";
1733
- * import { PlacesClient } from "@bedrock-rbx/ocale/places";
1532
+ * import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
1734
1533
  * import {
1735
1534
  * asResourceKey,
1736
1535
  * asRobloxAssetId,
1737
1536
  * asSha256Hex,
1738
- * createPlaceDriver,
1537
+ * createGamePassDriver,
1739
1538
  * } from "@bedrock-rbx/core";
1740
1539
  *
1741
1540
  * const httpClient: HttpClient = {
1742
1541
  * async request() {
1743
1542
  * return {
1744
- * data: { body: { versionNumber: 1 }, headers: {}, status: 200 },
1543
+ * data: {
1544
+ * body: {
1545
+ * createdTimestamp: "2024-01-15T10:30:00.000Z",
1546
+ * description: "Grants VIP perks.",
1547
+ * gamePassId: 9_876_543_210,
1548
+ * iconAssetId: 1_122_334_455,
1549
+ * isForSale: true,
1550
+ * name: "VIP Pass",
1551
+ * updatedTimestamp: "2024-01-15T10:30:00.000Z",
1552
+ * },
1553
+ * headers: {},
1554
+ * status: 200,
1555
+ * },
1745
1556
  * success: true,
1746
1557
  * };
1747
1558
  * },
1748
1559
  * };
1749
1560
  *
1750
- * const driver = createPlaceDriver({
1751
- * client: new PlacesClient({
1561
+ * const driver = createGamePassDriver({
1562
+ * client: new GamePassesClient({
1752
1563
  * apiKey: "rbx-your-key",
1753
1564
  * httpClient,
1754
1565
  * sleep: async () => {},
1755
1566
  * }),
1756
- * readFile: async () =>
1757
- * new Uint8Array([
1758
- * 0x3c, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x21, 0x89, 0xff, 0x0d, 0x0a, 0x1a,
1759
- * 0x0a,
1760
- * ]),
1567
+ * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
1761
1568
  * universeId: asRobloxAssetId("1234567890"),
1762
1569
  * });
1763
1570
  *
1764
1571
  * return driver
1765
1572
  * .create({
1766
- * description: undefined,
1767
- * displayName: undefined,
1768
- * fileHash: asSha256Hex(
1769
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1770
- * ),
1771
- * filePath: "places/start.rbxl",
1772
- * key: asResourceKey("start-place"),
1773
- * kind: "place",
1774
- * placeId: asRobloxAssetId("4711"),
1775
- * serverSize: undefined,
1573
+ * description: "Grants VIP perks.",
1574
+ * icon: { "en-us": "assets/vip-icon.png" },
1575
+ * iconFileHashes: {
1576
+ * "en-us": asSha256Hex(
1577
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1578
+ * ),
1579
+ * },
1580
+ * key: asResourceKey("vip-pass"),
1581
+ * kind: "gamePass",
1582
+ * name: "VIP Pass",
1583
+ * price: 500,
1776
1584
  * })
1777
1585
  * .then((result) => {
1778
1586
  * expect(result.success).toBeTrue();
1779
1587
  * if (result.success) {
1780
- * expect(result.data.outputs.versionNumber).toBe(1);
1588
+ * expect(result.data.outputs.assetId).toBe("9876543210");
1781
1589
  * }
1782
1590
  * });
1783
1591
  * ```
1784
1592
  */
1785
- function createPlaceDriver(deps) {
1593
+ function createGamePassDriver(deps) {
1594
+ const effective = {
1595
+ ...deps,
1596
+ readFile: withRedactedIcon(deps.readFile)
1597
+ };
1786
1598
  return {
1787
1599
  async create(desired) {
1788
- return publishPlace(deps, desired);
1600
+ return createGamePass(effective, desired);
1789
1601
  },
1790
- async update(_current, desired) {
1791
- return publishPlace(deps, desired);
1602
+ async update(current, desired) {
1603
+ return updateGamePass(effective, {
1604
+ current,
1605
+ desired
1606
+ });
1792
1607
  }
1793
1608
  };
1794
1609
  }
1795
- function buildMetadataParameters(universeId, desired) {
1796
- const metadata = PLACE_MANAGED_METADATA_FIELDS.reduce((accumulator, field) => {
1797
- const value = desired[field];
1798
- return value === void 0 ? accumulator : {
1799
- ...accumulator,
1800
- [field]: value
1801
- };
1802
- }, {});
1803
- if (Object.keys(metadata).length === 0) return;
1610
+ function toCurrentState$1(desired, data) {
1611
+ const { id, iconAssetId } = data;
1612
+ if (iconAssetId === void 0) return {
1613
+ err: new ApiError("Malformed game pass response: iconAssetId missing after icon upload", { statusCode: 200 }),
1614
+ success: false
1615
+ };
1804
1616
  return {
1805
- ...metadata,
1806
- placeId: desired.placeId,
1807
- universeId
1617
+ data: {
1618
+ ...desired,
1619
+ outputs: {
1620
+ assetId: asRobloxAssetId(id),
1621
+ iconAssetIds: { "en-us": asRobloxAssetId(iconAssetId) }
1622
+ }
1623
+ },
1624
+ success: true
1808
1625
  };
1809
1626
  }
1810
- function detectFormat(filePath) {
1811
- if (filePath.endsWith(".rbxlx")) return "rbxlx";
1812
- if (filePath.endsWith(".rbxl")) return "rbxl";
1813
- }
1814
- async function publishVersion(deps, desired) {
1815
- const format = detectFormat(desired.filePath);
1816
- if (format === void 0) return {
1817
- err: new ApiError(`Unsupported place file extension for ${desired.filePath}; expected .rbxl or .rbxlx`, { statusCode: 0 }),
1818
- success: false
1819
- };
1820
- const body = await deps.readFile(desired.filePath);
1821
- return deps.client.publish({
1822
- body: Uint8Array.from(body),
1823
- format,
1824
- placeId: desired.placeId,
1825
- universeId: deps.universeId
1627
+ async function createGamePass(deps, desired) {
1628
+ const imageFile = await deps.readFile(desired.icon["en-us"]);
1629
+ const result = await deps.client.create({
1630
+ name: desired.name,
1631
+ description: desired.description,
1632
+ imageFile,
1633
+ universeId: deps.universeId,
1634
+ ...desired.price !== void 0 ? { price: desired.price } : {}
1826
1635
  });
1636
+ if (!result.success) return result;
1637
+ return toCurrentState$1(desired, result.data);
1827
1638
  }
1828
- async function publishPlace(deps, desired) {
1829
- const publishResult = await publishVersion(deps, desired);
1830
- if (!publishResult.success) return publishResult;
1831
- const metadataParameters = buildMetadataParameters(deps.universeId, desired);
1832
- if (metadataParameters !== void 0) {
1833
- const metadataResult = await deps.client.update(metadataParameters);
1834
- if (!metadataResult.success) return metadataResult;
1835
- }
1836
- return {
1639
+ async function resolveUpdatedState(deps, context) {
1640
+ const { current, desired, hasIconChanged } = context;
1641
+ if (!hasIconChanged) return {
1837
1642
  data: {
1838
1643
  ...desired,
1839
- outputs: publishResult.data
1644
+ outputs: current.outputs
1840
1645
  },
1841
1646
  success: true
1842
1647
  };
1648
+ const fetched = await deps.client.get({
1649
+ gamePassId: current.outputs.assetId,
1650
+ universeId: deps.universeId
1651
+ });
1652
+ if (!fetched.success) return fetched;
1653
+ return toCurrentState$1(desired, fetched.data);
1654
+ }
1655
+ async function updateGamePass(deps, states) {
1656
+ const { current, desired } = states;
1657
+ const hasIconChanged = shouldReuploadIcon(current.iconFileHashes, desired.iconFileHashes);
1658
+ const imageFile = hasIconChanged ? await deps.readFile(desired.icon["en-us"]) : void 0;
1659
+ const result = await deps.client.update({
1660
+ name: desired.name,
1661
+ description: desired.description,
1662
+ gamePassId: current.outputs.assetId,
1663
+ universeId: deps.universeId,
1664
+ ...derivePriceFields(desired),
1665
+ ...imageFile !== void 0 ? { imageFile } : {}
1666
+ });
1667
+ if (!result.success) return result;
1668
+ return resolveUpdatedState(deps, {
1669
+ current,
1670
+ desired,
1671
+ hasIconChanged
1672
+ });
1843
1673
  }
1844
1674
  //#endregion
1845
- //#region src/adapters/universe-driver.ts
1675
+ //#region src/core/state-file.ts
1676
+ const envelopeSchema = type({
1677
+ $bedrock: { version: "1" },
1678
+ environment: "string",
1679
+ resources: type({
1680
+ "key": "string",
1681
+ "[string]": "unknown",
1682
+ "kind": "'developerProduct' | 'gamePass' | 'place' | 'universe'",
1683
+ "outputs": "object"
1684
+ }).array()
1685
+ });
1846
1686
  /**
1847
- * Wraps {@link UniversesClient} as a `ResourceDriver<"universe">`. `create`
1848
- * and `update` both delegate to a shared reconcile helper because Open
1849
- * Cloud cannot mint universes; the user supplies an existing `universeId`
1850
- * and bedrock adopts the universe on first apply.
1687
+ * Serialize a {@link BedrockState} to the on-disk JSON representation used by
1688
+ * state-port adapters.
1851
1689
  *
1852
- * A `NotFound` error (HTTP 404) from `UniversesClient.update` is repackaged
1853
- * as an adoption-error `ApiError` whose message names the config key and
1854
- * the `universeId`, so operators can tell adoption failure apart from
1855
- * transient upstream errors. A successful response whose `rootPlaceId` is
1856
- * absent surfaces as an `ApiError` with status 200, mirroring the
1857
- * malformed-response guard in `GamePassDriver`.
1690
+ * The on-disk shape wraps the in-memory state with a
1691
+ * `$bedrock: { version: N }` envelope so that a future breaking change to the
1692
+ * schema can be detected and rejected at parse time rather than silently
1693
+ * accepted. The top-level `version` field is not duplicated on disk.
1858
1694
  *
1859
- * When `displayName` is declared, the driver routes that field through
1860
- * `PlacesClient.update` on the root place after the universe PATCH
1861
- * succeeds. A subsequent places failure surfaces to the caller as the
1862
- * driver's error result without rolling back the prior universe patch,
1863
- * so callers observing a partial failure should reconcile by
1864
- * reapplying rather than assuming the universe-level fields are
1865
- * unchanged.
1695
+ * @example
1696
+ *
1697
+ * ```ts
1698
+ * import { serializeStateFile, type BedrockState } from "@bedrock-rbx/core";
1699
+ *
1700
+ * const state: BedrockState = {
1701
+ * environment: "production",
1702
+ * resources: [],
1703
+ * version: 1,
1704
+ * };
1705
+ *
1706
+ * const wire = serializeStateFile(state);
1707
+ * expect(JSON.parse(wire)).toStrictEqual({
1708
+ * $bedrock: { version: 1 },
1709
+ * environment: "production",
1710
+ * resources: [],
1711
+ * });
1712
+ * ```
1713
+ *
1714
+ * @param state - The in-memory state snapshot to serialize.
1715
+ * @returns A pretty-printed JSON string ready to hand to a state adapter's write method.
1716
+ */
1717
+ function serializeStateFile(state) {
1718
+ const envelope = {
1719
+ $bedrock: { version: state.version },
1720
+ environment: state.environment,
1721
+ resources: state.resources
1722
+ };
1723
+ return JSON.stringify(envelope, void 0, 2);
1724
+ }
1725
+ /**
1726
+ * Parse a raw on-disk state file into a {@link BedrockState}.
1727
+ *
1728
+ * A backend that reports "no state file for this environment yet" must pass
1729
+ * `undefined`: that distinguishes a legitimate first deploy from a file that
1730
+ * exists but cannot be trusted.
1731
+ *
1732
+ * @example
1733
+ *
1734
+ * ```ts
1735
+ * import { parseStateFile } from "@bedrock-rbx/core";
1736
+ *
1737
+ * const freshStart = parseStateFile(undefined, "gist:abc123/state.production.json");
1738
+ * expect(freshStart.success).toBeTrue();
1739
+ * if (freshStart.success) {
1740
+ * expect(freshStart.data).toBeUndefined();
1741
+ * }
1742
+ * ```
1743
+ *
1744
+ * @param raw - Raw file contents as a string, or `undefined` when the
1745
+ * backend reports no file exists yet.
1746
+ * @param file - Adapter-specific identifier included in any `StateError`
1747
+ * surfaced during parsing.
1748
+ * @returns `Ok(undefined)` for a missing file, `Ok(state)` for a parseable
1749
+ * file, or `Err(StateError)` for anything that cannot be trusted.
1750
+ */
1751
+ function parseStateFile(raw, file) {
1752
+ if (raw === void 0) return {
1753
+ data: void 0,
1754
+ success: true
1755
+ };
1756
+ const parsed = parseJson(raw, file);
1757
+ if (!parsed.success) return parsed;
1758
+ const validated = envelopeSchema(parsed.data);
1759
+ if (validated instanceof ArkErrors) return errState(file, `invalid state file: ${validated.summary}`);
1760
+ const resources = validated.resources;
1761
+ return {
1762
+ data: {
1763
+ environment: validated.environment,
1764
+ resources,
1765
+ version: 1
1766
+ },
1767
+ success: true
1768
+ };
1769
+ }
1770
+ function parseJson(raw, file) {
1771
+ try {
1772
+ return {
1773
+ data: JSON.parse(raw),
1774
+ success: true
1775
+ };
1776
+ } catch (err) {
1777
+ return {
1778
+ err: {
1779
+ file,
1780
+ kind: "stateError",
1781
+ reason: `malformed JSON: ${err instanceof Error ? err.message : String(err)}`
1782
+ },
1783
+ success: false
1784
+ };
1785
+ }
1786
+ }
1787
+ function errState(file, reason) {
1788
+ return {
1789
+ err: {
1790
+ file,
1791
+ kind: "stateError",
1792
+ reason
1793
+ },
1794
+ success: false
1795
+ };
1796
+ }
1797
+ //#endregion
1798
+ //#region src/adapters/gist-state-adapter.ts
1799
+ const GITHUB_API_BASE = "https://api.github.com";
1800
+ const GITHUB_API_VERSION = "2026-03-10";
1801
+ const USER_AGENT = "bedrock";
1802
+ const MAX_INLINE_BYTES = 1e7;
1803
+ const MAX_RETRIES = 3;
1804
+ const RETRYABLE_STATUSES = new Set([
1805
+ 409,
1806
+ 502,
1807
+ 503,
1808
+ 504
1809
+ ]);
1810
+ const MAX_VISIBILITY_ATTEMPTS = 5;
1811
+ const VISIBILITY_BASE_DELAY_MS = 250;
1812
+ /**
1813
+ * Build a `StatePort` that persists Bedrock state in a GitHub Gist.
1866
1814
  *
1867
- * @param deps - Injected ocale clients (universes plus places for the
1868
- * read-only universe fields Roblox derives from the root place).
1869
- * @returns A driver indexable by `"universe"` in a `DriverRegistry`.
1815
+ * One gist holds one file per environment, named `state.<env>.json`. The
1816
+ * adapter authenticates with a user-supplied token and speaks the GitHub
1817
+ * REST API directly; no SDK dependency.
1870
1818
  *
1871
1819
  * @example
1872
1820
  *
1873
1821
  * ```ts
1874
- * import type { HttpClient } from "@bedrock-rbx/ocale";
1875
- * import { PlacesClient } from "@bedrock-rbx/ocale/places";
1876
- * import { UniversesClient } from "@bedrock-rbx/ocale/universes";
1877
- * import { validUniverseBody } from "@bedrock-rbx/ocale/testing";
1878
- * import {
1879
- * asRobloxAssetId,
1880
- * createUniverseDriver,
1881
- * UNIVERSE_SINGLETON_KEY,
1882
- * } from "@bedrock-rbx/core";
1883
- *
1884
- * const universeBodyHttpClient: HttpClient = {
1885
- * async request() {
1886
- * return {
1887
- * data: {
1888
- * body: validUniverseBody({
1889
- * path: "universes/1234567890",
1890
- * rootPlace: "universes/1234567890/places/4711",
1891
- * }),
1892
- * headers: {},
1893
- * status: 200,
1894
- * },
1895
- * success: true,
1896
- * };
1897
- * },
1898
- * };
1822
+ * import { createGistStateAdapter } from "@bedrock-rbx/core";
1899
1823
  *
1900
- * const driver = createUniverseDriver({
1901
- * places: new PlacesClient({
1902
- * apiKey: "rbx-your-key",
1903
- * httpClient: universeBodyHttpClient,
1904
- * sleep: async () => {},
1905
- * }),
1906
- * universes: new UniversesClient({
1907
- * apiKey: "rbx-your-key",
1908
- * httpClient: universeBodyHttpClient,
1909
- * sleep: async () => {},
1910
- * }),
1824
+ * const port = createGistStateAdapter({
1825
+ * fetch: async () =>
1826
+ * new Response(JSON.stringify({ files: {} }), { status: 200 }),
1827
+ * gistId: "abc123def456",
1828
+ * token: "ghp_example",
1911
1829
  * });
1912
1830
  *
1913
- * return driver
1914
- * .create({
1915
- * consoleEnabled: undefined,
1916
- * desktopEnabled: true,
1917
- * displayName: undefined,
1918
- * key: UNIVERSE_SINGLETON_KEY,
1919
- * kind: "universe",
1920
- * mobileEnabled: undefined,
1921
- * privateServerPriceRobux: undefined,
1922
- * tabletEnabled: undefined,
1923
- * universeId: asRobloxAssetId("1234567890"),
1924
- * voiceChatEnabled: true,
1925
- * vrEnabled: undefined,
1926
- * })
1927
- * .then((result) => {
1928
- * expect(result.success).toBeTrue();
1929
- * if (result.success) {
1930
- * expect(result.data.outputs.rootPlaceId).toBe("4711");
1931
- * }
1932
- * });
1831
+ * return port.read("production").then((result) => {
1832
+ * expect(result.success).toBeTrue();
1833
+ * if (result.success) {
1834
+ * expect(result.data).toBeUndefined();
1835
+ * }
1836
+ * });
1933
1837
  * ```
1838
+ *
1839
+ * @param deps - Gist ID, GitHub token, and optional fetch override.
1840
+ * @returns A `StatePort` ready to be passed to `deploy()`.
1934
1841
  */
1935
- function createUniverseDriver(deps) {
1842
+ function createGistStateAdapter(deps) {
1843
+ const ctx = {
1844
+ fetchFn: deps.fetch ?? globalThis.fetch.bind(globalThis),
1845
+ gistId: deps.gistId,
1846
+ sleep: deps.sleep ?? defaultSleep,
1847
+ token: deps.token
1848
+ };
1936
1849
  return {
1937
- async create(desired) {
1938
- return reconcileUniverse({
1939
- deps,
1940
- desired
1941
- });
1850
+ async read(environment) {
1851
+ const safe = validateEnvironmentName(environment);
1852
+ if (!safe.success) return safe;
1853
+ return readPath(ctx, safe.data);
1942
1854
  },
1943
- async update(_current, desired) {
1944
- return reconcileUniverse({
1945
- deps,
1946
- desired
1947
- });
1855
+ async write(state) {
1856
+ const safe = validateEnvironmentName(state.environment);
1857
+ if (!safe.success) return safe;
1858
+ return writePath(ctx, state);
1948
1859
  }
1949
1860
  };
1950
1861
  }
1951
- function toCurrentState(desired, rootPlaceId) {
1862
+ async function defaultSleep(ms) {
1863
+ await new Promise((resolve) => {
1864
+ setTimeout(resolve, ms);
1865
+ });
1866
+ }
1867
+ function fileLabel(gistId, environment) {
1868
+ return `gist:${gistId}/state.${environment}.json`;
1869
+ }
1870
+ function fileName(environment) {
1871
+ return `state.${environment}.json`;
1872
+ }
1873
+ function toGistFile(entry) {
1874
+ if (typeof entry !== "object" || entry === null) return;
1875
+ const record = entry;
1876
+ const content = typeof record["content"] === "string" ? record["content"] : void 0;
1877
+ const rawUrl = typeof record["raw_url"] === "string" ? record["raw_url"] : void 0;
1878
+ const size = typeof record["size"] === "number" ? record["size"] : 0;
1952
1879
  return {
1953
- ...desired,
1954
- outputs: { rootPlaceId: asRobloxAssetId(rootPlaceId) }
1880
+ content,
1881
+ isTruncated: record["truncated"] === true,
1882
+ rawUrl,
1883
+ size
1955
1884
  };
1956
1885
  }
1957
- function buildParameters(desired) {
1958
- const base = UNIVERSE_MANAGED_FLAGS.reduce((accumulator, flag) => {
1959
- const isEnabled = desired[flag];
1960
- return isEnabled === void 0 ? accumulator : {
1961
- ...accumulator,
1962
- [flag]: isEnabled
1886
+ function isRateLimited(headers) {
1887
+ return headers.get("retry-after") !== null || headers.get("x-ratelimit-remaining") === "0";
1888
+ }
1889
+ function rateLimitReason(status, headers) {
1890
+ const retryAfter = headers.get("retry-after");
1891
+ if (retryAfter !== null) return `rate limited (${status}): retry after ${retryAfter}s`;
1892
+ return `rate limited (${status})`;
1893
+ }
1894
+ function mapHttpError({ file, gistId, response }) {
1895
+ const { headers, status } = response;
1896
+ if (status === 404) return {
1897
+ file,
1898
+ kind: "stateError",
1899
+ reason: `gist ${gistId} not found: check gistId`
1900
+ };
1901
+ if (status === 403 && isRateLimited(headers)) return {
1902
+ file,
1903
+ kind: "stateError",
1904
+ reason: rateLimitReason(status, headers)
1905
+ };
1906
+ if (status === 401 || status === 403) return {
1907
+ file,
1908
+ kind: "stateError",
1909
+ reason: `auth failed (${status}): check token scopes`
1910
+ };
1911
+ return {
1912
+ file,
1913
+ kind: "stateError",
1914
+ reason: `github returned ${status}`
1915
+ };
1916
+ }
1917
+ function networkError(error, file) {
1918
+ return {
1919
+ file,
1920
+ kind: "stateError",
1921
+ reason: `network error: ${error instanceof Error ? error.message : String(error)}`
1922
+ };
1923
+ }
1924
+ function buildHeaders(token) {
1925
+ const headers = new Headers();
1926
+ headers.set("Accept", "application/vnd.github+json");
1927
+ headers.set("Authorization", `Bearer ${token}`);
1928
+ headers.set("User-Agent", USER_AGENT);
1929
+ headers.set("X-GitHub-Api-Version", GITHUB_API_VERSION);
1930
+ return headers;
1931
+ }
1932
+ async function sendGet(ctx) {
1933
+ return ctx.fetchFn(`${GITHUB_API_BASE}/gists/${ctx.gistId}`, {
1934
+ headers: buildHeaders(ctx.token),
1935
+ method: "GET"
1936
+ });
1937
+ }
1938
+ function isRetryableStatus(status) {
1939
+ return RETRYABLE_STATUSES.has(status);
1940
+ }
1941
+ function backoffMs(attempt) {
1942
+ return 1e3 * 2 ** attempt;
1943
+ }
1944
+ async function withRetry(sleep, operation) {
1945
+ let response = await operation();
1946
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
1947
+ if (response.ok || !isRetryableStatus(response.status)) return response;
1948
+ await sleep(backoffMs(attempt));
1949
+ response = await operation();
1950
+ }
1951
+ return response;
1952
+ }
1953
+ async function fetchGistBody(ctx, file) {
1954
+ let response;
1955
+ try {
1956
+ response = await withRetry(ctx.sleep, async () => sendGet(ctx));
1957
+ } catch (err) {
1958
+ return {
1959
+ err: networkError(err, file),
1960
+ success: false
1963
1961
  };
1964
- }, { universeId: desired.universeId });
1962
+ }
1963
+ if (!response.ok) return {
1964
+ err: mapHttpError({
1965
+ file,
1966
+ gistId: ctx.gistId,
1967
+ response
1968
+ }),
1969
+ success: false
1970
+ };
1965
1971
  return {
1966
- ..."privateServerPriceRobux" in desired ? {
1967
- ...base,
1968
- privateServerPriceRobux: desired.privateServerPriceRobux
1969
- } : base,
1970
- ...copyDeclaredSocialLinks(desired)
1972
+ data: await response.json(),
1973
+ success: true
1974
+ };
1975
+ }
1976
+ function stateErr(file, reason) {
1977
+ return {
1978
+ err: {
1979
+ file,
1980
+ kind: "stateError",
1981
+ reason
1982
+ },
1983
+ success: false
1984
+ };
1985
+ }
1986
+ async function readGistContent({ entry, fetchFn, file, sleep }) {
1987
+ if (entry.size > MAX_INLINE_BYTES) return stateErr(file, `state file too large: ${entry.size} bytes`);
1988
+ if (entry.isTruncated) {
1989
+ if (entry.rawUrl === void 0) return stateErr(file, "truncated gist file missing raw_url");
1990
+ const { rawUrl } = entry;
1991
+ let rawResponse;
1992
+ try {
1993
+ rawResponse = await withRetry(sleep, async () => fetchFn(rawUrl));
1994
+ } catch (err) {
1995
+ return {
1996
+ err: networkError(err, file),
1997
+ success: false
1998
+ };
1999
+ }
2000
+ if (!rawResponse.ok) return stateErr(file, `raw_url fetch returned ${rawResponse.status}`);
2001
+ return parseStateFile(await rawResponse.text(), file);
2002
+ }
2003
+ return parseStateFile(entry.content, file);
2004
+ }
2005
+ async function readPath(ctx, environment) {
2006
+ const file = fileLabel(ctx.gistId, environment);
2007
+ const gist = await fetchGistBody(ctx, file);
2008
+ if (!gist.success) return gist;
2009
+ const files = gist.data["files"];
2010
+ const entry = toGistFile(files?.[fileName(environment)]);
2011
+ if (entry === void 0) return {
2012
+ data: void 0,
2013
+ success: true
1971
2014
  };
2015
+ return readGistContent({
2016
+ entry,
2017
+ fetchFn: ctx.fetchFn,
2018
+ file,
2019
+ sleep: ctx.sleep
2020
+ });
1972
2021
  }
1973
- function wrapUpdateError(err, desired) {
1974
- if (err instanceof ApiError && err.statusCode === 404) return new ApiError(`Universe ${desired.universeId} (key '${desired.key}') was not found; adoption failed`, { statusCode: 404 });
1975
- return err;
2022
+ async function sendPatch(ctx, body) {
2023
+ const headers = buildHeaders(ctx.token);
2024
+ headers.set("Content-Type", "application/json");
2025
+ return ctx.fetchFn(`${GITHUB_API_BASE}/gists/${ctx.gistId}`, {
2026
+ body,
2027
+ headers,
2028
+ method: "PATCH"
2029
+ });
1976
2030
  }
1977
- function hasUniverseLevelUpdate(desired) {
1978
- if (UNIVERSE_MANAGED_FLAGS.some((flag) => desired[flag] !== void 0)) return true;
1979
- if ("privateServerPriceRobux" in desired) return true;
1980
- return SOCIAL_LINK_FIELDS.some((field) => field in desired);
2031
+ async function isFileVisible(ctx, target) {
2032
+ try {
2033
+ const response = await sendGet(ctx);
2034
+ const body = JSON.parse(await response.text());
2035
+ const files = Reflect.get(body, "files");
2036
+ return typeof files === "object" && files !== null && target in files;
2037
+ } catch {
2038
+ return false;
2039
+ }
1981
2040
  }
1982
- async function resolveUniverse(deps, desired) {
1983
- const result = hasUniverseLevelUpdate(desired) ? await deps.universes.update(buildParameters(desired)) : await deps.universes.get({ universeId: desired.universeId });
1984
- if (!result.success) return {
1985
- err: wrapUpdateError(result.err, desired),
1986
- success: false
1987
- };
1988
- const { rootPlaceId } = result.data;
1989
- if (rootPlaceId === void 0) return {
1990
- err: new ApiError(`Malformed universe response for ${desired.universeId}: rootPlaceId missing`, { statusCode: 200 }),
1991
- success: false
1992
- };
1993
- return {
1994
- data: { rootPlaceId },
1995
- success: true
1996
- };
2041
+ /**
2042
+ * Polls the gist until the just-written environment file is visible on a
2043
+ * GET, with bounded retries. GitHub's gist API does not guarantee
2044
+ * read-your-write across replicas: a GET issued immediately after a
2045
+ * successful PATCH can return a body that omits the new file. The poll
2046
+ * pre-warms the cache the consumer's next read will hit, so a successful
2047
+ * write honours read-after-write at the port boundary.
2048
+ *
2049
+ * Best-effort: resolves after exhausting the visibility budget regardless
2050
+ * of whether the file became visible. The PATCH already committed; the
2051
+ * poll only narrows the window in which subsequent reads can lag.
2052
+ *
2053
+ * @param ctx - Adapter context carrying the injected fetch and sleep seams.
2054
+ * @param environment - Environment name whose file is being verified.
2055
+ */
2056
+ async function waitForFileVisibility(ctx, environment) {
2057
+ const target = fileName(environment);
2058
+ for (let attempt = 0; attempt < MAX_VISIBILITY_ATTEMPTS; attempt += 1) {
2059
+ if (await isFileVisible(ctx, target)) return;
2060
+ if (attempt < MAX_VISIBILITY_ATTEMPTS - 1) await ctx.sleep(VISIBILITY_BASE_DELAY_MS * 2 ** attempt);
2061
+ }
1997
2062
  }
1998
- async function reconcileUniverse(inputs) {
1999
- const { deps, desired } = inputs;
2000
- const universeResult = await resolveUniverse(deps, desired);
2001
- if (!universeResult.success) return universeResult;
2002
- const { rootPlaceId } = universeResult.data;
2003
- if (desired.displayName !== void 0) {
2004
- const placesResult = await deps.places.update({
2005
- displayName: desired.displayName,
2006
- placeId: rootPlaceId,
2007
- universeId: desired.universeId
2008
- });
2009
- if (!placesResult.success) return {
2010
- err: placesResult.err,
2063
+ async function writePath(ctx, state) {
2064
+ const file = fileLabel(ctx.gistId, state.environment);
2065
+ const body = JSON.stringify({ files: { [fileName(state.environment)]: { content: serializeStateFile(state) } } });
2066
+ let response;
2067
+ try {
2068
+ response = await withRetry(ctx.sleep, async () => sendPatch(ctx, body));
2069
+ } catch (err) {
2070
+ return {
2071
+ err: networkError(err, file),
2011
2072
  success: false
2012
2073
  };
2013
2074
  }
2075
+ if (response.ok) {
2076
+ try {
2077
+ await waitForFileVisibility(ctx, state.environment);
2078
+ } catch {}
2079
+ return {
2080
+ data: void 0,
2081
+ success: true
2082
+ };
2083
+ }
2084
+ if (response.status === 422) return stateErr(file, "invalid PATCH body sent to github");
2014
2085
  return {
2015
- data: toCurrentState(desired, rootPlaceId),
2016
- success: true
2086
+ err: mapHttpError({
2087
+ file,
2088
+ gistId: ctx.gistId,
2089
+ response
2090
+ }),
2091
+ success: false
2017
2092
  };
2018
2093
  }
2019
2094
  //#endregion
2020
- //#region src/cli/clack-port.ts
2095
+ //#region src/core/resources.ts
2021
2096
  /**
2022
- * Construct a `ClackPort` whose methods delegate to `@clack/prompts`. The
2023
- * resulting port writes to `process.stdout` via clack's defaults. Kept in
2024
- * its own module so consumers that never need the clack-backed rendering
2025
- * (programmatic deploys, custom adapters) do not pull `@clack/prompts`
2026
- * into their bundle.
2027
- *
2028
- * @example
2029
- *
2030
- * ```ts
2031
- * import { createClackPort } from "@bedrock-rbx/core";
2032
- *
2033
- * const port = createClackPort();
2097
+ * Ordered list of optional metadata fields the driver routes through
2098
+ * `PlacesClient.update`. Iterated by `placeKind.fieldsEqual` and the place
2099
+ * driver's parameter translator so drift detection and the constructed
2100
+ * `updateMask` cannot drift apart.
2101
+ */
2102
+ const PLACE_MANAGED_METADATA_FIELDS = [
2103
+ "displayName",
2104
+ "description",
2105
+ "serverSize"
2106
+ ];
2107
+ /**
2108
+ * Ordered list of optional boolean managed fields on {@link UniverseDesiredState}.
2034
2109
  *
2035
- * expect(typeof port.logSuccess).toBe("function");
2036
- * ```
2110
+ * The driver translator and the diff's per-field equality guard both iterate
2111
+ * this list so they cannot drift apart. Order drives `updateMask` sequence in
2112
+ * generated requests.
2113
+ */
2114
+ const UNIVERSE_MANAGED_FLAGS = [
2115
+ "desktopEnabled",
2116
+ "mobileEnabled",
2117
+ "tabletEnabled",
2118
+ "consoleEnabled",
2119
+ "vrEnabled",
2120
+ "voiceChatEnabled"
2121
+ ];
2122
+ /**
2123
+ * Tuple of every social link field name on {@link UniverseDesiredState}.
2124
+ * Iterated by flatten, driver, and diff to handle the tri-state clearable
2125
+ * semantics uniformly across all seven fields.
2126
+ */
2127
+ const SOCIAL_LINK_FIELDS = [
2128
+ "discordSocialLink",
2129
+ "facebookSocialLink",
2130
+ "guildedSocialLink",
2131
+ "robloxGroupSocialLink",
2132
+ "twitchSocialLink",
2133
+ "twitterSocialLink",
2134
+ "youtubeSocialLink"
2135
+ ];
2136
+ /**
2137
+ * Copy every social link field that is present as a key on `source`,
2138
+ * preserving the tri-state distinction between "key absent" (unmanaged,
2139
+ * omitted from result) and "key present with `undefined`" (cleared,
2140
+ * forwarded as-is). Shared by flatten, build-desired, and the universe
2141
+ * driver so all three layers propagate the same tri-state semantics.
2037
2142
  *
2038
- * @returns A port whose six methods each invoke the matching clack helper.
2143
+ * @param source - Object whose declared social link keys should be copied.
2144
+ * @returns Partial record containing only the social link keys present on
2145
+ * `source`; absent keys stay absent.
2039
2146
  */
2040
- function createClackPort() {
2041
- return {
2042
- cancel: (message) => {
2043
- cancel(message);
2044
- },
2045
- intro: (message) => {
2046
- intro(message);
2047
- },
2048
- logError: (message) => {
2049
- log.error(message);
2050
- },
2051
- logMessage: (message) => {
2052
- log.message(message);
2053
- },
2054
- logSuccess: (message) => {
2055
- log.success(message);
2056
- },
2057
- outro: (message) => {
2058
- outro(message);
2059
- }
2060
- };
2147
+ function copyDeclaredSocialLinks(source) {
2148
+ const copied = {};
2149
+ for (const field of SOCIAL_LINK_FIELDS) if (field in source) copied[field] = source[field];
2150
+ return copied;
2061
2151
  }
2062
- //#endregion
2063
- //#region src/core/validate-universe-xor.ts
2064
2152
  /**
2065
- * Walk the loose authored-shape and surface every place the
2066
- * universeId-XOR-between-root-and-env rule is violated. Pure: returns
2067
- * the issue list; the caller hands it to arktype's `ctx.reject` so each
2068
- * one lands at the offending config path. The schema's runtime narrow
2069
- * uses this to enforce the rule at validation time before the validated
2070
- * value is cast to the strict `Config` discriminated union.
2153
+ * Fixed stable key for the singleton universe resource. `flattenConfig`
2154
+ * stamps this onto the sole `UniverseDesiredInput` it emits; fixtures and
2155
+ * state adapters share the constant so the invariant is encoded once.
2071
2156
  *
2072
- * @param value - Parsed config the schema is validating.
2073
- * @returns Zero or more issues. Empty when the config satisfies the rule.
2157
+ * @example
2158
+ *
2159
+ * ```ts
2160
+ * import { UNIVERSE_SINGLETON_KEY } from "@bedrock-rbx/core";
2161
+ *
2162
+ * expect(UNIVERSE_SINGLETON_KEY).toBe("main");
2163
+ * ```
2074
2164
  */
2075
- function collectUniverseIdIssues(value) {
2076
- const rootUniverseId = value.universe?.universeId;
2077
- const hasRootUniverseBlock = value.universe !== void 0;
2078
- const environmentEntries = Object.entries(value.environments);
2079
- const hasEnvironmentUniverseId = environmentEntries.some(([, environment]) => environment.universe?.universeId !== void 0);
2080
- const environmentIssues = collectEnvironmentIssues(rootUniverseId, environmentEntries);
2081
- const rootIssues = hasRootUniverseBlock && rootUniverseId === void 0 && !hasEnvironmentUniverseId ? [{
2082
- message: "universeId must be declared on the root universe block, or on every environment that declares its own universe overlay.",
2083
- path: ["universe", "universeId"]
2084
- }] : [];
2085
- return [...environmentIssues, ...rootIssues];
2086
- }
2087
- function collectEnvironmentIssues(rootUniverseId, environmentEntries) {
2088
- return environmentEntries.flatMap(([environmentName, environment]) => {
2089
- if (environment.universe === void 0) return [];
2090
- if (rootUniverseId !== void 0 && environment.universe.universeId !== void 0) return [{
2091
- message: "universeId is declared at the root universe block; remove it from this environment overlay (root is authoritative) or remove it from the root and declare it on every environment.",
2092
- path: [
2093
- "environments",
2094
- environmentName,
2095
- "universe",
2096
- "universeId"
2097
- ]
2098
- }];
2099
- if (rootUniverseId === void 0 && environment.universe.universeId === void 0) return [{
2100
- message: "universeId must be declared on this environment overlay because the root universe block does not provide one.",
2101
- path: [
2102
- "environments",
2103
- environmentName,
2104
- "universe",
2105
- "universeId"
2106
- ]
2107
- }];
2108
- return [];
2109
- });
2110
- }
2165
+ const UNIVERSE_SINGLETON_KEY = asResourceKey("main");
2111
2166
  //#endregion
2112
- //#region src/core/schema.ts
2167
+ //#region src/adapters/place-driver.ts
2113
2168
  /**
2114
- * Narrow a `StateConfig` to the `GistStateConfig` arm. The `(string & {})`
2115
- * autocomplete idiom prevents TypeScript from narrowing on
2116
- * `backend === "gist"` alone, so dispatch sites use this guard to
2117
- * preserve the `gistId` field shape.
2169
+ * Wraps {@link PlacesClient} as a `ResourceDriver<"place">`. `create` and
2170
+ * `update` are both thin wrappers over a shared publish helper because the
2171
+ * upstream Open Cloud call is identical either way: there is no "create
2172
+ * place" endpoint (the place is user-supplied input), only "publish version".
2173
+ *
2174
+ * Format is detected from the file extension (`.rbxl` → binary,
2175
+ * `.rbxlx` → XML); any other extension returns an `ApiError`-backed failure
2176
+ * without hitting the network.
2177
+ *
2178
+ * @param deps - Injected ocale client, file reader, and owning universe.
2179
+ * @returns A driver indexable by `"place"` in a `DriverRegistry`.
2180
+ * @throws Whatever `deps.readFile` rejects with.
2118
2181
  *
2119
2182
  * @example
2120
2183
  *
2121
2184
  * ```ts
2122
- * import { isGistStateConfig } from "@bedrock-rbx/core";
2123
- * import type { StateConfig } from "@bedrock-rbx/core/config";
2185
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2186
+ * import { PlacesClient } from "@bedrock-rbx/ocale/places";
2187
+ * import {
2188
+ * asResourceKey,
2189
+ * asRobloxAssetId,
2190
+ * asSha256Hex,
2191
+ * createPlaceDriver,
2192
+ * } from "@bedrock-rbx/core";
2124
2193
  *
2125
- * const config: StateConfig = { backend: "gist", gistId: "abc" };
2194
+ * const httpClient: HttpClient = {
2195
+ * async request() {
2196
+ * return {
2197
+ * data: { body: { versionNumber: 1 }, headers: {}, status: 200 },
2198
+ * success: true,
2199
+ * };
2200
+ * },
2201
+ * };
2126
2202
  *
2127
- * expect(isGistStateConfig(config)).toBeTrue();
2128
- * if (isGistStateConfig(config)) {
2129
- * expect(config.gistId).toBe("abc");
2130
- * }
2131
- * ```
2203
+ * const driver = createPlaceDriver({
2204
+ * client: new PlacesClient({
2205
+ * apiKey: "rbx-your-key",
2206
+ * httpClient,
2207
+ * sleep: async () => {},
2208
+ * }),
2209
+ * readFile: async () =>
2210
+ * new Uint8Array([
2211
+ * 0x3c, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x21, 0x89, 0xff, 0x0d, 0x0a, 0x1a,
2212
+ * 0x0a,
2213
+ * ]),
2214
+ * universeId: asRobloxAssetId("1234567890"),
2215
+ * });
2132
2216
  *
2133
- * @param config - Resolved state config to inspect.
2134
- * @returns `true` when `config.backend === "gist"`; otherwise `false`.
2217
+ * return driver
2218
+ * .create({
2219
+ * description: undefined,
2220
+ * displayName: undefined,
2221
+ * fileHash: asSha256Hex(
2222
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2223
+ * ),
2224
+ * filePath: "places/start.rbxl",
2225
+ * key: asResourceKey("start-place"),
2226
+ * kind: "place",
2227
+ * placeId: asRobloxAssetId("4711"),
2228
+ * serverSize: undefined,
2229
+ * })
2230
+ * .then((result) => {
2231
+ * expect(result.success).toBeTrue();
2232
+ * if (result.success) {
2233
+ * expect(result.data.outputs.versionNumber).toBe(1);
2234
+ * }
2235
+ * });
2236
+ * ```
2135
2237
  */
2136
- function isGistStateConfig(config) {
2137
- return config.backend === "gist";
2238
+ function createPlaceDriver(deps) {
2239
+ return {
2240
+ async create(desired) {
2241
+ return publishPlace(deps, desired);
2242
+ },
2243
+ async update(_current, desired) {
2244
+ return publishPlace(deps, desired);
2245
+ }
2246
+ };
2138
2247
  }
2139
- const OPTIONAL_BOOLEAN$2 = "boolean | undefined";
2140
- const OPTIONAL_STRING = "string | undefined";
2141
- const REDACTED_KEY = "redacted?";
2142
- const NON_EMPTY_OVERRIDE_MESSAGE = "a non-empty override object; use `redacted: true` for default placeholders";
2143
- /**
2144
- * Shared arktype constraint for any optional positive-integer field.
2145
- * Reused by per-kind entry schemas so positive-integer fields validate
2146
- * identically.
2147
- */
2148
- const OPTIONAL_POSITIVE_INTEGER = "(number.integer >= 1) | undefined";
2149
- /**
2150
- * Shared arktype constraint for any optional Robux-price field. The schema
2151
- * rejects negatives, fractional values, `NaN`, and `Infinity` at config
2152
- * validation time so a malformed price surfaces with a path attributing the
2153
- * failure to the offending field, rather than slipping through to the
2154
- * Roblox API and surfacing as an opaque error at apply time. Per-kind entry
2155
- * schemas reuse this constant so all Robux-price fields validate
2156
- * identically.
2157
- */
2158
- const OPTIONAL_ROBUX_PRICE = "number.integer >= 0 | undefined";
2159
- const gamePassRedacted = type({
2160
- "description?": "string",
2161
- "icon?": iconMap,
2162
- "name?": "string"
2163
- }).onUndeclaredKey("reject").narrow((value, ctx) => {
2164
- if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
2165
- return true;
2166
- }).or(OPTIONAL_BOOLEAN$2);
2167
- const placeRedacted = type({
2168
- "description?": "string",
2169
- "displayName?": "string"
2170
- }).onUndeclaredKey("reject").narrow((value, ctx) => {
2171
- if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
2172
- return true;
2173
- }).or(OPTIONAL_BOOLEAN$2);
2174
- const productRedacted = type({
2175
- "description?": "string",
2176
- "icon?": iconMap,
2177
- "name?": "string"
2178
- }).onUndeclaredKey("reject").narrow((value, ctx) => {
2179
- if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
2180
- return true;
2181
- }).or(OPTIONAL_BOOLEAN$2);
2182
- const gamePassEntry = type({
2183
- "name": "string",
2184
- "description": "string",
2185
- "icon": iconMap,
2186
- "price?": OPTIONAL_ROBUX_PRICE,
2187
- [REDACTED_KEY]: gamePassRedacted
2188
- });
2189
- const passesCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: gamePassEntry }).onUndeclaredKey("reject");
2190
- const developerProductEntry = type({
2191
- "name": "string",
2192
- "description": "string",
2193
- "icon?": iconMap,
2194
- "isRegionalPricingEnabled?": OPTIONAL_BOOLEAN$2,
2195
- "price?": OPTIONAL_ROBUX_PRICE,
2196
- [REDACTED_KEY]: productRedacted,
2197
- "storePageEnabled?": OPTIONAL_BOOLEAN$2
2198
- }).onUndeclaredKey("reject");
2199
- const productsCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: developerProductEntry }).onUndeclaredKey("reject");
2200
- const ROBLOX_ID_DIGITS = "string.digits";
2201
- const placeEntry = type({
2202
- "description?": OPTIONAL_STRING,
2203
- "displayName?": OPTIONAL_STRING,
2204
- "filePath": "string",
2205
- [REDACTED_KEY]: placeRedacted,
2206
- "serverSize?": OPTIONAL_POSITIVE_INTEGER
2207
- }).onUndeclaredKey("reject");
2208
- const placesCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: placeEntry }).onUndeclaredKey("reject");
2209
- const socialLinkOrUndefined$1 = type({
2210
- title: "string",
2211
- uri: "string"
2212
- }).onUndeclaredKey("reject").or("undefined");
2213
- const universeEntry = type({
2214
- "consoleEnabled?": OPTIONAL_BOOLEAN$2,
2215
- "desktopEnabled?": OPTIONAL_BOOLEAN$2,
2216
- "discordSocialLink?": socialLinkOrUndefined$1,
2217
- "displayName?": OPTIONAL_STRING,
2218
- "facebookSocialLink?": socialLinkOrUndefined$1,
2219
- "guildedSocialLink?": socialLinkOrUndefined$1,
2220
- "mobileEnabled?": OPTIONAL_BOOLEAN$2,
2221
- "privateServerPriceRobux?": OPTIONAL_ROBUX_PRICE,
2222
- "robloxGroupSocialLink?": socialLinkOrUndefined$1,
2223
- "tabletEnabled?": OPTIONAL_BOOLEAN$2,
2224
- "twitchSocialLink?": socialLinkOrUndefined$1,
2225
- "twitterSocialLink?": socialLinkOrUndefined$1,
2226
- "universeId?": ROBLOX_ID_DIGITS,
2227
- "voiceChatEnabled?": OPTIONAL_BOOLEAN$2,
2228
- "vrEnabled?": OPTIONAL_BOOLEAN$2,
2229
- "youtubeSocialLink?": socialLinkOrUndefined$1
2230
- }).onUndeclaredKey("reject");
2231
- const stateConfig = type({
2232
- "backend": "string",
2233
- "gistId?": "string > 0"
2234
- }).onUndeclaredKey("reject");
2235
- const gamePassOverlay = type({
2236
- "description?": "string",
2237
- "icon?": iconMap,
2238
- "name?": "string",
2239
- "price?": OPTIONAL_ROBUX_PRICE,
2240
- [REDACTED_KEY]: OPTIONAL_BOOLEAN$2
2241
- }).onUndeclaredKey("reject");
2242
- const passesOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: gamePassOverlay }).onUndeclaredKey("reject");
2243
- const developerProductOverlay = type({
2244
- "description?": "string",
2245
- "icon?": iconMap,
2246
- "isRegionalPricingEnabled?": OPTIONAL_BOOLEAN$2,
2247
- "name?": "string",
2248
- "price?": OPTIONAL_ROBUX_PRICE,
2249
- [REDACTED_KEY]: OPTIONAL_BOOLEAN$2,
2250
- "storePageEnabled?": OPTIONAL_BOOLEAN$2
2251
- }).onUndeclaredKey("reject");
2252
- const productsOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: developerProductOverlay }).onUndeclaredKey("reject");
2253
- const placeOverlay = type({
2254
- "description?": OPTIONAL_STRING,
2255
- "displayName?": OPTIONAL_STRING,
2256
- "filePath?": "string",
2257
- "placeId": ROBLOX_ID_DIGITS,
2258
- [REDACTED_KEY]: OPTIONAL_BOOLEAN$2,
2259
- "serverSize?": OPTIONAL_POSITIVE_INTEGER
2260
- }).onUndeclaredKey("reject");
2261
- const placesOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: placeOverlay }).onUndeclaredKey("reject");
2262
- const universeOverlay = universeEntry;
2263
- const environmentEntry = type({
2264
- "label?": OPTIONAL_STRING,
2265
- "passes?": passesOverlayCollection,
2266
- "places?": placesOverlayCollection,
2267
- "products?": productsOverlayCollection,
2268
- [REDACTED_KEY]: OPTIONAL_BOOLEAN$2,
2269
- "state?": stateConfig,
2270
- "universe?": universeOverlay
2271
- }).onUndeclaredKey("reject");
2272
- const rootSchema = type({
2273
- "displayNamePrefix?": type({
2274
- "enabled?": OPTIONAL_BOOLEAN$2,
2275
- "format?": OPTIONAL_STRING
2276
- }).onUndeclaredKey("reject"),
2277
- "environments": type({ [`[/${ENV_NAME_PATTERN_SOURCE}/]`]: environmentEntry }).onUndeclaredKey("reject").narrow((value, ctx) => {
2278
- if (Object.keys(value).length === 0) return ctx.mustBe("an environments record with at least one declared environment");
2279
- return true;
2280
- }),
2281
- "extends?": "unknown",
2282
- "passes?": passesCollection,
2283
- "places?": placesCollection,
2284
- "products?": productsCollection,
2285
- "state?": stateConfig,
2286
- "universe?": universeEntry
2287
- }).onUndeclaredKey("reject").narrow((value, ctx) => {
2288
- return collectUniverseIdIssues(value).reduce((_accumulator, issue) => {
2289
- return ctx.reject({
2290
- message: issue.message,
2291
- path: [...issue.path]
2292
- });
2293
- }, true);
2294
- });
2248
+ function buildMetadataParameters(universeId, desired) {
2249
+ const metadata = PLACE_MANAGED_METADATA_FIELDS.reduce((accumulator, field) => {
2250
+ const value = desired[field];
2251
+ return value === void 0 ? accumulator : {
2252
+ ...accumulator,
2253
+ [field]: value
2254
+ };
2255
+ }, {});
2256
+ if (Object.keys(metadata).length === 0) return;
2257
+ return {
2258
+ ...metadata,
2259
+ placeId: desired.placeId,
2260
+ universeId
2261
+ };
2262
+ }
2263
+ function detectFormat(filePath) {
2264
+ if (filePath.endsWith(".rbxlx")) return "rbxlx";
2265
+ if (filePath.endsWith(".rbxl")) return "rbxl";
2266
+ }
2267
+ async function publishVersion(deps, desired) {
2268
+ const format = detectFormat(desired.filePath);
2269
+ if (format === void 0) return {
2270
+ err: new ApiError(`Unsupported place file extension for ${desired.filePath}; expected .rbxl or .rbxlx`, { statusCode: 0 }),
2271
+ success: false
2272
+ };
2273
+ const body = await deps.readFile(desired.filePath);
2274
+ return deps.client.publish({
2275
+ body: Uint8Array.from(body),
2276
+ format,
2277
+ placeId: desired.placeId,
2278
+ universeId: deps.universeId
2279
+ });
2280
+ }
2281
+ async function publishPlace(deps, desired) {
2282
+ const publishResult = await publishVersion(deps, desired);
2283
+ if (!publishResult.success) return publishResult;
2284
+ const metadataParameters = buildMetadataParameters(deps.universeId, desired);
2285
+ if (metadataParameters !== void 0) {
2286
+ const metadataResult = await deps.client.update(metadataParameters);
2287
+ if (!metadataResult.success) return metadataResult;
2288
+ }
2289
+ return {
2290
+ data: {
2291
+ ...desired,
2292
+ outputs: publishResult.data
2293
+ },
2294
+ success: true
2295
+ };
2296
+ }
2297
+ //#endregion
2298
+ //#region src/adapters/universe-driver.ts
2295
2299
  /**
2296
- * Validate a parsed config value against the runtime schema. Returns the
2297
- * validated `Config` on success or a `validationFailed` `ConfigError` with
2298
- * one issue per problem, each attributed to a field path. `sourceFile`
2299
- * appears in the error so callers can point a human at the offending file.
2300
+ * Wraps {@link UniversesClient} as a `ResourceDriver<"universe">`. `create`
2301
+ * and `update` both delegate to a shared reconcile helper because Open
2302
+ * Cloud cannot mint universes; the user supplies an existing `universeId`
2303
+ * and bedrock adopts the universe on first apply.
2304
+ *
2305
+ * A `NotFound` error (HTTP 404) from `UniversesClient.update` is repackaged
2306
+ * as an adoption-error `ApiError` whose message names the config key and
2307
+ * the `universeId`, so operators can tell adoption failure apart from
2308
+ * transient upstream errors. A successful response whose `rootPlaceId` is
2309
+ * absent surfaces as an `ApiError` with status 200, mirroring the
2310
+ * malformed-response guard in `GamePassDriver`.
2311
+ *
2312
+ * When `displayName` is declared, the driver routes that field through
2313
+ * `PlacesClient.update` on the root place after the universe PATCH
2314
+ * succeeds. A subsequent places failure surfaces to the caller as the
2315
+ * driver's error result without rolling back the prior universe patch,
2316
+ * so callers observing a partial failure should reconcile by
2317
+ * reapplying rather than assuming the universe-level fields are
2318
+ * unchanged.
2319
+ *
2320
+ * @param deps - Injected ocale clients (universes plus places for the
2321
+ * read-only universe fields Roblox derives from the root place).
2322
+ * @returns A driver indexable by `"universe"` in a `DriverRegistry`.
2300
2323
  *
2301
- * @param input - Parsed value from a config source (object tree from a
2302
- * config loader, or a hand-built literal). Shape is checked, not assumed.
2303
- * @param sourceFile - Path or identifier of the source file, used in the
2304
- * `validationFailed` error.
2305
- * @returns `Ok` with the validated `Config`, or `Err` with a
2306
- * `validationFailed` error carrying each issue's field path.
2307
2324
  * @example
2308
2325
  *
2309
2326
  * ```ts
2310
- * import { validateConfig } from "@bedrock-rbx/core";
2327
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2328
+ * import { PlacesClient } from "@bedrock-rbx/ocale/places";
2329
+ * import { UniversesClient } from "@bedrock-rbx/ocale/universes";
2330
+ * import { validUniverseBody } from "@bedrock-rbx/ocale/testing";
2331
+ * import {
2332
+ * asRobloxAssetId,
2333
+ * createUniverseDriver,
2334
+ * UNIVERSE_SINGLETON_KEY,
2335
+ * } from "@bedrock-rbx/core";
2311
2336
  *
2312
- * const ok = validateConfig(
2313
- * {
2314
- * environments: { production: {} },
2315
- * passes: {
2316
- * "vip-pass": {
2317
- * description: "VIP perks.",
2318
- * icon: { "en-us": "assets/vip.png" },
2319
- * name: "VIP Pass",
2320
- * price: 500,
2337
+ * const universeBodyHttpClient: HttpClient = {
2338
+ * async request() {
2339
+ * return {
2340
+ * data: {
2341
+ * body: validUniverseBody({
2342
+ * path: "universes/1234567890",
2343
+ * rootPlace: "universes/1234567890/places/4711",
2344
+ * }),
2345
+ * headers: {},
2346
+ * status: 200,
2321
2347
  * },
2322
- * },
2348
+ * success: true,
2349
+ * };
2323
2350
  * },
2324
- * "bedrock.config.ts",
2325
- * );
2326
- * expect(ok.success).toBeTrue();
2351
+ * };
2327
2352
  *
2328
- * const err = validateConfig(
2329
- * { environments: { production: {} }, passes: { "vip-pass": { name: "VIP" } } },
2330
- * "bedrock.config.ts",
2331
- * );
2332
- * expect(err.success).toBeFalse();
2333
- * if (!err.success) {
2334
- * expect(err.err.kind).toBe("validationFailed");
2335
- * }
2353
+ * const driver = createUniverseDriver({
2354
+ * places: new PlacesClient({
2355
+ * apiKey: "rbx-your-key",
2356
+ * httpClient: universeBodyHttpClient,
2357
+ * sleep: async () => {},
2358
+ * }),
2359
+ * universes: new UniversesClient({
2360
+ * apiKey: "rbx-your-key",
2361
+ * httpClient: universeBodyHttpClient,
2362
+ * sleep: async () => {},
2363
+ * }),
2364
+ * });
2365
+ *
2366
+ * return driver
2367
+ * .create({
2368
+ * consoleEnabled: undefined,
2369
+ * desktopEnabled: true,
2370
+ * displayName: undefined,
2371
+ * key: UNIVERSE_SINGLETON_KEY,
2372
+ * kind: "universe",
2373
+ * mobileEnabled: undefined,
2374
+ * privateServerPriceRobux: undefined,
2375
+ * tabletEnabled: undefined,
2376
+ * universeId: asRobloxAssetId("1234567890"),
2377
+ * voiceChatEnabled: true,
2378
+ * vrEnabled: undefined,
2379
+ * })
2380
+ * .then((result) => {
2381
+ * expect(result.success).toBeTrue();
2382
+ * if (result.success) {
2383
+ * expect(result.data.outputs.rootPlaceId).toBe("4711");
2384
+ * }
2385
+ * });
2336
2386
  * ```
2337
2387
  */
2338
- function validateConfig(input, sourceFile) {
2339
- const validated = rootSchema(input);
2340
- if (validated instanceof ArkErrors) return {
2341
- err: {
2342
- issues: Array.from(validated, (issue) => {
2343
- return {
2344
- message: issue.message,
2345
- path: [...issue.path].map((segment) => String(segment))
2346
- };
2347
- }),
2348
- kind: "validationFailed",
2349
- sourceFile
2388
+ function createUniverseDriver(deps) {
2389
+ return {
2390
+ async create(desired) {
2391
+ return reconcileUniverse({
2392
+ deps,
2393
+ desired
2394
+ });
2350
2395
  },
2396
+ async update(_current, desired) {
2397
+ return reconcileUniverse({
2398
+ deps,
2399
+ desired
2400
+ });
2401
+ }
2402
+ };
2403
+ }
2404
+ function toCurrentState(desired, rootPlaceId) {
2405
+ return {
2406
+ ...desired,
2407
+ outputs: { rootPlaceId: asRobloxAssetId(rootPlaceId) }
2408
+ };
2409
+ }
2410
+ function buildParameters(desired) {
2411
+ const base = UNIVERSE_MANAGED_FLAGS.reduce((accumulator, flag) => {
2412
+ const isEnabled = desired[flag];
2413
+ return isEnabled === void 0 ? accumulator : {
2414
+ ...accumulator,
2415
+ [flag]: isEnabled
2416
+ };
2417
+ }, { universeId: desired.universeId });
2418
+ return {
2419
+ ..."privateServerPriceRobux" in desired ? {
2420
+ ...base,
2421
+ privateServerPriceRobux: desired.privateServerPriceRobux
2422
+ } : base,
2423
+ ...copyDeclaredSocialLinks(desired)
2424
+ };
2425
+ }
2426
+ function wrapUpdateError(err, desired) {
2427
+ if (err instanceof ApiError && err.statusCode === 404) return new ApiError(`Universe ${desired.universeId} (key '${desired.key}') was not found; adoption failed`, { statusCode: 404 });
2428
+ return err;
2429
+ }
2430
+ function hasUniverseLevelUpdate(desired) {
2431
+ if (UNIVERSE_MANAGED_FLAGS.some((flag) => desired[flag] !== void 0)) return true;
2432
+ if ("privateServerPriceRobux" in desired) return true;
2433
+ return SOCIAL_LINK_FIELDS.some((field) => field in desired);
2434
+ }
2435
+ async function resolveUniverse(deps, desired) {
2436
+ const result = hasUniverseLevelUpdate(desired) ? await deps.universes.update(buildParameters(desired)) : await deps.universes.get({ universeId: desired.universeId });
2437
+ if (!result.success) return {
2438
+ err: wrapUpdateError(result.err, desired),
2439
+ success: false
2440
+ };
2441
+ const { rootPlaceId } = result.data;
2442
+ if (rootPlaceId === void 0) return {
2443
+ err: new ApiError(`Malformed universe response for ${desired.universeId}: rootPlaceId missing`, { statusCode: 200 }),
2351
2444
  success: false
2352
2445
  };
2353
2446
  return {
2354
- data: validated,
2355
- success: true
2447
+ data: { rootPlaceId },
2448
+ success: true
2449
+ };
2450
+ }
2451
+ async function reconcileUniverse(inputs) {
2452
+ const { deps, desired } = inputs;
2453
+ const universeResult = await resolveUniverse(deps, desired);
2454
+ if (!universeResult.success) return universeResult;
2455
+ const { rootPlaceId } = universeResult.data;
2456
+ if (desired.displayName !== void 0) {
2457
+ const placesResult = await deps.places.update({
2458
+ displayName: desired.displayName,
2459
+ placeId: rootPlaceId,
2460
+ universeId: desired.universeId
2461
+ });
2462
+ if (!placesResult.success) return {
2463
+ err: placesResult.err,
2464
+ success: false
2465
+ };
2466
+ }
2467
+ return {
2468
+ data: toCurrentState(desired, rootPlaceId),
2469
+ success: true
2470
+ };
2471
+ }
2472
+ //#endregion
2473
+ //#region src/cli/clack-port.ts
2474
+ /**
2475
+ * Construct a `ClackPort` whose methods delegate to `@clack/prompts`. The
2476
+ * resulting port writes to `process.stdout` via clack's defaults. Kept in
2477
+ * its own module so consumers that never need the clack-backed rendering
2478
+ * (programmatic deploys, custom adapters) do not pull `@clack/prompts`
2479
+ * into their bundle.
2480
+ *
2481
+ * @example
2482
+ *
2483
+ * ```ts
2484
+ * import { createClackPort } from "@bedrock-rbx/core";
2485
+ *
2486
+ * const port = createClackPort();
2487
+ *
2488
+ * expect(typeof port.logSuccess).toBe("function");
2489
+ * ```
2490
+ *
2491
+ * @returns A port whose six methods each invoke the matching clack helper.
2492
+ */
2493
+ function createClackPort() {
2494
+ return {
2495
+ cancel: (message) => {
2496
+ cancel(message);
2497
+ },
2498
+ intro: (message) => {
2499
+ intro(message);
2500
+ },
2501
+ logError: (message) => {
2502
+ log.error(message);
2503
+ },
2504
+ logMessage: (message) => {
2505
+ log.message(message);
2506
+ },
2507
+ logSuccess: (message) => {
2508
+ log.success(message);
2509
+ },
2510
+ outro: (message) => {
2511
+ outro(message);
2512
+ }
2356
2513
  };
2357
2514
  }
2358
2515
  //#endregion
@@ -2412,8 +2569,19 @@ async function normalize$3(input, io) {
2412
2569
  success: true
2413
2570
  };
2414
2571
  }
2572
+ function changedFieldsBetween$3(desired, current) {
2573
+ return [
2574
+ ...desired.description === current.description ? [] : ["description"],
2575
+ ...desired.icon?.["en-us"] === current.icon?.["en-us"] ? [] : ["icon"],
2576
+ ...iconHashesEqual(current.iconFileHashes, desired.iconFileHashes) ? [] : ["iconFileHashes"],
2577
+ ...desired.name === current.name ? [] : ["name"],
2578
+ ...desired.price === current.price ? [] : ["price"],
2579
+ ...desired.isRegionalPricingEnabled === void 0 || desired.isRegionalPricingEnabled === current.isRegionalPricingEnabled ? [] : ["isRegionalPricingEnabled"],
2580
+ ...desired.storePageEnabled === void 0 || desired.storePageEnabled === current.storePageEnabled ? [] : ["storePageEnabled"]
2581
+ ];
2582
+ }
2415
2583
  function fieldsEqual$3(desired, current) {
2416
- return desired.description === current.description && desired.icon?.["en-us"] === current.icon?.["en-us"] && iconHashesEqual(current.iconFileHashes, desired.iconFileHashes) && desired.name === current.name && desired.price === current.price && (desired.isRegionalPricingEnabled === void 0 || desired.isRegionalPricingEnabled === current.isRegionalPricingEnabled) && (desired.storePageEnabled === void 0 || desired.storePageEnabled === current.storePageEnabled);
2584
+ return changedFieldsBetween$3(desired, current).length === 0;
2417
2585
  }
2418
2586
  function assertReconcilable(current, desired) {
2419
2587
  if (current.iconFileHashes !== void 0 && desired.iconFileHashes === void 0) return {
@@ -2436,6 +2604,7 @@ function assertReconcilable(current, desired) {
2436
2604
  */
2437
2605
  const developerProductKind = {
2438
2606
  assertReconcilable,
2607
+ changedFieldsBetween: changedFieldsBetween$3,
2439
2608
  entrySchema: entrySchema$3,
2440
2609
  fieldsEqual: fieldsEqual$3,
2441
2610
  flatten: flatten$3,
@@ -2482,8 +2651,17 @@ async function normalize$2(input, io) {
2482
2651
  success: true
2483
2652
  };
2484
2653
  }
2654
+ function changedFieldsBetween$2(desired, current) {
2655
+ return [
2656
+ ...desired.description === current.description ? [] : ["description"],
2657
+ ...desired.icon["en-us"] === current.icon["en-us"] ? [] : ["icon"],
2658
+ ...iconHashesEqual(current.iconFileHashes, desired.iconFileHashes) ? [] : ["iconFileHashes"],
2659
+ ...desired.name === current.name ? [] : ["name"],
2660
+ ...desired.price === current.price ? [] : ["price"]
2661
+ ];
2662
+ }
2485
2663
  function fieldsEqual$2(desired, current) {
2486
- return desired.description === current.description && desired.icon["en-us"] === current.icon["en-us"] && iconHashesEqual(current.iconFileHashes, desired.iconFileHashes) && desired.name === current.name && desired.price === current.price;
2664
+ return changedFieldsBetween$2(desired, current).length === 0;
2487
2665
  }
2488
2666
  /**
2489
2667
  * Resource-kind module for Roblox game passes. Owns the entry schema,
@@ -2491,6 +2669,7 @@ function fieldsEqual$2(desired, current) {
2491
2669
  * `gamePass` kind.
2492
2670
  */
2493
2671
  const gamePassKind = {
2672
+ changedFieldsBetween: changedFieldsBetween$2,
2494
2673
  entrySchema: entrySchema$2,
2495
2674
  fieldsEqual: fieldsEqual$2,
2496
2675
  flatten: flatten$2,
@@ -2539,12 +2718,19 @@ async function normalize$1(input, io) {
2539
2718
  success: true
2540
2719
  };
2541
2720
  }
2721
+ function changedFieldsBetween$1(desired, current) {
2722
+ return [
2723
+ ...desired.fileHash === current.fileHash ? [] : ["fileHash"],
2724
+ ...desired.filePath === current.filePath ? [] : ["filePath"],
2725
+ ...desired.placeId === current.placeId ? [] : ["placeId"],
2726
+ ...PLACE_MANAGED_METADATA_FIELDS.filter((field) => {
2727
+ const desiredValue = desired[field];
2728
+ return desiredValue !== void 0 && desiredValue !== current[field];
2729
+ })
2730
+ ];
2731
+ }
2542
2732
  function fieldsEqual$1(desired, current) {
2543
- if (desired.fileHash !== current.fileHash || desired.filePath !== current.filePath || desired.placeId !== current.placeId) return false;
2544
- return PLACE_MANAGED_METADATA_FIELDS.every((field) => {
2545
- const desiredValue = desired[field];
2546
- return desiredValue === void 0 || desiredValue === current[field];
2547
- });
2733
+ return changedFieldsBetween$1(desired, current).length === 0;
2548
2734
  }
2549
2735
  /**
2550
2736
  * Resource-kind module for Roblox places. Owns the entry schema,
@@ -2552,6 +2738,7 @@ function fieldsEqual$1(desired, current) {
2552
2738
  * kind.
2553
2739
  */
2554
2740
  const placeKind = {
2741
+ changedFieldsBetween: changedFieldsBetween$1,
2555
2742
  entrySchema: entrySchema$1,
2556
2743
  fieldsEqual: fieldsEqual$1,
2557
2744
  flatten: flatten$1,
@@ -2634,22 +2821,20 @@ function socialLinkEqual(a, b) {
2634
2821
  if (b === void 0) return false;
2635
2822
  return a.title === b.title && a.uri === b.uri;
2636
2823
  }
2637
- function declaredSocialLinksEqual(desired, current) {
2638
- for (const field of SOCIAL_LINK_FIELDS) {
2639
- if (!(field in desired)) continue;
2640
- if (!socialLinkEqual(desired[field], current[field])) return false;
2641
- }
2642
- return true;
2824
+ function changedFieldsBetween(desired, current) {
2825
+ return [
2826
+ ...desired.universeId === current.universeId ? [] : ["universeId"],
2827
+ ...UNIVERSE_MANAGED_FLAGS.filter((flag) => {
2828
+ const isDesiredEnabled = desired[flag];
2829
+ return isDesiredEnabled !== void 0 && isDesiredEnabled !== current[flag];
2830
+ }),
2831
+ ...desired.displayName === void 0 || desired.displayName === current.displayName ? [] : ["displayName"],
2832
+ ..."privateServerPriceRobux" in desired && desired.privateServerPriceRobux !== current.privateServerPriceRobux ? ["privateServerPriceRobux"] : [],
2833
+ ...SOCIAL_LINK_FIELDS.filter((field) => field in desired && !socialLinkEqual(desired[field], current[field]))
2834
+ ];
2643
2835
  }
2644
2836
  function fieldsEqual(desired, current) {
2645
- if (desired.universeId !== current.universeId) return false;
2646
- for (const flag of UNIVERSE_MANAGED_FLAGS) {
2647
- const isDesiredEnabled = desired[flag];
2648
- if (isDesiredEnabled !== void 0 && isDesiredEnabled !== current[flag]) return false;
2649
- }
2650
- if (desired.displayName !== void 0 && desired.displayName !== current.displayName) return false;
2651
- if ("privateServerPriceRobux" in desired && desired.privateServerPriceRobux !== current.privateServerPriceRobux) return false;
2652
- return declaredSocialLinksEqual(desired, current);
2837
+ return changedFieldsBetween(desired, current).length === 0;
2653
2838
  }
2654
2839
  //#endregion
2655
2840
  //#region src/core/kinds/index.ts
@@ -2675,6 +2860,7 @@ const defaultKindRegistry = {
2675
2860
  gamePass: gamePassKind,
2676
2861
  place: placeKind,
2677
2862
  universe: {
2863
+ changedFieldsBetween,
2678
2864
  entrySchema,
2679
2865
  fieldsEqual,
2680
2866
  flatten,
@@ -2695,8 +2881,12 @@ const defaultKindRegistry = {
2695
2881
  * `update` op if any declared field differs or a `noop` op if every field
2696
2882
  * matches.
2697
2883
  *
2698
- * Ops appear in the order their desired entries appear in the input array so
2699
- * callers can rely on declaration order when logging or applying ops.
2884
+ * Ops appear in the order their desired entries appear in the input array.
2885
+ * `applyOps` regroups them into Phase 1 (universe) and Phase 2 (everything
2886
+ * else) when dispatching; the execution order within Phase 2 is not
2887
+ * guaranteed because Phase 2 dispatches concurrently. Persisted state-file
2888
+ * order is determined by the merge in `deploy.runReconcile` (which retains
2889
+ * prior-snapshot positions for unchanged keys), not by this diff output.
2700
2890
  *
2701
2891
  * @param desired - Declared desired state from user config, already normalized
2702
2892
  * (file hashes computed, nullable wire values mapped to `undefined`).
@@ -2760,6 +2950,11 @@ const defaultKindRegistry = {
2760
2950
  * const ops = diff([unchanged, drifted, fresh], current);
2761
2951
  *
2762
2952
  * expect(ops.map((op) => op.type)).toEqual(["noop", "update", "create"]);
2953
+ *
2954
+ * const updateOp = ops[1]!;
2955
+ * if (updateOp.type === "update") {
2956
+ * expect(updateOp.changedFields).toStrictEqual(["name"]);
2957
+ * }
2763
2958
  * ```
2764
2959
  */
2765
2960
  function diff(desired, current) {
@@ -2769,21 +2964,21 @@ function diff(desired, current) {
2769
2964
  function compositeKey$1(resource) {
2770
2965
  return `${resource.kind}:${resource.key}`;
2771
2966
  }
2772
- function desiredFieldsEqual(desired, current) {
2773
- return defaultKindRegistry[desired.kind].fieldsEqual(desired, current);
2774
- }
2775
2967
  function operationFor(desired, current) {
2776
2968
  if (current === void 0) return {
2777
2969
  key: desired.key,
2778
2970
  desired,
2779
2971
  type: "create"
2780
2972
  };
2781
- if (desiredFieldsEqual(desired, current)) return {
2973
+ const changedFields = defaultKindRegistry[desired.kind].changedFieldsBetween(desired, current);
2974
+ if (changedFields.length === 0) return {
2782
2975
  key: desired.key,
2976
+ kind: desired.kind,
2783
2977
  type: "noop"
2784
2978
  };
2785
2979
  return {
2786
2980
  key: desired.key,
2981
+ changedFields,
2787
2982
  current,
2788
2983
  desired,
2789
2984
  type: "update"
@@ -2879,79 +3074,89 @@ function capitalize(value) {
2879
3074
  function flattenConfig(config) {
2880
3075
  return Object.values(defaultKindRegistry).flatMap((module) => module.flatten(config));
2881
3076
  }
2882
- //#endregion
2883
- //#region src/core/resolve-state-config.ts
2884
3077
  /**
2885
- * Pick the `StateConfig` that applies to `environment`. Per-environment
2886
- * overrides win over the root block; if neither is present, returns
2887
- * `Err(stateNotConfigured)` so the deploy boundary can surface a typed
2888
- * error instead of silently falling back.
2889
- *
2890
- * @param config - Validated project config.
2891
- * @param environment - Target environment name.
2892
- * @returns The resolved `StateConfig`, or `Err(stateNotConfigured)` when
2893
- * neither the environment override nor the root block is set.
2894
- * @example
2895
- *
2896
- * ```ts
2897
- * import { resolveStateConfig } from "@bedrock-rbx/core";
2898
- *
2899
- * const result = resolveStateConfig(
2900
- * {
2901
- * state: { backend: "gist", gistId: "root-gist" },
2902
- * environments: {
2903
- * production: { state: { backend: "gist", gistId: "prod-gist" } },
2904
- * },
2905
- * },
2906
- * "production",
2907
- * );
3078
+ * Common prefix used to build the default name pushed for a redacted
3079
+ * developer-product. The full default produced by {@link defaultRedactedProductName}
3080
+ * is `${REDACTED_PRODUCT_NAME} ${suffix}`, where `suffix` is a 6-hex-char
3081
+ * digest of the resource key (see {@link redactedNameSuffix}). The suffix is
3082
+ * required because Roblox enforces per-universe uniqueness on
3083
+ * developer-product names, so a shared bare placeholder would collide across
3084
+ * multiple redacted entries. The prefix avoids the word `Redacted` and the
3085
+ * `#` separator because Roblox's text-moderation filter has been observed
3086
+ * silently replacing names matching `Redacted Product #<hex>` with
3087
+ * `########################`, which then causes downstream `DuplicateProductName`
3088
+ * errors when other redacted entries are moderated to the same string.
3089
+ */
3090
+ const REDACTED_PRODUCT_NAME = "Hidden Product";
3091
+ const PASS_PRODUCT_ENV_FIELDS = [
3092
+ "description",
3093
+ "icon",
3094
+ "name",
3095
+ "price"
3096
+ ];
3097
+ const PLACE_ENV_FIELDS = ["description", "displayName"];
3098
+ /**
3099
+ * Six-character lowercase hex digest of `SHA-256(key)`, used as the
3100
+ * disambiguating suffix on a redacted developer-product's default `name`.
3101
+ * Stable across config edits (driven only by the bedrock resource key, not
3102
+ * declaration order) and opaque to a Roblox player browsing the marketplace.
3103
+ * A natural collision is caught at plan time by `validatePlan`.
3104
+ *
3105
+ * @param key - Bedrock resource key for the developer product being redacted.
3106
+ * @returns The first six lowercase hex characters of the SHA-256 digest of `key`.
3107
+ */
3108
+ function redactedNameSuffix(key) {
3109
+ return createHash("sha256").update(key).digest("hex").slice(0, 6);
3110
+ }
3111
+ /**
3112
+ * Default redacted name for a developer product with the given resource key.
3113
+ * Combines {@link REDACTED_PRODUCT_NAME} with {@link redactedNameSuffix} so
3114
+ * each redacted entry resolves to a unique value the upstream API will accept.
2908
3115
  *
2909
- * expect(result.success).toBeTrue();
2910
- * if (result.success) {
2911
- * expect(result.data).toContainEntry(["gistId", "prod-gist"]);
2912
- * }
2913
- * ```
3116
+ * @param key - Bedrock resource key for the developer product being redacted.
3117
+ * @returns The placeholder name pushed to Roblox for this product.
2914
3118
  */
2915
- function resolveStateConfig(config, environment) {
2916
- const override = config.environments[environment]?.state;
2917
- if (override !== void 0) return {
2918
- data: override,
2919
- success: true
2920
- };
2921
- if (config.state !== void 0) return {
2922
- data: config.state,
2923
- success: true
2924
- };
2925
- return {
2926
- err: {
2927
- environment,
2928
- kind: "stateNotConfigured"
2929
- },
2930
- success: false
2931
- };
3119
+ function defaultRedactedProductName(key) {
3120
+ return `${REDACTED_PRODUCT_NAME} ${redactedNameSuffix(key)}`;
2932
3121
  }
2933
3122
  /**
2934
3123
  * Pure transform that substitutes bedrock-supplied placeholder content for
2935
- * every resource whose effective `redacted` flag is truthy. The effective
2936
- * flag is the per-resource `redacted` value when set, otherwise the
2937
- * `environmentRedacted` fallback. A `redacted` object form replaces
2938
- * matching fields with the supplied values and falls back to the bedrock
2939
- * defaults for the rest. Runs between env-overlay merge and display-name
2940
- * prefix render so the rest of the pipeline (flatten, normalize, diff,
2941
- * apply) operates on already-redacted values and needs no special-case
2942
- * redaction logic.
3124
+ * every resource whose effective redaction state is truthy. Three layers
3125
+ * compose field-by-field per resource: env-resource (most-specific, from
3126
+ * `inputs.envResource`), root-resource (the `redacted` field on the
3127
+ * passed-in entry), and env-level (least-specific, `inputs.envLevel`).
3128
+ * The first non-undefined value sets state (`false` carves out); object
3129
+ * layers then contribute fields with the most-specific layer winning per
3130
+ * field, and bedrock defaults fill any field nobody set. Runs between
3131
+ * env-overlay merge and display-name prefix render so the rest of the
3132
+ * pipeline (flatten, normalize, diff, apply) operates on already-redacted
3133
+ * values and needs no special-case redaction logic.
2943
3134
  *
2944
3135
  * @param config - Post-merge `ResolvedConfig` produced by `selectEnvironment`.
2945
- * @param environmentRedacted - Environment-level redaction toggle. Resources
2946
- * that omit a per-resource `redacted` flag inherit this value.
3136
+ * @param inputs - Aggregated redaction layers. Omit to skip redaction
3137
+ * entirely. See {@link RedactionInputs} for the shape.
2947
3138
  * @returns A `ResolvedConfig` whose redacted entries carry placeholder
2948
3139
  * values; non-redacted entries pass through verbatim, and the input is
2949
3140
  * not mutated.
2950
3141
  */
2951
- function applyRedaction(config, environmentRedacted = false) {
2952
- const passes = redactPasses(config.passes, environmentRedacted);
2953
- const places = redactPlaces(config.places, environmentRedacted);
2954
- const products = redactProducts(config.products, environmentRedacted);
3142
+ function applyRedaction(config, inputs) {
3143
+ const environmentLevel = inputs?.envLevel;
3144
+ const environmentResource = inputs?.envResource;
3145
+ const passes = redactPasses({
3146
+ collection: config.passes,
3147
+ envLevel: environmentLevel,
3148
+ envResource: environmentResource?.passes
3149
+ });
3150
+ const places = redactPlaces({
3151
+ collection: config.places,
3152
+ envLevel: environmentLevel,
3153
+ envResource: environmentResource?.places
3154
+ });
3155
+ const products = redactProducts({
3156
+ collection: config.products,
3157
+ envLevel: environmentLevel,
3158
+ envResource: environmentResource?.products
3159
+ });
2955
3160
  if (passes === config.passes && places === config.places && products === config.products) return config;
2956
3161
  return {
2957
3162
  ...config,
@@ -2962,9 +3167,10 @@ function applyRedaction(config, environmentRedacted = false) {
2962
3167
  }
2963
3168
  /**
2964
3169
  * Inspect the pre-redaction merged config and produce one annotation per
2965
- * resource flagged `redacted: true`. Callers thread the result into plan
2966
- * output so authors can see which resources are redacted in the active
2967
- * environment and whether their real-value edits are being suppressed.
3170
+ * resource flagged `redacted: true` at either the root entry or its
3171
+ * env-overlay counterpart. Callers thread the result into plan output so
3172
+ * authors can see which resources are redacted in the active environment
3173
+ * and whether their real-value edits are being suppressed.
2968
3174
  *
2969
3175
  * Operates on the pre-redaction view because the post-redaction config no
2970
3176
  * longer carries the real `name`/`description`/`icon` values needed to
@@ -2972,42 +3178,107 @@ function applyRedaction(config, environmentRedacted = false) {
2972
3178
  *
2973
3179
  * @param merged - `ResolvedConfig` produced by environment overlay merge,
2974
3180
  * before `applyRedaction` has substituted placeholders.
3181
+ * @param environmentResource - Per-kind env-overlay redaction layers
3182
+ * extracted from the active env entry. Omit when the caller has no
3183
+ * env-overlay layer.
2975
3184
  * @returns Zero or more annotations, one per redacted resource. Empty when
2976
3185
  * the config declares no redacted resources.
2977
3186
  */
2978
- function collectRedactionAnnotations(merged) {
2979
- const passes = Object.entries(merged.passes ?? {}).filter(([, entry]) => entry.redacted === true).map(([key, entry]) => {
3187
+ function collectRedactionAnnotations(merged, environmentResource) {
3188
+ const passes = Object.entries(merged.passes ?? {}).filter(([key, entry]) => entry.redacted === true || environmentResource?.passes?.[key] === true).map(([key, entry]) => {
2980
3189
  return {
2981
3190
  key: asResourceKey(key),
2982
3191
  hasRealValueEdits: passHasRealValueEdits(entry),
2983
3192
  kind: "gamePass"
2984
3193
  };
2985
3194
  });
2986
- const products = Object.entries(merged.products ?? {}).filter(([, entry]) => entry.redacted === true).map(([key, entry]) => {
3195
+ const products = Object.entries(merged.products ?? {}).filter(([key, entry]) => entry.redacted === true || environmentResource?.products?.[key] === true).map(([key, entry]) => {
2987
3196
  return {
2988
3197
  key: asResourceKey(key),
2989
- hasRealValueEdits: productHasRealValueEdits(entry),
3198
+ hasRealValueEdits: productHasRealValueEdits(key, entry),
2990
3199
  kind: "developerProduct"
2991
3200
  };
2992
3201
  });
2993
3202
  return [...passes, ...products];
2994
3203
  }
3204
+ function pickEnvironmentFields(environmentLevel, fields) {
3205
+ if (environmentLevel === void 0 || typeof environmentLevel === "boolean") return environmentLevel;
3206
+ return Object.fromEntries(fields.map((field) => [field, environmentLevel[field]]));
3207
+ }
3208
+ /**
3209
+ * Walk redaction layers most-specific to least-specific and produce the
3210
+ * effective per-field override for one resource. Returns `undefined` when the
3211
+ * resource is not redacted; returns a (possibly empty) object when it is.
3212
+ * State step: the first non-undefined layer sets state -- `false` carves out,
3213
+ * `true` or object enables. Fields step: walk every object layer in the same
3214
+ * order, taking the first value per field. A field's value may itself be
3215
+ * `undefined` (the env-level projection produced by {@link pickEnvironmentFields}
3216
+ * includes every projected key, even when the env override left it absent);
3217
+ * downstream per-kind redact functions collapse those back to bedrock
3218
+ * placeholder defaults via `??`.
3219
+ *
3220
+ * @template Override - Per-kind override type the resource accepts.
3221
+ * @param layers - Layers ordered most-specific (index 0) to least-specific.
3222
+ * @returns The effective override, or `undefined` when not redacted.
3223
+ */
3224
+ function resolveEffectiveOverride(layers) {
3225
+ const firstNonUndefined = layers.find((layer) => layer !== void 0);
3226
+ if (firstNonUndefined === void 0 || firstNonUndefined === false) return;
3227
+ const effective = {};
3228
+ for (const layer of layers) {
3229
+ if (typeof layer !== "object") continue;
3230
+ for (const [field, value] of Object.entries(layer)) if (!(field in effective)) effective[field] = value;
3231
+ }
3232
+ return effective;
3233
+ }
3234
+ function resolveEntries(inputs) {
3235
+ const { collection, environmentForKind, envResource } = inputs;
3236
+ return Object.entries(collection).map(([key, entry]) => {
3237
+ return {
3238
+ key,
3239
+ entry,
3240
+ override: resolveEffectiveOverride([
3241
+ envResource?.[key],
3242
+ entry.redacted,
3243
+ environmentForKind
3244
+ ])
3245
+ };
3246
+ });
3247
+ }
3248
+ function redactCollection(inputs) {
3249
+ const { collection, environmentForKind, envResource, redact } = inputs;
3250
+ if (collection === void 0) return;
3251
+ const resolved = resolveEntries({
3252
+ collection,
3253
+ environmentForKind,
3254
+ envResource
3255
+ });
3256
+ if (resolved.every((item) => item.override === void 0)) return collection;
3257
+ return Object.fromEntries(resolved.map((item) => {
3258
+ return item.override === void 0 ? [item.key, item.entry] : [item.key, redact({
3259
+ key: item.key,
3260
+ entry: item.entry,
3261
+ override: item.override
3262
+ })];
3263
+ }));
3264
+ }
2995
3265
  function redactPass(entry, override) {
2996
3266
  return {
2997
3267
  ...entry,
2998
3268
  name: override.name ?? "Redacted Pass",
2999
3269
  description: override.description ?? "",
3000
- icon: override.icon ?? { "en-us": "<bedrock:redacted-icon.png>" }
3270
+ icon: override.icon ?? { "en-us": "<bedrock:redacted-icon.png>" },
3271
+ ...entry.price === void 0 ? {} : { price: override.price ?? 99999 }
3001
3272
  };
3002
3273
  }
3003
- function redactPasses(passes, environmentRedacted) {
3004
- if (passes === void 0) return;
3005
- if (!Object.values(passes).some((entry) => (entry.redacted ?? environmentRedacted) !== false)) return passes;
3006
- return Object.fromEntries(Object.entries(passes).map(([key, entry]) => {
3007
- const effective = entry.redacted ?? environmentRedacted;
3008
- if (effective === false) return [key, entry];
3009
- return [key, redactPass(entry, typeof effective === "object" ? effective : {})];
3010
- }));
3274
+ function redactPasses(inputs) {
3275
+ const { collection, envLevel, envResource } = inputs;
3276
+ return redactCollection({
3277
+ collection,
3278
+ environmentForKind: pickEnvironmentFields(envLevel, PASS_PRODUCT_ENV_FIELDS),
3279
+ envResource,
3280
+ redact: (item) => redactPass(item.entry, item.override)
3281
+ });
3011
3282
  }
3012
3283
  function redactPlace(entry, override) {
3013
3284
  return {
@@ -3016,37 +3287,39 @@ function redactPlace(entry, override) {
3016
3287
  displayName: override.displayName ?? entry.displayName
3017
3288
  };
3018
3289
  }
3019
- function redactPlaces(places, environmentRedacted) {
3020
- if (places === void 0) return;
3021
- if (!Object.values(places).some((entry) => (entry.redacted ?? environmentRedacted) !== false)) return places;
3022
- return Object.fromEntries(Object.entries(places).map(([key, entry]) => {
3023
- const effective = entry.redacted ?? environmentRedacted;
3024
- if (effective === false) return [key, entry];
3025
- return [key, redactPlace(entry, typeof effective === "object" ? effective : {})];
3026
- }));
3290
+ function redactPlaces(inputs) {
3291
+ const { collection, envLevel, envResource } = inputs;
3292
+ return redactCollection({
3293
+ collection,
3294
+ environmentForKind: pickEnvironmentFields(envLevel, PLACE_ENV_FIELDS),
3295
+ envResource,
3296
+ redact: (item) => redactPlace(item.entry, item.override)
3297
+ });
3027
3298
  }
3028
- function redactProduct(entry, override) {
3299
+ function redactProduct(inputs) {
3300
+ const { key, entry, override } = inputs;
3029
3301
  return {
3030
3302
  ...entry,
3031
- name: override.name ?? "Redacted Product",
3303
+ name: override.name ?? defaultRedactedProductName(key),
3032
3304
  description: override.description ?? "",
3033
- icon: override.icon ?? { "en-us": "<bedrock:redacted-icon.png>" }
3305
+ icon: override.icon ?? { "en-us": "<bedrock:redacted-icon.png>" },
3306
+ ...entry.price === void 0 ? {} : { price: override.price ?? 99999 }
3034
3307
  };
3035
3308
  }
3036
- function redactProducts(products, environmentRedacted) {
3037
- if (products === void 0) return;
3038
- if (!Object.values(products).some((entry) => (entry.redacted ?? environmentRedacted) !== false)) return products;
3039
- return Object.fromEntries(Object.entries(products).map(([key, entry]) => {
3040
- const effective = entry.redacted ?? environmentRedacted;
3041
- if (effective === false) return [key, entry];
3042
- return [key, redactProduct(entry, typeof effective === "object" ? effective : {})];
3043
- }));
3309
+ function redactProducts(inputs) {
3310
+ const { collection, envLevel, envResource } = inputs;
3311
+ return redactCollection({
3312
+ collection,
3313
+ environmentForKind: pickEnvironmentFields(envLevel, PASS_PRODUCT_ENV_FIELDS),
3314
+ envResource,
3315
+ redact: redactProduct
3316
+ });
3044
3317
  }
3045
3318
  function passHasRealValueEdits(entry) {
3046
- return entry.name !== "Redacted Pass" || entry.description !== "" || entry.icon["en-us"] !== "<bedrock:redacted-icon.png>";
3319
+ return entry.name !== "Redacted Pass" || entry.description !== "" || entry.icon["en-us"] !== "<bedrock:redacted-icon.png>" || entry.price !== void 0 && entry.price !== 99999;
3047
3320
  }
3048
- function productHasRealValueEdits(entry) {
3049
- return entry.name !== "Redacted Product" || entry.description !== "" || entry.icon !== void 0 && entry.icon["en-us"] !== "<bedrock:redacted-icon.png>";
3321
+ function productHasRealValueEdits(key, entry) {
3322
+ return !(entry.name === defaultRedactedProductName(key) || entry.name === "Hidden Product") || entry.description !== "" || entry.icon !== void 0 && entry.icon["en-us"] !== "<bedrock:redacted-icon.png>" || entry.price !== void 0 && entry.price !== 99999;
3050
3323
  }
3051
3324
  //#endregion
3052
3325
  //#region src/core/select-environment.ts
@@ -3096,6 +3369,22 @@ function selectMergedEnvironment(config, environment) {
3096
3369
  };
3097
3370
  }
3098
3371
  /**
3372
+ * Build the per-resource env-overlay redaction layer that `applyRedaction`
3373
+ * and `collectRedactionAnnotations` consume. Reads each redactable kind off
3374
+ * the environment entry and projects every entry's `redacted` field into
3375
+ * the layer; omits kinds the env entry does not declare.
3376
+ *
3377
+ * @param entry - Environment entry whose overlay redaction values to extract.
3378
+ * @returns A `EnvironmentResourceRedaction` ready to pass downstream.
3379
+ */
3380
+ function extractResourceRedaction(entry) {
3381
+ return {
3382
+ ...entry.passes ? { passes: extractRedactionLayer(entry.passes) } : {},
3383
+ ...entry.places ? { places: extractRedactionLayer(entry.places) } : {},
3384
+ ...entry.products ? { products: extractRedactionLayer(entry.products) } : {}
3385
+ };
3386
+ }
3387
+ /**
3099
3388
  * Project a validated `Config` onto a single environment. Looks up the
3100
3389
  * matching `environments[environment]` entry, deep-merges its resource
3101
3390
  * overlay (`passes`, `places`, `universe`) over the root config via defu,
@@ -3226,10 +3515,17 @@ function mergeUniverse(overlay, base) {
3226
3515
  if (overlay === void 0 && base === void 0) return;
3227
3516
  return defu(overlay ?? {}, base ?? {});
3228
3517
  }
3518
+ function stripRedacted(overlay) {
3519
+ if (overlay === void 0) return;
3520
+ return Object.fromEntries(Object.entries(overlay).map(([key, entryValue]) => {
3521
+ const { redacted: _redacted, ...rest } = entryValue;
3522
+ return [key, rest];
3523
+ }));
3524
+ }
3229
3525
  function mergeOverlays(config, entry) {
3230
- const passes = mergeKeyedRecord(entry.passes, config.passes);
3231
- const places = mergeKeyedRecord(entry.places, config.places);
3232
- const products = mergeKeyedRecord(entry.products, config.products);
3526
+ const passes = mergeKeyedRecord(stripRedacted(entry.passes), config.passes);
3527
+ const places = mergeKeyedRecord(stripRedacted(entry.places), config.places);
3528
+ const products = mergeKeyedRecord(stripRedacted(entry.products), config.products);
3233
3529
  const universe = mergeUniverse(entry.universe, config.universe);
3234
3530
  const state = entry.state ?? config.state;
3235
3531
  const { places: _placesRoot, products: _productsRoot, universe: _universeRoot, ...rest } = config;
@@ -3277,6 +3573,11 @@ function findIncompletePlace(projected, environment) {
3277
3573
  };
3278
3574
  }
3279
3575
  }
3576
+ function extractRedactionLayer(overlay) {
3577
+ const layer = {};
3578
+ for (const [key, entryValue] of Object.entries(overlay)) if (entryValue.redacted !== void 0) layer[key] = entryValue.redacted;
3579
+ return layer;
3580
+ }
3280
3581
  function resolvePrefix(config, entry) {
3281
3582
  if (config.displayNamePrefix?.enabled === false) return;
3282
3583
  const { label } = entry;
@@ -3302,7 +3603,10 @@ function applyPlacesPrefix(places, prefix) {
3302
3603
  }
3303
3604
  function redactAndPrefix(inputs) {
3304
3605
  const { config, entry, merged } = inputs;
3305
- const redacted = applyRedaction(merged, entry.redacted);
3606
+ const redacted = applyRedaction(merged, {
3607
+ envLevel: entry.redacted,
3608
+ envResource: extractResourceRedaction(entry)
3609
+ });
3306
3610
  const prefix = resolvePrefix(config, entry);
3307
3611
  const places = applyPlacesPrefix(redacted.places, prefix);
3308
3612
  const universe = applyUniversePrefix(redacted.universe, prefix);
@@ -3374,6 +3678,8 @@ function redactAndPrefix(inputs) {
3374
3678
  * ```
3375
3679
  */
3376
3680
  function validatePlan(desired, current) {
3681
+ const collision = detectProductNameCollision(desired);
3682
+ if (collision !== void 0) return collision;
3377
3683
  const currentByKey = new Map(current.map((entry) => [compositeKey(entry), entry]));
3378
3684
  for (const entry of desired) {
3379
3685
  const matched = currentByKey.get(compositeKey(entry));
@@ -3389,136 +3695,116 @@ function validatePlan(desired, current) {
3389
3695
  function compositeKey(resource) {
3390
3696
  return `${resource.kind}:${resource.key}`;
3391
3697
  }
3698
+ function detectProductNameCollision(desired) {
3699
+ const seenByName = /* @__PURE__ */ new Map();
3700
+ for (const entry of desired) {
3701
+ if (entry.kind !== "developerProduct") continue;
3702
+ const prior = seenByName.get(entry.name);
3703
+ if (prior === void 0) {
3704
+ seenByName.set(entry.name, entry.key);
3705
+ continue;
3706
+ }
3707
+ return {
3708
+ err: {
3709
+ keys: [prior, entry.key],
3710
+ kind: "redactedNameCollision",
3711
+ message: `developer products '${prior}' and '${entry.key}' both resolve to the wire name '${entry.name}'. Roblox enforces per-universe uniqueness on developer-product names, so the second update would be rejected as DuplicateProductName. Set 'redacted: { name: "<unique>" }' on one of them to disambiguate.`,
3712
+ resolvedName: entry.name
3713
+ },
3714
+ success: false
3715
+ };
3716
+ }
3717
+ }
3392
3718
  //#endregion
3393
3719
  //#region src/shell/apply-ops.ts
3394
3720
  /**
3395
- * Dispatch each reconciliation operation to the matching resource driver
3396
- * with first-fail semantics: on the first `Err` (driver failure or
3397
- * `updateUnsupported`), the remaining operations are skipped and the error
3398
- * is returned verbatim.
3721
+ * Dispatch reconciliation operations to their matching drivers in two phases
3722
+ * with continue-on-failure semantics. Phase 1 runs universe ops sequentially
3723
+ * (singleton per environment; sequencing it before everything else avoids the
3724
+ * `displayName` race against the root `Place`). Phase 2 dispatches every
3725
+ * remaining non-noop op concurrently via `Promise.all`; every op is
3726
+ * attempted regardless of earlier failures.
3399
3727
  *
3400
3728
  * Behaviour:
3401
- * - `create` operations are routed to `registry[op.desired.kind].create`.
3402
- * - `update` operations are routed to `registry[op.desired.kind].update`
3403
- * when the driver exposes it; otherwise they short-circuit to an
3404
- * `updateUnsupported` Err without invoking the driver.
3729
+ * - `create` operations route to `registry[op.desired.kind].create`.
3730
+ * - `update` operations route to `registry[op.desired.kind].update` when the
3731
+ * driver exposes it; otherwise they yield an `updateUnsupported`
3732
+ * `ApplyError` without invoking the driver.
3405
3733
  * - `noop` operations are skipped entirely (no I/O, no dispatch).
3406
- *
3407
- * On success the returned array carries the driver outputs for every
3408
- * non-noop op, in dispatched order. Noops are not represented; callers
3409
- * needing a full post-apply snapshot merge with the pre-apply current
3410
- * state keyed by `ResourceKey`.
3411
- *
3412
- * @param ops - Reconciliation operations produced by `diff`, applied in order.
3413
- * @param registry - Per-kind driver table; dispatch uses `op.desired.kind` as the index.
3414
- * @returns `Ok(state)` when every operation succeeds, where `state` holds
3415
- * driver outputs for each non-noop op in dispatched order; or the first
3416
- * failure encountered.
3417
- * @throws Whatever the dispatched driver rejects with outside its `Result`
3418
- * return. A driver whose injected I/O (file reads, network calls, etc.)
3419
- * throws will surface that rejection here rather than translating it into
3420
- * a `Result` failure; wrap the call site in a try/catch when drivers are
3421
- * not trusted to contain their own rejections.
3734
+ * - A driver that throws outside its `Result` contract is caught at the
3735
+ * dispatch boundary and translated to an `unexpectedThrow` `ApplyError`
3736
+ * scoped to that op alone; the rest of the batch keeps running.
3737
+ *
3738
+ * On Ok the returned array carries driver outputs for every non-noop op
3739
+ * in phase order: Phase 1 universe entries first, then Phase 2 entries in
3740
+ * their input order. Noops are not represented; callers needing a full
3741
+ * post-apply snapshot merge with the pre-apply current state keyed by
3742
+ * `ResourceKey`.
3743
+ *
3744
+ * On Err the aggregate carries every survivor in `applied` (Phase 1 first,
3745
+ * then Phase 2 input order) and every failure in `failures` with the same
3746
+ * grouping. Neither array reflects completion order.
3747
+ *
3748
+ * @param ops - Reconciliation operations produced by `diff`, applied in
3749
+ * declaration order.
3750
+ * @param registry - Per-kind driver table; dispatch uses `op.desired.kind`
3751
+ * as the index.
3752
+ * @param reporting - Optional progress wiring. When supplied, `applyOps`
3753
+ * emits one `resourceOpStarted` and one terminal event per non-noop op,
3754
+ * one `resourceOpNoop` per noop op, and a final `applySummary` carrying
3755
+ * the per-type counts and the wall-clock apply duration. When omitted,
3756
+ * no events fire.
3757
+ * @returns `Ok(state)` when every op succeeded; otherwise
3758
+ * `Err(AggregateApplyError)` with the survivors and the non-empty
3759
+ * failures tuple.
3422
3760
  * @example
3423
3761
  *
3424
3762
  * ```ts
3425
- * import {
3426
- * applyOps,
3427
- * asResourceKey,
3428
- * asRobloxAssetId,
3429
- * asSha256Hex,
3430
- * type DriverRegistry,
3431
- * type Operation,
3432
- * } from "@bedrock-rbx/core";
3763
+ * import { applyOps, type DriverRegistry } from "@bedrock-rbx/core";
3433
3764
  *
3434
- * const registry: DriverRegistry = {
3435
- * gamePass: {
3436
- * async create(desired) {
3437
- * return {
3438
- * data: {
3439
- * ...desired,
3440
- * outputs: {
3441
- * assetId: asRobloxAssetId("9876543210"),
3442
- * iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
3443
- * },
3444
- * },
3445
- * success: true,
3446
- * };
3447
- * },
3448
- * },
3449
- * place: {
3450
- * async create(desired) {
3451
- * return {
3452
- * data: { ...desired, outputs: { versionNumber: 1 } },
3453
- * success: true,
3454
- * };
3455
- * },
3456
- * },
3457
- * universe: {
3458
- * async create(desired) {
3459
- * return {
3460
- * data: { ...desired, outputs: { rootPlaceId: asRobloxAssetId("4711") } },
3461
- * success: true,
3462
- * };
3463
- * },
3464
- * },
3465
- * developerProduct: {
3466
- * async create(desired) {
3467
- * return {
3468
- * data: {
3469
- * ...desired,
3470
- * outputs: { productId: asRobloxAssetId("8172635495") },
3471
- * },
3472
- * success: true,
3473
- * };
3474
- * },
3475
- * },
3765
+ * const noopRegistry: DriverRegistry = {
3766
+ * developerProduct: { create: async () => ({ err: new Error("stub") as never, success: false }) },
3767
+ * gamePass: { create: async () => ({ err: new Error("stub") as never, success: false }) },
3768
+ * place: { create: async () => ({ err: new Error("stub") as never, success: false }) },
3769
+ * universe: { create: async () => ({ err: new Error("stub") as never, success: false }) },
3476
3770
  * };
3477
3771
  *
3478
- * const ops: ReadonlyArray<Operation> = [
3479
- * {
3480
- * key: asResourceKey("vip-pass"),
3481
- * type: "create",
3482
- * desired: {
3483
- * key: asResourceKey("vip-pass"),
3484
- * name: "VIP Pass",
3485
- * description: "Grants VIP perks.",
3486
- * icon: { "en-us": "assets/vip-icon.png" },
3487
- * iconFileHashes: {
3488
- * "en-us": asSha256Hex(
3489
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3490
- * ),
3491
- * },
3492
- * kind: "gamePass",
3493
- * price: 500,
3494
- * },
3495
- * },
3496
- * ];
3497
- *
3498
- * return applyOps(ops, registry).then((result) => {
3499
- * expect(result.success).toBe(true);
3500
- * expect(result.success && result.data).toHaveLength(1);
3772
+ * return applyOps([], noopRegistry).then((result) => {
3773
+ * expect(result).toStrictEqual({ data: [], success: true });
3501
3774
  * });
3502
3775
  * ```
3503
3776
  */
3504
- async function applyOps(ops, registry) {
3505
- const applied = [];
3506
- for (const op of ops) {
3507
- if (op.type === "noop") continue;
3508
- const outcome = await dispatchOp(op, registry);
3509
- if (!outcome.success) return {
3510
- err: {
3511
- ...outcome.err,
3512
- appliedSoFar: applied
3513
- },
3514
- success: false
3515
- };
3516
- applied.push(outcome.data);
3517
- }
3518
- return {
3777
+ async function applyOps(ops, registry, reporting) {
3778
+ const start = Date.now();
3779
+ const { noopCount, phase1, phase2 } = partitionAndEmitNoops(ops, reporting);
3780
+ const pairs = await dispatchInPhases({
3781
+ phase1,
3782
+ phase2,
3783
+ registry,
3784
+ reporting
3785
+ });
3786
+ const end = Date.now();
3787
+ const { applied, failures } = partitionOutcomes(pairs.map((pair) => pair.outcome));
3788
+ emitApplySummary({
3789
+ end,
3790
+ failures,
3791
+ noopCount,
3792
+ pairs,
3793
+ reporting,
3794
+ start
3795
+ });
3796
+ const [head, ...tail] = failures;
3797
+ if (head === void 0) return {
3519
3798
  data: applied,
3520
3799
  success: true
3521
3800
  };
3801
+ return {
3802
+ err: {
3803
+ applied,
3804
+ failures: [head, ...tail]
3805
+ },
3806
+ success: false
3807
+ };
3522
3808
  }
3523
3809
  function driverFailure(key, cause) {
3524
3810
  return {
@@ -3552,7 +3838,7 @@ async function applyOne(op, driver) {
3552
3838
  const updated = await driver.update(op.current, op.desired);
3553
3839
  return updated.success ? updated : driverFailure(op.key, updated.err);
3554
3840
  }
3555
- async function dispatchOp(op, registry) {
3841
+ async function dispatchByKind(op, registry) {
3556
3842
  switch (op.desired.kind) {
3557
3843
  case "developerProduct": return applyOne(op, registry.developerProduct);
3558
3844
  case "gamePass": return applyOne(op, registry.gamePass);
@@ -3560,6 +3846,161 @@ async function dispatchOp(op, registry) {
3560
3846
  case "universe": return applyOne(op, registry.universe);
3561
3847
  }
3562
3848
  }
3849
+ async function dispatchOp(op, registry) {
3850
+ try {
3851
+ return await dispatchByKind(op, registry);
3852
+ } catch (err) {
3853
+ return {
3854
+ err: {
3855
+ key: op.key,
3856
+ cause: err,
3857
+ kind: "unexpectedThrow"
3858
+ },
3859
+ success: false
3860
+ };
3861
+ }
3862
+ }
3863
+ function createSucceededEvent(input) {
3864
+ const { key, environment, state } = input;
3865
+ switch (state.kind) {
3866
+ case "developerProduct": return {
3867
+ key,
3868
+ environment,
3869
+ kind: "resourceOpSucceeded",
3870
+ opType: "create",
3871
+ outputs: state.outputs,
3872
+ resourceKind: "developerProduct"
3873
+ };
3874
+ case "gamePass": return {
3875
+ key,
3876
+ environment,
3877
+ kind: "resourceOpSucceeded",
3878
+ opType: "create",
3879
+ outputs: state.outputs,
3880
+ resourceKind: "gamePass"
3881
+ };
3882
+ case "place": return {
3883
+ key,
3884
+ environment,
3885
+ kind: "resourceOpSucceeded",
3886
+ opType: "create",
3887
+ outputs: state.outputs,
3888
+ resourceKind: "place"
3889
+ };
3890
+ case "universe": return {
3891
+ key,
3892
+ environment,
3893
+ kind: "resourceOpSucceeded",
3894
+ opType: "create",
3895
+ outputs: state.outputs,
3896
+ resourceKind: "universe"
3897
+ };
3898
+ }
3899
+ }
3900
+ function toTerminalEvent(input) {
3901
+ const { environment, op, outcome } = input;
3902
+ if (!outcome.success) return {
3903
+ key: op.key,
3904
+ environment,
3905
+ error: outcome.err,
3906
+ kind: "resourceOpFailed",
3907
+ opType: op.type,
3908
+ resourceKind: op.desired.kind
3909
+ };
3910
+ if (op.type === "update") return {
3911
+ key: op.key,
3912
+ changedFields: op.changedFields,
3913
+ environment,
3914
+ kind: "resourceOpSucceeded",
3915
+ opType: "update",
3916
+ resourceKind: op.desired.kind
3917
+ };
3918
+ return createSucceededEvent({
3919
+ key: op.key,
3920
+ environment,
3921
+ state: outcome.data
3922
+ });
3923
+ }
3924
+ async function reportAndDispatch(input) {
3925
+ const { op, registry, reporting } = input;
3926
+ if (reporting !== void 0) reporting.progress.emit({
3927
+ key: op.key,
3928
+ environment: reporting.environment,
3929
+ kind: "resourceOpStarted",
3930
+ opType: op.type,
3931
+ resourceKind: op.desired.kind
3932
+ });
3933
+ const outcome = await dispatchOp(op, registry);
3934
+ if (reporting !== void 0) reporting.progress.emit(toTerminalEvent({
3935
+ environment: reporting.environment,
3936
+ op,
3937
+ outcome
3938
+ }));
3939
+ return {
3940
+ op,
3941
+ outcome
3942
+ };
3943
+ }
3944
+ async function dispatchInPhases(input) {
3945
+ const phase1Pairs = [];
3946
+ for (const op of input.phase1) phase1Pairs.push(await reportAndDispatch({
3947
+ op,
3948
+ registry: input.registry,
3949
+ reporting: input.reporting
3950
+ }));
3951
+ const phase2Pairs = await Promise.all(input.phase2.map(async (op) => {
3952
+ return reportAndDispatch({
3953
+ op,
3954
+ registry: input.registry,
3955
+ reporting: input.reporting
3956
+ });
3957
+ }));
3958
+ return [...phase1Pairs, ...phase2Pairs];
3959
+ }
3960
+ function emitApplySummary(input) {
3961
+ if (input.reporting === void 0) return;
3962
+ const created = input.pairs.filter((pair) => pair.outcome.success && pair.op.type === "create").length;
3963
+ const updated = input.pairs.filter((pair) => pair.outcome.success && pair.op.type === "update").length;
3964
+ input.reporting.progress.emit({
3965
+ created,
3966
+ durationMs: input.end - input.start,
3967
+ environment: input.reporting.environment,
3968
+ failed: input.failures.length,
3969
+ kind: "applySummary",
3970
+ noop: input.noopCount,
3971
+ updated
3972
+ });
3973
+ }
3974
+ function partitionOutcomes(outcomes) {
3975
+ return {
3976
+ applied: outcomes.flatMap((outcome) => outcome.success ? [outcome.data] : []),
3977
+ failures: outcomes.flatMap((outcome) => outcome.success ? [] : [outcome.err])
3978
+ };
3979
+ }
3980
+ function emitNoop(op, reporting) {
3981
+ if (reporting === void 0) return;
3982
+ reporting.progress.emit({
3983
+ key: op.key,
3984
+ environment: reporting.environment,
3985
+ kind: "resourceOpNoop",
3986
+ resourceKind: op.kind
3987
+ });
3988
+ }
3989
+ function partitionAndEmitNoops(ops, reporting) {
3990
+ const phase1 = [];
3991
+ const phase2 = [];
3992
+ let noopCount = 0;
3993
+ for (const op of ops) if (op.type === "noop") {
3994
+ noopCount += 1;
3995
+ emitNoop(op, reporting);
3996
+ } else if (op.desired.kind === "universe") phase1.push(op);
3997
+ else phase2.push(op);
3998
+ return {
3999
+ noopCount,
4000
+ phase1,
4001
+ phase2
4002
+ };
4003
+ }
3563
4004
  //#endregion
3564
4005
  //#region src/shell/build-default-registry.ts
3565
4006
  /**
@@ -4283,6 +4724,7 @@ async function resolveDeps(options) {
4283
4724
  return {
4284
4725
  data: {
4285
4726
  config: effective,
4727
+ progress: options.progress,
4286
4728
  readFile: readFile$2,
4287
4729
  registry: registry.data,
4288
4730
  statePort: statePort.data
@@ -4297,7 +4739,7 @@ function mergeResources(pre, applied) {
4297
4739
  return [...byKey.values()];
4298
4740
  }
4299
4741
  function buildSnapshot(inputs) {
4300
- const appliedResources = inputs.applied.success ? inputs.applied.data : inputs.applied.err.appliedSoFar;
4742
+ const appliedResources = inputs.applied.success ? inputs.applied.data : inputs.applied.err.applied;
4301
4743
  return {
4302
4744
  environment: inputs.environment,
4303
4745
  resources: mergeResources(inputs.priorResources, appliedResources),
@@ -4305,13 +4747,6 @@ function buildSnapshot(inputs) {
4305
4747
  };
4306
4748
  }
4307
4749
  function finalize(inputs) {
4308
- if (!inputs.applied.success) return {
4309
- err: {
4310
- cause: inputs.applied.err,
4311
- kind: "applyFailed"
4312
- },
4313
- success: false
4314
- };
4315
4750
  if (!inputs.written.success) return {
4316
4751
  err: {
4317
4752
  cause: inputs.written.err,
@@ -4320,6 +4755,13 @@ function finalize(inputs) {
4320
4755
  },
4321
4756
  success: false
4322
4757
  };
4758
+ if (!inputs.applied.success) return {
4759
+ err: {
4760
+ cause: inputs.applied.err,
4761
+ kind: "applyFailed"
4762
+ },
4763
+ success: false
4764
+ };
4323
4765
  return {
4324
4766
  data: inputs.merged,
4325
4767
  success: true
@@ -4351,16 +4793,24 @@ async function runReconcile(environment, deps) {
4351
4793
  },
4352
4794
  success: false
4353
4795
  };
4354
- const applied = await applyOps(diff(desired.data, priorResources), deps.registry);
4796
+ const applied = await applyOps(diff(desired.data, priorResources), deps.registry, deps.progress === void 0 ? void 0 : {
4797
+ environment,
4798
+ progress: deps.progress
4799
+ });
4355
4800
  const merged = buildSnapshot({
4356
4801
  applied,
4357
4802
  environment,
4358
4803
  priorResources
4359
4804
  });
4805
+ const written = await deps.statePort.write(merged);
4806
+ if (written.success) deps.progress?.emit({
4807
+ environment,
4808
+ kind: "stateWritten"
4809
+ });
4360
4810
  return finalize({
4361
4811
  applied,
4362
4812
  merged,
4363
- written: await deps.statePort.write(merged)
4813
+ written
4364
4814
  });
4365
4815
  }
4366
4816
  //#endregion
@@ -5483,7 +5933,7 @@ const PRODUCT_ICON_KIND = "productIcon";
5483
5933
  * and the Roblox-assigned `iconImageAssetId` lands on the outputs.
5484
5934
  *
5485
5935
  * Resources whose payload is malformed (non-object, missing required string
5486
- * field, missing `productId`, malformed `fileHash`) are dropped silently.
5936
+ * field, missing `assetId`, malformed `fileHash`) are dropped silently.
5487
5937
  * Orphan `productIcon_<k>` resources (no matching product) emit one
5488
5938
  * `ambiguous` warning each.
5489
5939
  *
@@ -5546,7 +5996,7 @@ function readProductInputs(raw) {
5546
5996
  }
5547
5997
  function readProductOutputs(raw) {
5548
5998
  if (!isObjectPayload$1(raw)) return;
5549
- const productId = coerceRobloxId$2(raw["productId"]);
5999
+ const productId = coerceRobloxId$2(raw["assetId"]);
5550
6000
  if (productId === void 0) return;
5551
6001
  return { productId };
5552
6002
  }
@@ -6513,6 +6963,6 @@ function isFileMissing(err) {
6513
6963
  return typeof err === "object" && err !== null && "code" in err && typeof err.code === "string" && FILE_MISSING_CODES.has(err.code);
6514
6964
  }
6515
6965
  //#endregion
6516
- export { createGamePassDriver as A, createClackProgressAdapter as B, createPlaceDriver as C, parseStateFile as D, createGistStateAdapter as E, asSha256Hex as F, renderMigrationSummary as G, renderDeployError as H, isResourceKey as I, renderParseError as K, isRobloxAssetId as L, shouldReuploadIcon as M, asResourceKey as N, serializeStateFile as O, asRobloxAssetId as P, isSha256Hex as R, createUniverseDriver as S, UNIVERSE_SINGLETON_KEY as T, renderMigrateError as U, renderBuildStatePortError as V, renderMigrateParseError as W, diff as _, buildStatePort as a, validateConfig as b, applyOps as c, selectMergedEnvironment as d, collectRedactionAnnotations as f, renderDisplayNamePrefix as g, DEFAULT_PREFIX_FORMAT as h, loadConfig$1 as i, createDeveloperProductDriver as j, validateEnvironmentName as k, validatePlan as l, flattenConfig as m, serializeConfig as n, buildDesired as o, resolveStateConfig as p, renderStateWriteError as q, deploy as r, buildDefaultRegistry as s, migrateMantleState as t, selectEnvironment as u, defaultKindRegistry as v, SOCIAL_LINK_FIELDS as w, createClackPort as x, isGistStateConfig as y, derivePriceFields as z };
6966
+ export { createClackProgressAdapter as A, isSha256Hex as B, UNIVERSE_SINGLETON_KEY as C, createGamePassDriver as D, serializeStateFile as E, asResourceKey as F, renderMigrateParseError as G, renderBuildStatePortError as H, asRobloxAssetId as I, renderStateWriteError as J, renderMigrationSummary as K, asSha256Hex as L, validateConfig as M, shouldReuploadIcon as N, createDeveloperProductDriver as O, validateEnvironmentName as P, isResourceKey as R, SOCIAL_LINK_FIELDS as S, parseStateFile as T, renderDeployError as U, resolveStateConfig as V, renderMigrateError as W, diff as _, buildStatePort as a, createUniverseDriver as b, applyOps as c, selectEnvironment as d, selectMergedEnvironment as f, renderDisplayNamePrefix as g, DEFAULT_PREFIX_FORMAT as h, loadConfig$1 as i, isGistStateConfig as j, derivePriceFields as k, validatePlan as l, flattenConfig as m, serializeConfig as n, buildDesired as o, collectRedactionAnnotations as p, renderParseError as q, deploy as r, buildDefaultRegistry as s, migrateMantleState as t, extractResourceRedaction as u, defaultKindRegistry as v, createGistStateAdapter as w, createPlaceDriver as x, createClackPort as y, isRobloxAssetId as z };
6517
6967
 
6518
- //# sourceMappingURL=migrate-mantle-state-CQjWBZwT.mjs.map
6968
+ //# sourceMappingURL=migrate-mantle-state-qejWFAR0.mjs.map