@protoyard/blueprint 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +1149 -2
- package/dist/aggregate.d.ts +83 -0
- package/dist/aggregate.js +268 -0
- package/dist/app-model.d.ts +76 -0
- package/dist/app-model.js +287 -0
- package/dist/archetypes/index.d.ts +1 -0
- package/dist/archetypes/index.js +1 -0
- package/dist/archetypes/schema.d.ts +126 -0
- package/dist/archetypes/schema.js +77 -0
- package/dist/audit.d.ts +52 -0
- package/dist/audit.js +149 -0
- package/dist/authoring/deck-lint.d.ts +32 -0
- package/dist/authoring/deck-lint.js +567 -0
- package/dist/authoring/index.d.ts +1 -0
- package/dist/authoring/index.js +1 -0
- package/dist/authoring/select-deck-layouts.d.ts +50 -0
- package/dist/authoring/select-deck-layouts.js +213 -0
- package/dist/authoring-apis.d.ts +13 -0
- package/dist/authoring-apis.js +13 -0
- package/dist/authoring-validation.d.ts +65 -0
- package/dist/authoring-validation.js +267 -0
- package/dist/avatar.d.ts +25 -0
- package/dist/avatar.js +30 -0
- package/dist/brand.d.ts +70 -0
- package/dist/brand.js +294 -0
- package/dist/brief/archetypes.d.ts +28 -0
- package/dist/brief/archetypes.js +798 -0
- package/dist/brief/content-synthesize.d.ts +31 -0
- package/dist/brief/content-synthesize.js +210 -0
- package/dist/brief/deck-outlines.d.ts +96 -0
- package/dist/brief/deck-outlines.js +561 -0
- package/dist/brief/draft-bodies.d.ts +29 -0
- package/dist/brief/draft-bodies.js +224 -0
- package/dist/brief/fields.d.ts +19 -0
- package/dist/brief/fields.js +229 -0
- package/dist/brief/index.d.ts +8 -0
- package/dist/brief/index.js +8 -0
- package/dist/brief/kind-detection.d.ts +69 -0
- package/dist/brief/kind-detection.js +353 -0
- package/dist/brief/limits.d.ts +117 -0
- package/dist/brief/limits.js +217 -0
- package/dist/brief/page-doc-outlines.d.ts +80 -0
- package/dist/brief/page-doc-outlines.js +485 -0
- package/dist/brief/synthesize.d.ts +59 -0
- package/dist/brief/synthesize.js +511 -0
- package/dist/brief/text.d.ts +13 -0
- package/dist/brief/text.js +59 -0
- package/dist/canvas-screen.d.ts +31 -0
- package/dist/canvas-screen.js +33 -0
- package/dist/canvas-seed.d.ts +58 -0
- package/dist/canvas-seed.js +82 -0
- package/dist/computed/evaluate.d.ts +11 -0
- package/dist/computed/evaluate.js +101 -0
- package/dist/computed/index.d.ts +4 -0
- package/dist/computed/index.js +3 -0
- package/dist/computed/public.d.ts +14 -0
- package/dist/computed/public.js +19 -0
- package/dist/computed/schema.d.ts +10 -0
- package/dist/computed/schema.js +140 -0
- package/dist/computed/types.d.ts +70 -0
- package/dist/computed/types.js +1 -0
- package/dist/computed/validate.d.ts +6 -0
- package/dist/computed/validate.js +146 -0
- package/dist/csv-guard.d.ts +15 -0
- package/dist/csv-guard.js +29 -0
- package/dist/csv.d.ts +85 -0
- package/dist/csv.js +288 -0
- package/dist/files.d.ts +35 -0
- package/dist/files.js +246 -0
- package/dist/form-schema.d.ts +124 -0
- package/dist/form-schema.js +300 -0
- package/dist/generator-version.d.ts +11 -0
- package/dist/generator-version.js +51 -0
- package/dist/generator.d.ts +34 -0
- package/dist/generator.js +445 -0
- package/dist/identity.d.ts +34 -0
- package/dist/identity.js +91 -0
- package/dist/import/csv-shape.d.ts +40 -0
- package/dist/import/csv-shape.js +265 -0
- package/dist/import/json-shape.d.ts +55 -0
- package/dist/import/json-shape.js +491 -0
- package/dist/import/limits.d.ts +13 -0
- package/dist/import/limits.js +14 -0
- package/dist/import/sqlite-schema.d.ts +36 -0
- package/dist/import/sqlite-schema.js +729 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +58 -0
- package/dist/kinds/anchors.d.ts +18 -0
- package/dist/kinds/anchors.js +46 -0
- package/dist/kinds/bodies.d.ts +6 -0
- package/dist/kinds/bodies.js +32 -0
- package/dist/kinds/brand-runtime.d.ts +19 -0
- package/dist/kinds/brand-runtime.js +35 -0
- package/dist/kinds/deck.d.ts +8 -0
- package/dist/kinds/deck.js +34 -0
- package/dist/kinds/doc.d.ts +19 -0
- package/dist/kinds/doc.js +41 -0
- package/dist/kinds/embed/index.d.ts +39 -0
- package/dist/kinds/embed/index.js +91 -0
- package/dist/kinds/envelope.d.ts +159 -0
- package/dist/kinds/envelope.js +78 -0
- package/dist/kinds/index.d.ts +11 -0
- package/dist/kinds/index.js +11 -0
- package/dist/kinds/kind.d.ts +36 -0
- package/dist/kinds/kind.js +55 -0
- package/dist/kinds/limits.d.ts +64 -0
- package/dist/kinds/limits.js +190 -0
- package/dist/kinds/page-doc-runtime.d.ts +37 -0
- package/dist/kinds/page-doc-runtime.js +55 -0
- package/dist/kinds/page.d.ts +16 -0
- package/dist/kinds/page.js +67 -0
- package/dist/kinds/parse.d.ts +18 -0
- package/dist/kinds/parse.js +27 -0
- package/dist/kinds/runtime.d.ts +128 -0
- package/dist/kinds/runtime.js +206 -0
- package/dist/kinds/services.d.ts +49 -0
- package/dist/kinds/services.js +249 -0
- package/dist/kinds/testing.d.ts +9 -0
- package/dist/kinds/testing.js +16 -0
- package/dist/migrations.d.ts +97 -0
- package/dist/migrations.js +351 -0
- package/dist/motion.d.ts +50 -0
- package/dist/motion.js +52 -0
- package/dist/notifications.d.ts +19 -0
- package/dist/notifications.js +177 -0
- package/dist/patch.d.ts +122 -0
- package/dist/patch.js +322 -0
- package/dist/policy.d.ts +23 -0
- package/dist/policy.js +105 -0
- package/dist/runtime-definition.d.ts +39 -0
- package/dist/runtime-definition.js +74 -0
- package/dist/scenarios.d.ts +9 -0
- package/dist/scenarios.js +49 -0
- package/dist/schema/blueprint.schema.json +10191 -0
- package/dist/schema-json.d.ts +6 -0
- package/dist/schema-json.js +36 -0
- package/dist/schema.d.ts +6348 -0
- package/dist/schema.js +1269 -0
- package/dist/search.d.ts +16 -0
- package/dist/search.js +96 -0
- package/dist/services.d.ts +89 -0
- package/dist/services.js +898 -0
- package/dist/share.d.ts +78 -0
- package/dist/share.js +234 -0
- package/dist/sheet-screen.d.ts +49 -0
- package/dist/sheet-screen.js +91 -0
- package/dist/storage.d.ts +43 -0
- package/dist/storage.js +179 -0
- package/dist/stream.d.ts +26 -0
- package/dist/stream.js +173 -0
- package/dist/untrusted.d.ts +63 -0
- package/dist/untrusted.js +502 -0
- package/dist/validation-tasks.d.ts +178 -0
- package/dist/validation-tasks.js +199 -0
- package/dist/validation.d.ts +35 -0
- package/dist/validation.js +312 -0
- package/dist/variants.d.ts +112 -0
- package/dist/variants.js +283 -0
- package/dist/workflows/index.d.ts +3 -0
- package/dist/workflows/index.js +3 -0
- package/dist/workflows/schema.d.ts +50 -0
- package/dist/workflows/schema.js +34 -0
- package/dist/workflows/transition.d.ts +15 -0
- package/dist/workflows/transition.js +25 -0
- package/dist/workflows/validate.d.ts +17 -0
- package/dist/workflows/validate.js +140 -0
- package/package.json +120 -3
package/dist/variants.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { createRng } from '@protoyard/core';
|
|
2
|
+
import { entityLabel } from "./app-model.js";
|
|
3
|
+
import { canonicalJson } from "./identity.js";
|
|
4
|
+
import { parseBlueprint } from "./schema.js";
|
|
5
|
+
/**
|
|
6
|
+
* Deterministic blueprint variants for `protoyard quick --variants` (ADR-031).
|
|
7
|
+
* Each variant changes the shell, not the domain: its layout, theme, spacing
|
|
8
|
+
* density and which entity comes first. Entities, fields, roles and data stay
|
|
9
|
+
* the same, so variants of one idea compare like for like.
|
|
10
|
+
*
|
|
11
|
+
* The theme and layout catalogs come from the caller (the CLI reads them from
|
|
12
|
+
* @protoyard/themes and @protoyard/core), so this module needs no theme code.
|
|
13
|
+
* Randomness is a core seeded RNG over the caller's seed plus the blueprint's
|
|
14
|
+
* canonical JSON: the same input always yields byte-identical variants.
|
|
15
|
+
*/
|
|
16
|
+
/** Built-in theme presets a brand can name (`brand.preset`); other theme ids are library themes. */
|
|
17
|
+
export const VARIANT_THEME_PRESETS = [
|
|
18
|
+
'violet',
|
|
19
|
+
'ocean',
|
|
20
|
+
'emerald',
|
|
21
|
+
'rose',
|
|
22
|
+
'amber',
|
|
23
|
+
'monochrome',
|
|
24
|
+
];
|
|
25
|
+
export const VARIANT_DENSITIES = ['compact', 'comfortable', 'spacious'];
|
|
26
|
+
/** `brand.density` multipliers (the schema allows 0.75–1.25). */
|
|
27
|
+
export const VARIANT_DENSITY_SCALE = {
|
|
28
|
+
compact: 0.85,
|
|
29
|
+
comfortable: 1,
|
|
30
|
+
spacious: 1.15,
|
|
31
|
+
};
|
|
32
|
+
export const VARIANT_MAX_COUNT = 8;
|
|
33
|
+
const PRESETS = new Set(VARIANT_THEME_PRESETS);
|
|
34
|
+
function titleCase(id) {
|
|
35
|
+
return id
|
|
36
|
+
.split('-')
|
|
37
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
38
|
+
.join(' ');
|
|
39
|
+
}
|
|
40
|
+
function unique(values) {
|
|
41
|
+
return [...new Set(values)];
|
|
42
|
+
}
|
|
43
|
+
/** Whether a brand built from `theme` (or the current brand) can carry `brand.density`. */
|
|
44
|
+
function densityApplies(blueprint, theme) {
|
|
45
|
+
if (theme !== null)
|
|
46
|
+
return PRESETS.has(theme);
|
|
47
|
+
const brand = blueprint.brand;
|
|
48
|
+
return brand !== undefined && brand.libraryTheme === undefined;
|
|
49
|
+
}
|
|
50
|
+
function combos(blueprint, axes) {
|
|
51
|
+
const layouts = axes.layouts?.length ? unique(axes.layouts) : [null];
|
|
52
|
+
const themes = axes.themes?.length ? unique(axes.themes) : [null];
|
|
53
|
+
const densities = unique(axes.densities ?? VARIANT_DENSITIES);
|
|
54
|
+
const primaries = axes.emphasis === false || blueprint.entities.length < 2
|
|
55
|
+
? [null]
|
|
56
|
+
: blueprint.entities.map((entity) => entity.name);
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const layout of layouts) {
|
|
59
|
+
for (const theme of themes) {
|
|
60
|
+
const options = densities.length > 0 && densityApplies(blueprint, theme) ? densities : [null];
|
|
61
|
+
for (const density of options) {
|
|
62
|
+
for (const primary of primaries)
|
|
63
|
+
out.push({ layout, theme, density, primary });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
function themedBrand(blueprint, combo) {
|
|
70
|
+
const current = blueprint.brand;
|
|
71
|
+
if (combo.theme === null) {
|
|
72
|
+
if (combo.density === null || current === undefined)
|
|
73
|
+
return current;
|
|
74
|
+
const { density: _density, ...rest } = current;
|
|
75
|
+
return { ...rest, ...densityKey(combo.density) };
|
|
76
|
+
}
|
|
77
|
+
const name = current?.name;
|
|
78
|
+
if (PRESETS.has(combo.theme)) {
|
|
79
|
+
return {
|
|
80
|
+
...(name === undefined ? {} : { name }),
|
|
81
|
+
preset: combo.theme,
|
|
82
|
+
...(combo.density === null ? {} : densityKey(combo.density)),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { ...(name === undefined ? {} : { name }), libraryTheme: combo.theme };
|
|
86
|
+
}
|
|
87
|
+
/** `comfortable` is the default spacing, so it drops `brand.density` instead of writing 1. */
|
|
88
|
+
function densityKey(density) {
|
|
89
|
+
return density === 'comfortable' ? {} : { density: VARIANT_DENSITY_SCALE[density] };
|
|
90
|
+
}
|
|
91
|
+
function moveFirst(items, matches) {
|
|
92
|
+
return [...items.filter(matches), ...items.filter((item) => !matches(item))];
|
|
93
|
+
}
|
|
94
|
+
function apply(blueprint, combo) {
|
|
95
|
+
const next = structuredClone(blueprint);
|
|
96
|
+
if (combo.layout !== null) {
|
|
97
|
+
const mobile = next.layout?.mobile;
|
|
98
|
+
next.layout = { id: combo.layout, ...(mobile === undefined ? {} : { mobile }) };
|
|
99
|
+
}
|
|
100
|
+
const brand = themedBrand(next, combo);
|
|
101
|
+
if (brand === undefined)
|
|
102
|
+
delete next.brand;
|
|
103
|
+
else
|
|
104
|
+
next.brand = brand;
|
|
105
|
+
if (combo.primary !== null) {
|
|
106
|
+
const primary = combo.primary;
|
|
107
|
+
next.entities = moveFirst(next.entities, (entity) => entity.name === primary);
|
|
108
|
+
next.screens = moveFirst(next.screens, (screen) => 'entity' in screen && screen.entity === primary);
|
|
109
|
+
}
|
|
110
|
+
return parseBlueprint(next);
|
|
111
|
+
}
|
|
112
|
+
function label(blueprint, combo) {
|
|
113
|
+
const parts = [];
|
|
114
|
+
if (combo.layout !== null)
|
|
115
|
+
parts.push(`${titleCase(combo.layout)} layout`);
|
|
116
|
+
if (combo.theme !== null)
|
|
117
|
+
parts.push(`${titleCase(combo.theme)} theme`);
|
|
118
|
+
if (combo.density !== null)
|
|
119
|
+
parts.push(titleCase(combo.density));
|
|
120
|
+
if (combo.primary !== null) {
|
|
121
|
+
const entity = blueprint.entities.find((candidate) => candidate.name === combo.primary);
|
|
122
|
+
if (entity)
|
|
123
|
+
parts.push(`${entityLabel(entity)} first`);
|
|
124
|
+
}
|
|
125
|
+
return parts.length > 0 ? parts.join(' · ') : 'As written';
|
|
126
|
+
}
|
|
127
|
+
function changes(combo) {
|
|
128
|
+
return {
|
|
129
|
+
...(combo.layout === null ? {} : { layout: combo.layout }),
|
|
130
|
+
...(combo.theme === null ? {} : { theme: combo.theme }),
|
|
131
|
+
...(combo.density === null ? {} : { density: combo.density }),
|
|
132
|
+
...(combo.primary === null ? {} : { primaryEntity: combo.primary }),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Returns `count` distinct, valid variants of `blueprint`, none identical to
|
|
137
|
+
* the blueprint itself. Throws a RangeError when the axes cannot produce that
|
|
138
|
+
* many, and the schema's error when a catalog id is invalid (for example an
|
|
139
|
+
* unknown layout).
|
|
140
|
+
*/
|
|
141
|
+
export function deriveVariants(blueprint, options) {
|
|
142
|
+
const { count } = options;
|
|
143
|
+
if (!Number.isInteger(count) || count < 1 || count > VARIANT_MAX_COUNT) {
|
|
144
|
+
throw new RangeError(`count must be an integer from 1 to ${VARIANT_MAX_COUNT} (got ${count}).`);
|
|
145
|
+
}
|
|
146
|
+
const base = parseBlueprint(blueprint);
|
|
147
|
+
const baseJson = canonicalJson(base);
|
|
148
|
+
const rng = createRng(`protoyard-variants:${options.seed ?? ''}:${baseJson}`);
|
|
149
|
+
const seen = new Set([baseJson]);
|
|
150
|
+
const variants = [];
|
|
151
|
+
for (const combo of rng.shuffle(combos(base, options.axes ?? {}))) {
|
|
152
|
+
if (variants.length === count)
|
|
153
|
+
break;
|
|
154
|
+
const next = apply(base, combo);
|
|
155
|
+
const json = canonicalJson(next);
|
|
156
|
+
if (seen.has(json))
|
|
157
|
+
continue;
|
|
158
|
+
seen.add(json);
|
|
159
|
+
variants.push({
|
|
160
|
+
id: `v${variants.length + 1}`,
|
|
161
|
+
label: label(base, combo),
|
|
162
|
+
changes: changes(combo),
|
|
163
|
+
blueprint: next,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (variants.length < count) {
|
|
167
|
+
throw new RangeError(`Only ${variants.length} distinct variant(s) are possible with these axes; add layouts or themes, or lower the count.`);
|
|
168
|
+
}
|
|
169
|
+
return variants;
|
|
170
|
+
}
|
|
171
|
+
// ── Content kinds (decks, pages, docs) ───────────────────────────────────────
|
|
172
|
+
/** Deck transitions a variant may set (`deck.style.transition`). */
|
|
173
|
+
export const VARIANT_DECK_TRANSITIONS = ['none', 'fade', 'slide', 'zoom', 'morph'];
|
|
174
|
+
function kindCombos(kind, axes) {
|
|
175
|
+
const deck = kind === 'deck';
|
|
176
|
+
const themes = axes.themes?.length ? unique(axes.themes) : [null];
|
|
177
|
+
const transitions = deck
|
|
178
|
+
? unique(axes.transitions ?? VARIANT_DECK_TRANSITIONS)
|
|
179
|
+
: [null];
|
|
180
|
+
const densities = unique(axes.densities ?? VARIANT_DENSITIES);
|
|
181
|
+
const fontPairs = deck && axes.fontPairs?.length ? unique(axes.fontPairs) : [null];
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const theme of themes) {
|
|
184
|
+
// A page or doc brand carries a density only with a preset (a library theme has its own).
|
|
185
|
+
const options = densities.length === 0 || (!deck && theme !== null && !PRESETS.has(theme))
|
|
186
|
+
? [null]
|
|
187
|
+
: densities;
|
|
188
|
+
for (const transition of transitions.length > 0 ? transitions : [null]) {
|
|
189
|
+
for (const density of options) {
|
|
190
|
+
for (const fontPair of fontPairs)
|
|
191
|
+
out.push({ theme, transition, density, fontPair });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
function applyKind(blueprint, combo) {
|
|
198
|
+
const next = structuredClone(blueprint);
|
|
199
|
+
if (next.kind === 'deck') {
|
|
200
|
+
const style = { ...next.deck.style };
|
|
201
|
+
if (combo.theme !== null) {
|
|
202
|
+
// A theme and a palette seed are exclusive (as in Deck Studio).
|
|
203
|
+
delete style.paletteSeed;
|
|
204
|
+
style.theme = combo.theme;
|
|
205
|
+
}
|
|
206
|
+
if (combo.transition !== null)
|
|
207
|
+
style.transition = combo.transition;
|
|
208
|
+
if (combo.density !== null)
|
|
209
|
+
style.density = combo.density;
|
|
210
|
+
if (combo.fontPair !== null)
|
|
211
|
+
style.fontPair = combo.fontPair;
|
|
212
|
+
next.deck = { ...next.deck, style };
|
|
213
|
+
return next;
|
|
214
|
+
}
|
|
215
|
+
const brand = themedBrand(next, {
|
|
216
|
+
layout: null,
|
|
217
|
+
theme: combo.theme,
|
|
218
|
+
density: combo.density,
|
|
219
|
+
primary: null,
|
|
220
|
+
});
|
|
221
|
+
if (brand === undefined)
|
|
222
|
+
delete next.brand;
|
|
223
|
+
else
|
|
224
|
+
next.brand = brand;
|
|
225
|
+
return next;
|
|
226
|
+
}
|
|
227
|
+
function kindLabel(combo) {
|
|
228
|
+
const parts = [];
|
|
229
|
+
if (combo.theme !== null)
|
|
230
|
+
parts.push(`${titleCase(combo.theme)} theme`);
|
|
231
|
+
if (combo.transition !== null)
|
|
232
|
+
parts.push(`${titleCase(combo.transition)} transition`);
|
|
233
|
+
if (combo.density !== null)
|
|
234
|
+
parts.push(titleCase(combo.density));
|
|
235
|
+
if (combo.fontPair !== null)
|
|
236
|
+
parts.push(`${titleCase(combo.fontPair)} fonts`);
|
|
237
|
+
return parts.length > 0 ? parts.join(' · ') : 'As written';
|
|
238
|
+
}
|
|
239
|
+
function kindChanges(combo) {
|
|
240
|
+
return {
|
|
241
|
+
...(combo.theme === null ? {} : { theme: combo.theme }),
|
|
242
|
+
...(combo.transition === null ? {} : { transition: combo.transition }),
|
|
243
|
+
...(combo.density === null ? {} : { density: combo.density }),
|
|
244
|
+
...(combo.fontPair === null ? {} : { fontPair: combo.fontPair }),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* `count` distinct variants of a deck, page or doc, none identical to it:
|
|
249
|
+
* only the style axes change (see `KindVariantAxes`). Deterministic like
|
|
250
|
+
* `deriveVariants`: the same blueprint, seed and axes give byte-identical
|
|
251
|
+
* variants. The caller validates the result (`safeParseAnyBlueprint`) when
|
|
252
|
+
* an axis value may be unknown. Throws a RangeError when the axes cannot
|
|
253
|
+
* produce that many.
|
|
254
|
+
*/
|
|
255
|
+
export function deriveKindVariants(blueprint, options) {
|
|
256
|
+
const { count } = options;
|
|
257
|
+
if (!Number.isInteger(count) || count < 1 || count > VARIANT_MAX_COUNT) {
|
|
258
|
+
throw new RangeError(`count must be an integer from 1 to ${VARIANT_MAX_COUNT} (got ${count}).`);
|
|
259
|
+
}
|
|
260
|
+
const baseJson = canonicalJson(blueprint);
|
|
261
|
+
const rng = createRng(`protoyard-kind-variants:${options.seed ?? ''}:${baseJson}`);
|
|
262
|
+
const seen = new Set([baseJson]);
|
|
263
|
+
const variants = [];
|
|
264
|
+
for (const combo of rng.shuffle(kindCombos(blueprint.kind, options.axes ?? {}))) {
|
|
265
|
+
if (variants.length === count)
|
|
266
|
+
break;
|
|
267
|
+
const next = applyKind(blueprint, combo);
|
|
268
|
+
const json = canonicalJson(next);
|
|
269
|
+
if (seen.has(json))
|
|
270
|
+
continue;
|
|
271
|
+
seen.add(json);
|
|
272
|
+
variants.push({
|
|
273
|
+
id: `v${variants.length + 1}`,
|
|
274
|
+
label: kindLabel(combo),
|
|
275
|
+
changes: kindChanges(combo),
|
|
276
|
+
blueprint: next,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (variants.length < count) {
|
|
280
|
+
throw new RangeError(`Only ${variants.length} distinct variant(s) are possible with these axes; add themes or lower the count.`);
|
|
281
|
+
}
|
|
282
|
+
return variants;
|
|
283
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { type WorkflowAuthorization, WorkflowAuthorizationSchema, type WorkflowDefinition, type WorkflowDefinitionInput, WorkflowDefinitionSchema, type WorkflowState, WorkflowStateSchema, type WorkflowTransition, WorkflowTransitionSchema, } from './schema.ts';
|
|
2
|
+
export { checkWorkflowTransition, type WorkflowTransitionCheck, type WorkflowTransitionContext, } from './transition.ts';
|
|
3
|
+
export { validateWorkflowDefinition, type WorkflowDiagnostic, type WorkflowValidationResult, } from './validate.ts';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const WorkflowAuthorizationSchema: z.ZodObject<{
|
|
3
|
+
roles: z.ZodArray<z.ZodString>;
|
|
4
|
+
capability: z.ZodString;
|
|
5
|
+
}, z.core.$strict>;
|
|
6
|
+
export declare const WorkflowStateSchema: z.ZodObject<{
|
|
7
|
+
id: z.ZodString;
|
|
8
|
+
label: z.ZodOptional<z.ZodString>;
|
|
9
|
+
}, z.core.$strict>;
|
|
10
|
+
export declare const WorkflowTransitionSchema: z.ZodObject<{
|
|
11
|
+
from: z.ZodString;
|
|
12
|
+
to: z.ZodString;
|
|
13
|
+
label: z.ZodOptional<z.ZodString>;
|
|
14
|
+
guards: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
15
|
+
field: z.ZodString;
|
|
16
|
+
equals: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
|
|
17
|
+
}, z.core.$strict>>>;
|
|
18
|
+
authorization: z.ZodOptional<z.ZodObject<{
|
|
19
|
+
roles: z.ZodArray<z.ZodString>;
|
|
20
|
+
capability: z.ZodString;
|
|
21
|
+
}, z.core.$strict>>;
|
|
22
|
+
}, z.core.$strict>;
|
|
23
|
+
/** A data-only state machine bound to one enum field on one blueprint entity. */
|
|
24
|
+
export declare const WorkflowDefinitionSchema: z.ZodObject<{
|
|
25
|
+
entity: z.ZodString;
|
|
26
|
+
field: z.ZodString;
|
|
27
|
+
initialState: z.ZodString;
|
|
28
|
+
states: z.ZodArray<z.ZodObject<{
|
|
29
|
+
id: z.ZodString;
|
|
30
|
+
label: z.ZodOptional<z.ZodString>;
|
|
31
|
+
}, z.core.$strict>>;
|
|
32
|
+
transitions: z.ZodArray<z.ZodObject<{
|
|
33
|
+
from: z.ZodString;
|
|
34
|
+
to: z.ZodString;
|
|
35
|
+
label: z.ZodOptional<z.ZodString>;
|
|
36
|
+
guards: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
37
|
+
field: z.ZodString;
|
|
38
|
+
equals: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
|
|
39
|
+
}, z.core.$strict>>>;
|
|
40
|
+
authorization: z.ZodOptional<z.ZodObject<{
|
|
41
|
+
roles: z.ZodArray<z.ZodString>;
|
|
42
|
+
capability: z.ZodString;
|
|
43
|
+
}, z.core.$strict>>;
|
|
44
|
+
}, z.core.$strict>>;
|
|
45
|
+
}, z.core.$strict>;
|
|
46
|
+
export type WorkflowDefinition = z.infer<typeof WorkflowDefinitionSchema>;
|
|
47
|
+
export type WorkflowDefinitionInput = z.input<typeof WorkflowDefinitionSchema>;
|
|
48
|
+
export type WorkflowState = z.infer<typeof WorkflowStateSchema>;
|
|
49
|
+
export type WorkflowTransition = z.infer<typeof WorkflowTransitionSchema>;
|
|
50
|
+
export type WorkflowAuthorization = z.infer<typeof WorkflowAuthorizationSchema>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const identifier = z.string().min(1).max(80);
|
|
3
|
+
export const WorkflowAuthorizationSchema = z.strictObject({
|
|
4
|
+
/** Blueprint roles permitted to take this transition. */
|
|
5
|
+
roles: z.array(identifier).min(1),
|
|
6
|
+
/** Capability these roles must hold on the workflow entity. */
|
|
7
|
+
capability: z.string().min(1).max(120),
|
|
8
|
+
});
|
|
9
|
+
export const WorkflowStateSchema = z.strictObject({
|
|
10
|
+
/** Stable state id; when validated, this must be one of the enum values. */
|
|
11
|
+
id: identifier,
|
|
12
|
+
label: z.string().min(1).max(60).optional(),
|
|
13
|
+
});
|
|
14
|
+
export const WorkflowTransitionSchema = z.strictObject({
|
|
15
|
+
from: identifier,
|
|
16
|
+
to: identifier,
|
|
17
|
+
label: z.string().min(1).max(60).optional(),
|
|
18
|
+
/** Simple field equality guards only; no predicates or executable expressions. */
|
|
19
|
+
guards: z
|
|
20
|
+
.array(z.strictObject({
|
|
21
|
+
field: identifier,
|
|
22
|
+
equals: z.union([z.string(), z.number().finite(), z.boolean(), z.null()]),
|
|
23
|
+
}))
|
|
24
|
+
.default([]),
|
|
25
|
+
authorization: WorkflowAuthorizationSchema.optional(),
|
|
26
|
+
});
|
|
27
|
+
/** A data-only state machine bound to one enum field on one blueprint entity. */
|
|
28
|
+
export const WorkflowDefinitionSchema = z.strictObject({
|
|
29
|
+
entity: identifier,
|
|
30
|
+
field: identifier,
|
|
31
|
+
initialState: identifier,
|
|
32
|
+
states: z.array(WorkflowStateSchema).min(1),
|
|
33
|
+
transitions: z.array(WorkflowTransitionSchema),
|
|
34
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { WorkflowDefinition } from './schema.ts';
|
|
2
|
+
export type WorkflowTransitionCheck = {
|
|
3
|
+
allowed: true;
|
|
4
|
+
transition: WorkflowDefinition['transitions'][number];
|
|
5
|
+
} | {
|
|
6
|
+
allowed: false;
|
|
7
|
+
reason: 'unknown-state' | 'transition-not-allowed' | 'field-guard-failed' | 'role-not-authorized' | 'capability-not-held';
|
|
8
|
+
};
|
|
9
|
+
export type WorkflowTransitionContext = {
|
|
10
|
+
role?: string;
|
|
11
|
+
capabilities?: readonly string[];
|
|
12
|
+
record?: Readonly<Record<string, unknown>>;
|
|
13
|
+
};
|
|
14
|
+
/** Pure deterministic check; callers still enforce real service policy before a write. */
|
|
15
|
+
export declare function checkWorkflowTransition(workflow: WorkflowDefinition, from: string, to: string, context?: WorkflowTransitionContext): WorkflowTransitionCheck;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Pure deterministic check; callers still enforce real service policy before a write. */
|
|
2
|
+
export function checkWorkflowTransition(workflow, from, to, context = {}) {
|
|
3
|
+
const states = new Set(workflow.states.map((state) => state.id));
|
|
4
|
+
if (!states.has(from) || !states.has(to))
|
|
5
|
+
return { allowed: false, reason: 'unknown-state' };
|
|
6
|
+
const transition = workflow.transitions.find((candidate) => candidate.from === from && candidate.to === to);
|
|
7
|
+
if (!transition)
|
|
8
|
+
return { allowed: false, reason: 'transition-not-allowed' };
|
|
9
|
+
if (transition.guards.some((guard) => !Object.hasOwn(context.record ?? {}, guard.field) ||
|
|
10
|
+
!Object.is(context.record?.[guard.field], guard.equals))) {
|
|
11
|
+
return { allowed: false, reason: 'field-guard-failed' };
|
|
12
|
+
}
|
|
13
|
+
const authorization = transition.authorization;
|
|
14
|
+
if (authorization) {
|
|
15
|
+
if (context.role === undefined || !authorization.roles.includes(context.role)) {
|
|
16
|
+
return { allowed: false, reason: 'role-not-authorized' };
|
|
17
|
+
}
|
|
18
|
+
const capabilities = context.capabilities ?? [];
|
|
19
|
+
if (capabilities.includes(`-${authorization.capability}`) ||
|
|
20
|
+
(!capabilities.includes('*') && !capabilities.includes(authorization.capability))) {
|
|
21
|
+
return { allowed: false, reason: 'capability-not-held' };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { allowed: true, transition };
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Blueprint } from '../schema.ts';
|
|
2
|
+
import { type WorkflowDefinition } from './schema.ts';
|
|
3
|
+
export type WorkflowDiagnostic = {
|
|
4
|
+
path: (string | number)[];
|
|
5
|
+
message: string;
|
|
6
|
+
};
|
|
7
|
+
export type WorkflowValidationResult = {
|
|
8
|
+
valid: true;
|
|
9
|
+
workflow: WorkflowDefinition;
|
|
10
|
+
diagnostics: [];
|
|
11
|
+
} | {
|
|
12
|
+
valid: false;
|
|
13
|
+
workflow: null;
|
|
14
|
+
diagnostics: WorkflowDiagnostic[];
|
|
15
|
+
};
|
|
16
|
+
/** Validate the workflow shape and its entity, enum states, role and capability references. */
|
|
17
|
+
export declare function validateWorkflowDefinition(input: unknown, blueprint: Blueprint): WorkflowValidationResult;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { WorkflowDefinitionSchema } from "./schema.js";
|
|
2
|
+
const ENTITY_ACTIONS = [
|
|
3
|
+
'read',
|
|
4
|
+
'create',
|
|
5
|
+
'update',
|
|
6
|
+
'update:own',
|
|
7
|
+
'delete',
|
|
8
|
+
'delete:own',
|
|
9
|
+
'import',
|
|
10
|
+
'export',
|
|
11
|
+
];
|
|
12
|
+
function hasCapability(blueprint, roleName, capability) {
|
|
13
|
+
const role = blueprint.roles.find((candidate) => candidate.name === roleName);
|
|
14
|
+
if (!role)
|
|
15
|
+
return false;
|
|
16
|
+
const separator = capability.indexOf(':');
|
|
17
|
+
const entity = capability.slice(0, separator);
|
|
18
|
+
const action = capability.slice(separator + 1);
|
|
19
|
+
if (!ENTITY_ACTIONS.includes(action))
|
|
20
|
+
return false;
|
|
21
|
+
let granted = false;
|
|
22
|
+
let denied = false;
|
|
23
|
+
for (const [target, permissions] of Object.entries(role.permissions)) {
|
|
24
|
+
if (target !== '*' && target !== entity)
|
|
25
|
+
continue;
|
|
26
|
+
for (const permission of permissions) {
|
|
27
|
+
if (permission === '*') {
|
|
28
|
+
granted = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (permission.startsWith('-')) {
|
|
32
|
+
const blocked = permission.slice(1);
|
|
33
|
+
if (blocked === '*' ||
|
|
34
|
+
blocked === capability ||
|
|
35
|
+
(!blocked.includes(':') && (action === blocked || action.startsWith(`${blocked}:`)))) {
|
|
36
|
+
denied = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else if (permission === action) {
|
|
40
|
+
granted = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return granted && !denied;
|
|
45
|
+
}
|
|
46
|
+
/** Validate the workflow shape and its entity, enum states, role and capability references. */
|
|
47
|
+
export function validateWorkflowDefinition(input, blueprint) {
|
|
48
|
+
const parsed = WorkflowDefinitionSchema.safeParse(input);
|
|
49
|
+
if (!parsed.success) {
|
|
50
|
+
return {
|
|
51
|
+
valid: false,
|
|
52
|
+
workflow: null,
|
|
53
|
+
diagnostics: parsed.error.issues.map((issue) => ({
|
|
54
|
+
path: issue.path.filter((part) => typeof part === 'string' || typeof part === 'number'),
|
|
55
|
+
message: issue.message,
|
|
56
|
+
})),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const workflow = parsed.data;
|
|
60
|
+
const diagnostics = [];
|
|
61
|
+
const add = (path, message) => diagnostics.push({ path, message });
|
|
62
|
+
const entityIndex = blueprint.entities.findIndex((entity) => entity.name === workflow.entity);
|
|
63
|
+
const entity = blueprint.entities[entityIndex];
|
|
64
|
+
if (!entity) {
|
|
65
|
+
add(['entity'], `Unknown workflow entity "${workflow.entity}".`);
|
|
66
|
+
}
|
|
67
|
+
const fieldIndex = entity?.fields.findIndex((field) => field.name === workflow.field) ?? -1;
|
|
68
|
+
const field = fieldIndex >= 0 ? entity?.fields[fieldIndex] : undefined;
|
|
69
|
+
if (entity && !field) {
|
|
70
|
+
add(['field'], `Unknown field "${workflow.field}" on entity "${workflow.entity}".`);
|
|
71
|
+
}
|
|
72
|
+
else if (field && field.kind !== 'enum') {
|
|
73
|
+
add(['field'], `Workflow field "${workflow.field}" must be an enum field.`);
|
|
74
|
+
}
|
|
75
|
+
const stateIds = new Set();
|
|
76
|
+
for (const [index, state] of workflow.states.entries()) {
|
|
77
|
+
if (stateIds.has(state.id))
|
|
78
|
+
add(['states', index, 'id'], `Duplicate workflow state "${state.id}".`);
|
|
79
|
+
stateIds.add(state.id);
|
|
80
|
+
if (field?.kind === 'enum' && !field.values.includes(state.id)) {
|
|
81
|
+
add(['states', index, 'id'], `State "${state.id}" is not a value of enum field "${workflow.field}".`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!stateIds.has(workflow.initialState)) {
|
|
85
|
+
add(['initialState'], `Initial state "${workflow.initialState}" is not declared.`);
|
|
86
|
+
}
|
|
87
|
+
const roleNames = new Set(blueprint.roles.map((role) => role.name));
|
|
88
|
+
const transitionPairs = new Set();
|
|
89
|
+
for (const [index, transition] of workflow.transitions.entries()) {
|
|
90
|
+
const path = ['transitions', index];
|
|
91
|
+
if (!stateIds.has(transition.from)) {
|
|
92
|
+
add([...path, 'from'], `Unknown source state "${transition.from}".`);
|
|
93
|
+
}
|
|
94
|
+
if (!stateIds.has(transition.to)) {
|
|
95
|
+
add([...path, 'to'], `Unknown destination state "${transition.to}".`);
|
|
96
|
+
}
|
|
97
|
+
if (transition.from === transition.to) {
|
|
98
|
+
add(path, 'A transition must move to a different state.');
|
|
99
|
+
}
|
|
100
|
+
const pair = `${transition.from}\u0000${transition.to}`;
|
|
101
|
+
if (transitionPairs.has(pair))
|
|
102
|
+
add(path, 'Duplicate transition for this state pair.');
|
|
103
|
+
transitionPairs.add(pair);
|
|
104
|
+
for (const [guardIndex, guard] of transition.guards.entries()) {
|
|
105
|
+
const guardField = entity?.fields.find((candidate) => candidate.name === guard.field);
|
|
106
|
+
const guardPath = [...path, 'guards', guardIndex, 'field'];
|
|
107
|
+
if (!guardField) {
|
|
108
|
+
add(guardPath, `Unknown guard field "${guard.field}" on entity "${workflow.entity}".`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
const compatible = (['text', 'longText', 'email', 'url', 'phone', 'date', 'datetime', 'enum'].includes(guardField.kind) &&
|
|
112
|
+
typeof guard.equals === 'string') ||
|
|
113
|
+
(['number', 'integer', 'money'].includes(guardField.kind) &&
|
|
114
|
+
typeof guard.equals === 'number') ||
|
|
115
|
+
(guardField.kind === 'boolean' && typeof guard.equals === 'boolean');
|
|
116
|
+
if (!compatible) {
|
|
117
|
+
add([...path, 'guards', guardIndex, 'equals'], `Guard value type does not match field "${guard.field}" (${guardField.kind}).`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const authorization = transition.authorization;
|
|
122
|
+
if (!authorization)
|
|
123
|
+
continue;
|
|
124
|
+
const capabilityPrefix = `${workflow.entity}:`;
|
|
125
|
+
if (!authorization.capability.startsWith(capabilityPrefix)) {
|
|
126
|
+
add([...path, 'authorization', 'capability'], `Capability must belong to workflow entity "${workflow.entity}".`);
|
|
127
|
+
}
|
|
128
|
+
for (const [roleIndex, roleName] of authorization.roles.entries()) {
|
|
129
|
+
if (!roleNames.has(roleName)) {
|
|
130
|
+
add([...path, 'authorization', 'roles', roleIndex], `Unknown role "${roleName}".`);
|
|
131
|
+
}
|
|
132
|
+
else if (!hasCapability(blueprint, roleName, authorization.capability)) {
|
|
133
|
+
add([...path, 'authorization', 'roles', roleIndex], `Role "${roleName}" does not grant capability "${authorization.capability}".`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return diagnostics.length > 0
|
|
138
|
+
? { valid: false, workflow: null, diagnostics }
|
|
139
|
+
: { valid: true, workflow, diagnostics: [] };
|
|
140
|
+
}
|