@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.
- package/dist/cli/run.mjs +37 -23
- package/dist/cli/run.mjs.map +1 -1
- package/dist/config.d.mts +2 -2
- package/dist/{define-config-Bd0XIiSX.d.mts → define-config-C2cOtDpP.d.mts} +117 -46
- package/dist/{define-config-Bd0XIiSX.d.mts.map → define-config-C2cOtDpP.d.mts.map} +1 -1
- package/dist/index.d.mts +473 -305
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{migrate-mantle-state-CQjWBZwT.mjs → migrate-mantle-state-qejWFAR0.mjs} +2112 -1662
- package/dist/migrate-mantle-state-qejWFAR0.mjs.map +1 -0
- package/package.json +3 -3
- package/dist/migrate-mantle-state-CQjWBZwT.mjs.map +0 -1
|
@@ -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
|
|
20
|
-
*
|
|
21
|
-
* (
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
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 "
|
|
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/
|
|
206
|
+
//#region src/core/resolve-state-config.ts
|
|
188
207
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
247
|
-
*
|
|
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(
|
|
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
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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/
|
|
893
|
+
//#region src/core/validate-universe-xor.ts
|
|
842
894
|
/**
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
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
|
|
849
|
-
* @
|
|
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
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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/
|
|
942
|
+
//#region src/core/schema.ts
|
|
859
943
|
/**
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
*
|
|
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
|
|
877
|
-
* import {
|
|
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
|
|
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
|
-
*
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
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
|
|
938
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
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
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
1202
|
-
*
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1376
|
-
*
|
|
1377
|
-
*
|
|
1378
|
-
*
|
|
1379
|
-
*
|
|
1380
|
-
*
|
|
1381
|
-
*
|
|
1382
|
-
*
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
1385
|
-
*
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
1394
|
-
*
|
|
1395
|
-
|
|
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
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1590
|
-
*
|
|
1591
|
-
*
|
|
1592
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1601
|
-
*
|
|
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
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
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
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
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
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
return {
|
|
1627
|
-
|
|
1628
|
-
|
|
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/
|
|
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
|
-
*
|
|
1308
|
+
* Translate a Mantle-style optional price into the Open Cloud wire shape.
|
|
1656
1309
|
*
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
*
|
|
1671
|
-
*
|
|
1672
|
-
*
|
|
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
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
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
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
1687
|
-
*
|
|
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
|
|
1691
|
-
* @
|
|
1692
|
-
*
|
|
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
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
return
|
|
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
|
-
*
|
|
1701
|
-
*
|
|
1702
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
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/
|
|
1513
|
+
//#region src/adapters/game-pass-driver.ts
|
|
1715
1514
|
/**
|
|
1716
|
-
* Wraps {@link
|
|
1717
|
-
*
|
|
1718
|
-
*
|
|
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
|
-
*
|
|
1722
|
-
*
|
|
1723
|
-
*
|
|
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 `"
|
|
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 {
|
|
1532
|
+
* import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
|
|
1734
1533
|
* import {
|
|
1735
1534
|
* asResourceKey,
|
|
1736
1535
|
* asRobloxAssetId,
|
|
1737
1536
|
* asSha256Hex,
|
|
1738
|
-
*
|
|
1537
|
+
* createGamePassDriver,
|
|
1739
1538
|
* } from "@bedrock-rbx/core";
|
|
1740
1539
|
*
|
|
1741
1540
|
* const httpClient: HttpClient = {
|
|
1742
1541
|
* async request() {
|
|
1743
1542
|
* return {
|
|
1744
|
-
* data: {
|
|
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 =
|
|
1751
|
-
* client: new
|
|
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:
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1769
|
-
* "
|
|
1770
|
-
*
|
|
1771
|
-
*
|
|
1772
|
-
*
|
|
1773
|
-
*
|
|
1774
|
-
*
|
|
1775
|
-
*
|
|
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.
|
|
1588
|
+
* expect(result.data.outputs.assetId).toBe("9876543210");
|
|
1781
1589
|
* }
|
|
1782
1590
|
* });
|
|
1783
1591
|
* ```
|
|
1784
1592
|
*/
|
|
1785
|
-
function
|
|
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
|
|
1600
|
+
return createGamePass(effective, desired);
|
|
1789
1601
|
},
|
|
1790
|
-
async update(
|
|
1791
|
-
return
|
|
1602
|
+
async update(current, desired) {
|
|
1603
|
+
return updateGamePass(effective, {
|
|
1604
|
+
current,
|
|
1605
|
+
desired
|
|
1606
|
+
});
|
|
1792
1607
|
}
|
|
1793
1608
|
};
|
|
1794
1609
|
}
|
|
1795
|
-
function
|
|
1796
|
-
const
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
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
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
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
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
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
|
|
1829
|
-
const
|
|
1830
|
-
if (!
|
|
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:
|
|
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/
|
|
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
|
-
*
|
|
1848
|
-
*
|
|
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
|
-
*
|
|
1853
|
-
*
|
|
1854
|
-
*
|
|
1855
|
-
*
|
|
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
|
-
*
|
|
1860
|
-
*
|
|
1861
|
-
*
|
|
1862
|
-
*
|
|
1863
|
-
*
|
|
1864
|
-
*
|
|
1865
|
-
*
|
|
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
|
-
*
|
|
1868
|
-
*
|
|
1869
|
-
*
|
|
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
|
|
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
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
1903
|
-
*
|
|
1904
|
-
*
|
|
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
|
|
1914
|
-
* .
|
|
1915
|
-
*
|
|
1916
|
-
*
|
|
1917
|
-
*
|
|
1918
|
-
*
|
|
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
|
|
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
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
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
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
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
|
|
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
|
-
|
|
1954
|
-
|
|
1880
|
+
content,
|
|
1881
|
+
isTruncated: record["truncated"] === true,
|
|
1882
|
+
rawUrl,
|
|
1883
|
+
size
|
|
1955
1884
|
};
|
|
1956
1885
|
}
|
|
1957
|
-
function
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
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
|
|
1974
|
-
|
|
1975
|
-
|
|
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
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
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
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
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
|
|
1999
|
-
const
|
|
2000
|
-
const
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
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
|
-
|
|
2016
|
-
|
|
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/
|
|
2095
|
+
//#region src/core/resources.ts
|
|
2021
2096
|
/**
|
|
2022
|
-
*
|
|
2023
|
-
*
|
|
2024
|
-
*
|
|
2025
|
-
*
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
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
|
-
*
|
|
2066
|
-
*
|
|
2067
|
-
*
|
|
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
|
-
* @
|
|
2073
|
-
*
|
|
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
|
-
|
|
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/
|
|
2167
|
+
//#region src/adapters/place-driver.ts
|
|
2113
2168
|
/**
|
|
2114
|
-
*
|
|
2115
|
-
*
|
|
2116
|
-
*
|
|
2117
|
-
*
|
|
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 {
|
|
2123
|
-
* import
|
|
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
|
|
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
|
-
*
|
|
2128
|
-
*
|
|
2129
|
-
*
|
|
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
|
-
*
|
|
2134
|
-
*
|
|
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
|
|
2137
|
-
return
|
|
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
|
-
|
|
2140
|
-
const
|
|
2141
|
-
const
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
const
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
return
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
})
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
return
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
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
|
-
*
|
|
2297
|
-
*
|
|
2298
|
-
*
|
|
2299
|
-
*
|
|
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 {
|
|
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
|
|
2313
|
-
* {
|
|
2314
|
-
*
|
|
2315
|
-
*
|
|
2316
|
-
*
|
|
2317
|
-
*
|
|
2318
|
-
*
|
|
2319
|
-
*
|
|
2320
|
-
*
|
|
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
|
-
*
|
|
2325
|
-
* );
|
|
2326
|
-
* expect(ok.success).toBeTrue();
|
|
2351
|
+
* };
|
|
2327
2352
|
*
|
|
2328
|
-
* const
|
|
2329
|
-
*
|
|
2330
|
-
*
|
|
2331
|
-
*
|
|
2332
|
-
*
|
|
2333
|
-
*
|
|
2334
|
-
*
|
|
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
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
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
|
-
|
|
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
|
|
2699
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
2886
|
-
*
|
|
2887
|
-
*
|
|
2888
|
-
*
|
|
2889
|
-
*
|
|
2890
|
-
*
|
|
2891
|
-
*
|
|
2892
|
-
*
|
|
2893
|
-
*
|
|
2894
|
-
*
|
|
2895
|
-
*
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
*
|
|
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
|
-
*
|
|
2910
|
-
*
|
|
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
|
|
2916
|
-
|
|
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
|
|
2936
|
-
*
|
|
2937
|
-
* `
|
|
2938
|
-
*
|
|
2939
|
-
*
|
|
2940
|
-
*
|
|
2941
|
-
*
|
|
2942
|
-
*
|
|
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
|
|
2946
|
-
*
|
|
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,
|
|
2952
|
-
const
|
|
2953
|
-
const
|
|
2954
|
-
const
|
|
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
|
|
2966
|
-
*
|
|
2967
|
-
*
|
|
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(
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
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(
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
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(
|
|
3299
|
+
function redactProduct(inputs) {
|
|
3300
|
+
const { key, entry, override } = inputs;
|
|
3029
3301
|
return {
|
|
3030
3302
|
...entry,
|
|
3031
|
-
name: override.name ??
|
|
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(
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
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
|
|
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,
|
|
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
|
|
3396
|
-
* with
|
|
3397
|
-
*
|
|
3398
|
-
*
|
|
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
|
|
3402
|
-
* - `update` operations
|
|
3403
|
-
*
|
|
3404
|
-
* `
|
|
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
|
-
*
|
|
3408
|
-
*
|
|
3409
|
-
*
|
|
3410
|
-
*
|
|
3411
|
-
*
|
|
3412
|
-
*
|
|
3413
|
-
*
|
|
3414
|
-
*
|
|
3415
|
-
*
|
|
3416
|
-
*
|
|
3417
|
-
*
|
|
3418
|
-
*
|
|
3419
|
-
*
|
|
3420
|
-
*
|
|
3421
|
-
*
|
|
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
|
|
3435
|
-
*
|
|
3436
|
-
*
|
|
3437
|
-
*
|
|
3438
|
-
*
|
|
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
|
-
*
|
|
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
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
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
|
|
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.
|
|
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
|
|
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 `
|
|
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["
|
|
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 {
|
|
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-
|
|
6968
|
+
//# sourceMappingURL=migrate-mantle-state-qejWFAR0.mjs.map
|