@byamoah/studio-state 0.0.0-stage → 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,371 @@
1
+ export const defineStudioDefaults = (value) => value;
2
+ export function getStudioValue(value, path) {
3
+ if (
4
+ !path ||
5
+ typeof path !== "string" ||
6
+ path
7
+ .split(".")
8
+ .some((part) => ["__proto__", "prototype", "constructor"].includes(part))
9
+ )
10
+ return undefined;
11
+ for (const part of path.split(".")) {
12
+ if (part.startsWith("@") && Array.isArray(value)) {
13
+ try {
14
+ value = value.find(
15
+ (item) => item?.id === decodeURIComponent(part.slice(1)),
16
+ );
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ continue;
21
+ }
22
+ if (!value || typeof value !== "object" || !Object.hasOwn(value, part))
23
+ return undefined;
24
+ value = value[part];
25
+ }
26
+ return value;
27
+ }
28
+ function validStudioId(id) {
29
+ if (typeof id !== "string" || !id.length || id.length > 200) return false;
30
+ try {
31
+ return encodeURIComponent(id).replaceAll(".", "%2E").length <= 300;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+ export function studioItemSegment(id) {
37
+ if (!validStudioId(id))
38
+ throw new Error(
39
+ "Studio items require a nonempty, well-formed string id (max 200 characters / 300 encoded characters).",
40
+ );
41
+ return "@" + encodeURIComponent(id).replaceAll(".", "%2E");
42
+ }
43
+ export function expandStudioPath(document, pattern) {
44
+ const parts = pattern.split("."), star = parts.indexOf("*");
45
+ if (star < 0) return [pattern];
46
+ const items = getStudioValue(document, parts.slice(0, star).join("."));
47
+ if (!Array.isArray(items)) return [];
48
+ return items.filter(item => validStudioId(item?.id)).flatMap(item =>
49
+ expandStudioPath(document, [...parts.slice(0, star), studioItemSegment(item.id), ...parts.slice(star + 1)].join(".")));
50
+ }
51
+ /** OR of type constraints; each constraint is an ancestor collection/type map. */
52
+ export function matchesStudioConditions(document, concretePath, conditions) {
53
+ if (!conditions?.length) return true;
54
+ const parts = concretePath.split(".");
55
+ return conditions.some(condition => Object.entries(condition).every(([pattern, type]) => {
56
+ const prefix = pattern.split(".");
57
+ const bound = prefix.map((part, index) => part === "*" ? parts[index] : part);
58
+ if (!parts[prefix.length]?.startsWith("@")) return false;
59
+ return getStudioValue(document, [...bound, parts[prefix.length]].join("."))?.type === type;
60
+ }));
61
+ }
62
+ export function studioCollectionPattern(path) {
63
+ return path.split(".").map(part => part.startsWith("@") ? "*" : part).join(".");
64
+ }
65
+ export function validateStudioState(value, contract) {
66
+ const errors = [];
67
+ const definitions = { ...(contract.collections ?? {}) };
68
+ // Preserve validation for callers supplying only wildcard fields.
69
+ for (const field of contract.fields) {
70
+ const parts = field.path.split(".");
71
+ for (let index = 0; index < parts.length; index++) if (parts[index] === "*") {
72
+ const prefix = parts.slice(0, index).join(".");
73
+ if (!contract.collections?.[prefix]) {
74
+ const clauses = field.conditions?.map(condition => Object.fromEntries(Object.entries(condition).filter(([ancestor]) => ancestor.split(".").length < index)));
75
+ const incoming = !clauses?.length || clauses.some(clause => !Object.keys(clause).length) ? undefined : clauses;
76
+ const previous = definitions[prefix];
77
+ definitions[prefix] = previous && (!previous.conditions || !incoming) ? {} : { conditions: [...(previous?.conditions ?? []), ...(incoming ?? [])] };
78
+ }
79
+ }
80
+ }
81
+ for (const [pattern, rule] of Object.entries(definitions)) {
82
+ for (const path of expandStudioPath(value, pattern)) {
83
+ if (!matchesStudioConditions(value, path, rule.conditions)) continue;
84
+ const items = getStudioValue(value, path);
85
+ if (!Array.isArray(items) || items.length < (rule.minItems ?? 0) || items.length > (rule.maxItems ?? 1000)) {
86
+ errors.push(`${path}: collection size is outside its allowed range.`); continue;
87
+ }
88
+ if (items.some(item => !item || !validStudioId(item.id)) || new Set(items.map(item => item.id)).size !== items.length)
89
+ errors.push(`${path}: requires at most 1000 items with unique nonempty string ids.`);
90
+ const allowed = rule.cases && new Set(rule.cases.filter(entry => matchesStudioConditions(value, path, entry.conditions)).flatMap(entry => entry.types));
91
+ if (rule.variants && items.some(item => !item || typeof item.type !== "string" || !Object.hasOwn(rule.variants, item.type) || (allowed && !allowed.has(item.type))))
92
+ errors.push(`${path}: contains an unsupported block type.`);
93
+ }
94
+ }
95
+ for (const pattern of contract.fields) for (const path of expandStudioPath(value, pattern.path)) {
96
+ if (!matchesStudioConditions(value, path, pattern.conditions)) continue;
97
+ const field = { ...pattern, path };
98
+ const v = getStudioValue(value, field.path);
99
+ if (v === undefined || v === null) {
100
+ if (!field.optional)
101
+ errors.push(`${field.path}: a bundled value is required.`);
102
+ continue;
103
+ }
104
+ let valid = true;
105
+ if (field.type === "color")
106
+ valid =
107
+ typeof v === "string" &&
108
+ /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(v);
109
+ else if (field.type === "number")
110
+ valid =
111
+ typeof v === "number" &&
112
+ Number.isFinite(v) &&
113
+ (field.min === undefined || v >= field.min) &&
114
+ (field.max === undefined || v <= field.max);
115
+ else if (field.type === "boolean") valid = typeof v === "boolean";
116
+ else if (field.type === "array") valid = Array.isArray(v);
117
+ else if (["text", "image"].includes(field.type))
118
+ valid =
119
+ typeof v === "string" &&
120
+ v.length <=
121
+ (field.maxLength ?? (field.type === "image" ? 1000000 : 600));
122
+ else if (field.type === "enum") valid = field.values?.includes(v) === true;
123
+ else valid = false;
124
+ if (!valid) errors.push(`${field.path}: invalid ${field.type} value.`);
125
+ }
126
+ return errors;
127
+ }
128
+
129
+ /** Duplicate serializable content, never component code or transient component state. */
130
+ export function duplicateStudioItem(document, path, id, options = {}) {
131
+ const list = getStudioValue(document, path);
132
+ if (!Array.isArray(list) || list.length >= (options.maxItems ?? 1000))
133
+ throw new Error("Collection cannot accept another item.");
134
+ const index = list.findIndex((item) => item?.id === id);
135
+ if (index < 0 || list.filter((item) => item?.id === id).length !== 1)
136
+ throw new Error("Select an existing unique item.");
137
+ if (!["before", "after"].includes(options.position ?? "after"))
138
+ throw new Error("Invalid insertion position.");
139
+ const duplicate = createStudioItem(list[index], list, options);
140
+ list.splice(
141
+ index + ((options.position ?? "after") === "after" ? 1 : 0),
142
+ 0,
143
+ duplicate,
144
+ );
145
+ return duplicate.id;
146
+ }
147
+
148
+ /** Create from a template with the same identity safeguards as duplication. */
149
+ export function createStudioItem(template, existing = [], options = {}) {
150
+ const createId = options.createId ?? (() => globalThis.crypto.randomUUID());
151
+ const reserved = new Set();
152
+ const reserve = (value) => {
153
+ if (!value || typeof value !== "object") return;
154
+ if (typeof value.id === "string") reserved.add(value.id);
155
+ for (const child of Object.values(value)) reserve(child);
156
+ };
157
+ reserve(existing);
158
+ reserve(template);
159
+ const fresh = () => {
160
+ for (let attempt = 0; attempt < 20; attempt++) {
161
+ const next = createId();
162
+ studioItemSegment(next);
163
+ if (!reserved.has(next)) {
164
+ reserved.add(next);
165
+ return next;
166
+ }
167
+ }
168
+ throw new Error("Could not generate a unique item id.");
169
+ };
170
+ const duplicate = JSON.parse(JSON.stringify(template));
171
+ duplicate.id = fresh();
172
+ const rekeyed = new WeakSet();
173
+ for (const pattern of [...(options.ownedCollections ?? [])].sort(
174
+ (a, b) => a.split(".").length - b.split(".").length,
175
+ )) {
176
+ if (
177
+ pattern
178
+ .split(".")
179
+ .some((part) =>
180
+ ["__proto__", "constructor", "prototype"].includes(part),
181
+ )
182
+ )
183
+ throw new Error("Invalid owned collection path.");
184
+ for (const ownedPath of expandStudioPath(duplicate, pattern)) {
185
+ const children = getStudioValue(duplicate, ownedPath);
186
+ if (!Array.isArray(children))
187
+ throw new Error("Owned collection must be an array.");
188
+ for (const child of children) {
189
+ if (!child || typeof child !== "object" || Array.isArray(child))
190
+ throw new Error("Owned items require object records.");
191
+ if (typeof child.id === "string") reserved.add(child.id);
192
+ }
193
+ for (const child of children) { child.id = fresh(); rekeyed.add(child); }
194
+ }
195
+ }
196
+ const rekeySlots = (owner) => {
197
+ for (const children of Object.values(owner?.slots ?? {})) {
198
+ if (!Array.isArray(children)) throw new Error("Slot contents must be arrays.");
199
+ for (const child of children) {
200
+ if (!child || typeof child !== "object" || Array.isArray(child)) throw new Error("Slot items require object records.");
201
+ if (!rekeyed.has(child)) { child.id = fresh(); rekeyed.add(child); }
202
+ rekeySlots(child);
203
+ }
204
+ }
205
+ };
206
+ rekeySlots(duplicate);
207
+ return duplicate;
208
+ }
209
+
210
+ /** Merge generated content over bundled values; arrays retain authored order and empty lists. */
211
+ export function mergeStudioDefaults(base, authored) {
212
+ if (!authored || typeof authored !== "object" || Array.isArray(authored))
213
+ return authored === undefined ? base : authored;
214
+ const result =
215
+ base && typeof base === "object" && !Array.isArray(base) ? { ...base } : {};
216
+ for (const [key, value] of Object.entries(authored)) {
217
+ if (["__proto__", "constructor", "prototype"].includes(key)) continue;
218
+ result[key] =
219
+ value && typeof value === "object" && !Array.isArray(value)
220
+ ? mergeStudioDefaults(result[key], value)
221
+ : value;
222
+ }
223
+ return result;
224
+ }
225
+
226
+ const studioRevealListeners = new Set();
227
+ export function subscribeStudioReveal(listener) {
228
+ studioRevealListeners.add(listener);
229
+ return () => studioRevealListeners.delete(listener);
230
+ }
231
+ export function requestStudioItemReveal(path, id) {
232
+ for (const listener of studioRevealListeners) listener({ path, id });
233
+ }
234
+
235
+ /** Reorder by stable identity. Moving past either end is a harmless no-op. */
236
+ export function moveStudioItem(document, path, id, offset) {
237
+ if (offset !== -1 && offset !== 1) throw new Error("Move one position at a time.");
238
+ const list = getStudioValue(document, path);
239
+ if (!Array.isArray(list)) throw new Error("Select an existing collection.");
240
+ const index = list.findIndex((item) => item?.id === id);
241
+ if (index < 0 || list.filter((item) => item?.id === id).length !== 1)
242
+ throw new Error("Select an existing unique item.");
243
+ const destination = index + offset;
244
+ if (destination < 0 || destination >= list.length) return false;
245
+ [list[index], list[destination]] = [list[destination], list[index]];
246
+ return true;
247
+ }
248
+
249
+ /** Remove only the selected content instance, respecting collection limits. */
250
+ export function removeStudioItem(document, path, id, options = {}) {
251
+ const list = getStudioValue(document, path);
252
+ if (!Array.isArray(list)) throw new Error("Select an existing collection.");
253
+ const index = list.findIndex((item) => item?.id === id);
254
+ if (index < 0 || list.filter((item) => item?.id === id).length !== 1)
255
+ throw new Error("Select an existing unique item.");
256
+ if (list.length <= (options.minItems ?? 0))
257
+ throw new Error("Collection requires its remaining items.");
258
+ list.splice(index, 1);
259
+ }
260
+
261
+ /** Insert or replace with a registered data template, never executable content. */
262
+ export function insertStudioBlock(document, path, type, definition, options = {}) {
263
+ if (!matchesStudioConditions(document,path,definition?.conditions) ||
264
+ (definition?.cases && !definition.cases.some(entry => entry.types.includes(type) && matchesStudioConditions(document,path,entry.conditions))))
265
+ throw new Error("This component is not supported by the slot owner.");
266
+ const variant = Object.hasOwn(definition?.variants ?? {}, type) && definition.variants[type];
267
+ if (!variant?.template || variant.template.type !== type)
268
+ throw new Error("Choose a registered block type.");
269
+ const list = getStudioValue(document, path);
270
+ if (!Array.isArray(list)) throw new Error("Select an existing collection.");
271
+ let index = list.length;
272
+ if (options.relativeTo !== undefined) {
273
+ index = list.findIndex(item => item?.id === options.relativeTo);
274
+ if (index < 0 || list.filter(item => item?.id === options.relativeTo).length !== 1)
275
+ throw new Error("Select an existing unique item.");
276
+ }
277
+ const replace = options.position === "replace";
278
+ if (replace && options.relativeTo === undefined) throw new Error("Select an item to replace.");
279
+ if (!replace && list.length >= (definition.maxItems ?? 1000)) throw new Error("Collection cannot accept another item.");
280
+ if (!["before", "after", "replace"].includes(options.position ?? "after")) throw new Error("Invalid insertion position.");
281
+ const item = createStudioItem(variant.template, list, { ...definition, createId: options.createId });
282
+ if (!replace && options.relativeTo !== undefined && (options.position ?? "after") === "after") index++;
283
+ list.splice(index, replace ? 1 : 0, item);
284
+ return item.id;
285
+ }
286
+
287
+ export function studioBindingAncestors(binding) {
288
+ const parts=binding.split(".");
289
+ try {
290
+ return parts.flatMap((part,index)=> {
291
+ if(!part.startsWith("@")) return [];
292
+ const id=decodeURIComponent(part.slice(1));
293
+ return validStudioId(id) ? [{path:parts.slice(0,index).join("."),id}] : [];
294
+ });
295
+ } catch { return []; }
296
+ }
297
+ /** Resolve a concrete slot to its inferred definition and owner-specific capabilities. */
298
+ export function studioCollectionDefinition(document, definitions, path) {
299
+ const rule=definitions?.[studioCollectionPattern(path)];
300
+ if(!rule?.cases || !rule.variants) return rule;
301
+ const types=new Set(rule.cases.filter(entry=>matchesStudioConditions(document,path,entry.conditions)).flatMap(entry=>entry.types));
302
+ return {...rule,variants:Object.fromEntries(Object.entries(rule.variants).filter(([type])=>types.has(type)))};
303
+ }
304
+
305
+ /** Capture data only; presets never contain component implementations. */
306
+ export function createStudioPreset(document, path, id, options) {
307
+ if (!options?.contract?.contractId || !/^[a-f0-9]{64}$/.test(options.contract.contractId) || !/^[a-z0-9][a-z0-9-]*$/.test(options.projectId))
308
+ throw Error("A project and compiled contract are required.");
309
+ const name = options.name?.trim();
310
+ if (!name || name.length > 100)
311
+ throw Error("Use a preset name between 1 and 100 characters.");
312
+ const list = getStudioValue(document, path);
313
+ if (!Array.isArray(list) || list.filter(item => item?.id === id).length !== 1)
314
+ throw Error("Select an existing unique item.");
315
+ const errors = validateStudioState(document, options.contract);
316
+ if (errors.length)
317
+ throw Error(errors[0]);
318
+ const preset = { protocolVersion: 1, id: globalThis.crypto.randomUUID(), projectId: options.projectId, contractId: options.contract.contractId, name, createdAt: new Date().toISOString(), sourcePath: studioCollectionPattern(path), item: JSON.parse(JSON.stringify(list.find(item => item.id === id))) };
319
+ if (JSON.stringify(preset).length > 1000000)
320
+ throw Error("Preset exceeds 1 MB.");
321
+ return preset;
322
+ }
323
+ /** Validate on a clone, then commit only the accepted item to its destination. */
324
+ export function insertStudioPreset(document, path, preset, options) {
325
+ if (preset?.protocolVersion !== 1 || preset.projectId !== options?.projectId || preset.contractId !== options?.contract?.contractId || !preset.contractId || !preset.item || typeof preset.item !== "object" || Array.isArray(preset.item))
326
+ throw Error("This preset belongs to a different project or app contract.");
327
+ const definition = studioCollectionDefinition(document, options.contract.collections, path);
328
+ if (!definition || !matchesStudioConditions(document, path, definition.conditions))
329
+ throw Error("Select a compatible collection.");
330
+ const next = JSON.parse(JSON.stringify(document));
331
+ let id;
332
+ if (definition.variants) {
333
+ const type = preset.item.type;
334
+ if (typeof type !== "string" || !Object.hasOwn(definition.variants, type))
335
+ throw Error("This component is not supported by this collection.");
336
+ id = insertStudioBlock(next, path, type, { ...definition, variants: { ...definition.variants, [type]: { ...definition.variants[type], template: preset.item } } }, options);
337
+ }
338
+ else {
339
+ if (preset.sourcePath !== studioCollectionPattern(path))
340
+ throw Error("This list uses a different item schema.");
341
+ const list = getStudioValue(next, path);
342
+ if (!Array.isArray(list))
343
+ throw Error("Select an existing collection.");
344
+ const position = options.position ?? "after";
345
+ if (!["before", "after", "replace"].includes(position))
346
+ throw Error("Invalid insertion position.");
347
+ let index = list.length;
348
+ if (options.relativeTo !== undefined) {
349
+ index = list.findIndex(item => item.id === options.relativeTo);
350
+ if (index < 0 || list.filter(item => item.id === options.relativeTo).length !== 1)
351
+ throw Error("Select an existing unique item.");
352
+ }
353
+ if (position === "replace" && options.relativeTo === undefined)
354
+ throw Error("Select an item to replace.");
355
+ if (position !== "replace" && list.length >= (definition.maxItems ?? 1000))
356
+ throw Error("Collection cannot accept another item.");
357
+ const item = createStudioItem(preset.item, list, { ...definition, createId: options.createId });
358
+ id = item.id;
359
+ if (options.relativeTo !== undefined && position === "after")
360
+ index++;
361
+ list.splice(index, position === "replace" ? 1 : 0, item);
362
+ }
363
+ const errors = validateStudioState(next, options.contract);
364
+ if (errors.length)
365
+ throw Error(errors[0]);
366
+ const target = getStudioValue(document, path), accepted = getStudioValue(next, path);
367
+ target.splice(0, target.length, ...accepted);
368
+ return id;
369
+ }
370
+
371
+ export {studioMigrationFieldKey,compareStudioContracts,previewStudioMigration} from './migration.js';
package/migration.js ADDED
@@ -0,0 +1,235 @@
1
+ import {
2
+ getStudioValue,
3
+ expandStudioPath,
4
+ matchesStudioConditions,
5
+ validateStudioState,
6
+ } from "./index.js";
7
+ const canonical = (value) =>
8
+ Array.isArray(value)
9
+ ? value.map(canonical)
10
+ : value && typeof value === "object"
11
+ ? Object.fromEntries(
12
+ Object.keys(value)
13
+ .sort()
14
+ .map((key) => [key, canonical(value[key])]),
15
+ )
16
+ : value;
17
+ const clauses = (value) =>
18
+ value
19
+ ?.map(canonical)
20
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
21
+ export function studioMigrationFieldKey(field) {
22
+ return JSON.stringify([field.path, clauses(field.conditions) ?? null]);
23
+ }
24
+ function fieldShape({ usages, sourceIds, status, ...field }) {
25
+ return canonical({
26
+ ...field,
27
+ ...(field.conditions ? { conditions: clauses(field.conditions) } : {}),
28
+ });
29
+ }
30
+ function collectionShape({ template, direction, variants, ...rule }) {
31
+ return canonical({ ...rule, types: Object.keys(variants ?? {}).sort() });
32
+ }
33
+ export function compareStudioContracts(previous, next) {
34
+ if (previous.contractId && previous.contractId === next.contractId) return [];
35
+ const changes = [];
36
+ for (const [scope, oldItems, newItems, key, shape] of [
37
+ [
38
+ "field",
39
+ previous.fields ?? [],
40
+ next.fields ?? [],
41
+ studioMigrationFieldKey,
42
+ fieldShape,
43
+ ],
44
+ [
45
+ "collection",
46
+ Object.entries(previous.collections ?? {}).map(([path, rule]) => ({
47
+ path,
48
+ ...rule,
49
+ })),
50
+ Object.entries(next.collections ?? {}).map(([path, rule]) => ({
51
+ path,
52
+ ...rule,
53
+ })),
54
+ (value) => value.path,
55
+ collectionShape,
56
+ ],
57
+ ]) {
58
+ const old = new Map(oldItems.map((value) => [key(value), value])),
59
+ current = new Map(newItems.map((value) => [key(value), value]));
60
+ for (const [id, before] of old) {
61
+ const after = current.get(id);
62
+ if (!after)
63
+ changes.push({
64
+ scope,
65
+ key: id,
66
+ path: before.path,
67
+ kind: "removed",
68
+ before,
69
+ });
70
+ else if (JSON.stringify(shape(before)) !== JSON.stringify(shape(after)))
71
+ changes.push({
72
+ scope,
73
+ key: id,
74
+ path: before.path,
75
+ kind: "changed",
76
+ before,
77
+ after,
78
+ });
79
+ }
80
+ for (const [id, after] of current)
81
+ if (!old.has(id))
82
+ changes.push({
83
+ scope,
84
+ key: id,
85
+ path: after.path,
86
+ kind: "added",
87
+ after,
88
+ });
89
+ }
90
+ return changes;
91
+ }
92
+ function safeParts(path) {
93
+ const parts = path.split(".");
94
+ if (
95
+ parts.some(
96
+ (p) =>
97
+ !p ||
98
+ ["__proto__", "prototype", "constructor"].includes(p) ||
99
+ !(p === "*" || p.startsWith("@") || /^[A-Za-z_$][\w$]*$/.test(p)),
100
+ )
101
+ )
102
+ throw Error("Unsafe migration path.");
103
+ return parts;
104
+ }
105
+ function write(document, path, value) {
106
+ const parts = safeParts(path);
107
+ let owner = document;
108
+ for (const part of parts.slice(0, -1)) {
109
+ if (part.startsWith("@")) {
110
+ if (!Array.isArray(owner))
111
+ throw Error("Destination collection does not exist.");
112
+ const id = decodeURIComponent(part.slice(1));
113
+ if (owner.filter((item) => item?.id === id).length !== 1)
114
+ throw Error("Destination item does not exist uniquely.");
115
+ owner = owner.find((item) => item?.id === id);
116
+ } else {
117
+ if (!owner || typeof owner !== "object" || Array.isArray(owner))
118
+ throw Error("Destination parent is incompatible.");
119
+ owner = owner[part] ??= {};
120
+ }
121
+ }
122
+ const key = parts.at(-1);
123
+ if (
124
+ key.startsWith("@") ||
125
+ key === "*" ||
126
+ !owner ||
127
+ typeof owner !== "object" ||
128
+ Array.isArray(owner)
129
+ )
130
+ throw Error("Select a field destination.");
131
+ owner[key] = structuredClone(value);
132
+ }
133
+ function convert(value, conversion) {
134
+ if (conversion === "copy") return structuredClone(value);
135
+ if (
136
+ conversion === "text" &&
137
+ ["string", "number", "boolean"].includes(typeof value)
138
+ )
139
+ return String(value);
140
+ if (
141
+ conversion === "number" &&
142
+ (typeof value === "number" ||
143
+ (typeof value === "string" && value.trim() !== ""))
144
+ ) {
145
+ const number = Number(value);
146
+ if (Number.isFinite(number)) return number;
147
+ }
148
+ if (
149
+ conversion === "boolean" &&
150
+ (value === true || value === false || value === "true" || value === "false")
151
+ )
152
+ return value === true || value === "true";
153
+ throw Error("The value cannot be converted using the selected conversion.");
154
+ }
155
+ /** Pure preview: original documents and archived content are never mutated. */
156
+ export function previewStudioMigration(
157
+ source,
158
+ document,
159
+ previous,
160
+ next,
161
+ mappings = [],
162
+ ) {
163
+ const candidate = structuredClone(document),
164
+ errors = [],
165
+ changes = [],
166
+ written = new Set();
167
+ const old = new Map(
168
+ previous.fields.map((field) => [studioMigrationFieldKey(field), field]),
169
+ ),
170
+ current = new Map(
171
+ next.fields.map((field) => [studioMigrationFieldKey(field), field]),
172
+ );
173
+ for (const mapping of mappings) {
174
+ try {
175
+ const from = old.get(mapping.from),
176
+ to = current.get(mapping.to);
177
+ if (!from || !to)
178
+ throw Error("Choose fields from the selected contracts.");
179
+ safeParts(from.path);
180
+ safeParts(to.path);
181
+ if (
182
+ (from.path.match(/\*/g) ?? []).length !==
183
+ (to.path.match(/\*/g) ?? []).length
184
+ )
185
+ throw Error(
186
+ "List nesting differs; an explicit structural migration is required.",
187
+ );
188
+ const conversion = mapping.conversion ?? "copy";
189
+ if (conversion === "copy" && from.type !== to.type)
190
+ throw Error("Different field types require an explicit conversion.");
191
+ if (
192
+ conversion !== "copy" &&
193
+ to.type !==
194
+ { text: "text", number: "number", boolean: "boolean" }[conversion]
195
+ )
196
+ throw Error("Conversion does not match the destination type.");
197
+ let matched = 0;
198
+ for (const path of expandStudioPath(source, from.path)) {
199
+ if (!matchesStudioConditions(source, path, from.conditions)) continue;
200
+ const value = getStudioValue(source, path);
201
+ if (value === undefined) continue;
202
+ const wildcards = path
203
+ .split(".")
204
+ .filter((_, index) => from.path.split(".")[index] === "*");
205
+ let i = 0;
206
+ const target = to.path
207
+ .split(".")
208
+ .map((part) => (part === "*" ? wildcards[i++] : part))
209
+ .join(".");
210
+ if (!matchesStudioConditions(candidate, target, to.conditions))
211
+ continue;
212
+ if (written.has(target))
213
+ throw Error("Multiple mappings write to the same destination.");
214
+ const after = convert(value, conversion),
215
+ before = getStudioValue(candidate, target);
216
+ write(candidate, target, after);
217
+ written.add(target);
218
+ matched++;
219
+ if (JSON.stringify(before) !== JSON.stringify(after))
220
+ changes.push({ from: path, to: target, before, after });
221
+ }
222
+ if (!matched)
223
+ throw Error("No matching source values or destination owners.");
224
+ } catch (error) {
225
+ errors.push(error.message);
226
+ }
227
+ }
228
+ errors.push(...validateStudioState(candidate, next));
229
+ return {
230
+ document: candidate,
231
+ changes,
232
+ errors: [...new Set(errors)],
233
+ valid: errors.length === 0,
234
+ };
235
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@byamoah/studio-state",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.0",
4
+ "type": "module",
5
+ "description": "Typed Studio state context and runtime contract validation",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./index.d.ts",
9
+ "default": "./index.js"
10
+ },
11
+ "./react": {
12
+ "types": "./react.d.ts",
13
+ "default": "./react.js"
14
+ },
15
+ "./delivery": {
16
+ "types": "./delivery.d.ts",
17
+ "default": "./delivery.js"
18
+ },
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "*.js",
23
+ "*.d.ts",
24
+ "README.md",
25
+ "LICENSE",
26
+ "CHANGELOG.md"
27
+ ],
28
+ "peerDependencies": {
29
+ "react": ">=18"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/KBismark/studio.git",
37
+ "directory": "packages/state"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/KBismark/studio/issues"
41
+ },
42
+ "license": "Apache-2.0"
43
+ }