@protoyard/blueprint 0.0.0-stage → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1145 -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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { hashString } from '@protoyard/core';
|
|
2
|
+
import { GENERATOR_SDK_VERSION } from '@protoyard/scenarios';
|
|
3
|
+
/**
|
|
4
|
+
* The world generator's version, apart from the generator itself: the
|
|
5
|
+
* runtime definition needs it synchronously, while the generator
|
|
6
|
+
* (`@protoyard/blueprint/generator`) loads lazily when a scenario seeds.
|
|
7
|
+
*/
|
|
8
|
+
/** Bump when the same blueprint + seed can produce a different world shape. */
|
|
9
|
+
export const BLUEPRINT_GENERATOR_SDK = 6;
|
|
10
|
+
/** Stable hash of a blueprint's generative surface (entities, fields, features). */
|
|
11
|
+
export function hashBlueprint(blueprint) {
|
|
12
|
+
// Json fields a canvas screen stores boards in seed a canvas instead of the generic value.
|
|
13
|
+
const canvasFields = new Set(blueprint.screens.flatMap((screen) => screen.type === 'canvas' ? [`${screen.entity}.${screen.field}`] : []));
|
|
14
|
+
const payload = {
|
|
15
|
+
name: blueprint.name,
|
|
16
|
+
entities: blueprint.entities.map((entity) => ({
|
|
17
|
+
name: entity.name,
|
|
18
|
+
plural: entity.plural,
|
|
19
|
+
table: entity.table ?? null,
|
|
20
|
+
titleField: entity.titleField,
|
|
21
|
+
ownerField: entity.ownerField ?? null,
|
|
22
|
+
seedCount: entity.seedCount ?? null,
|
|
23
|
+
features: entity.features,
|
|
24
|
+
fields: entity.fields.map((field) => ({
|
|
25
|
+
name: field.name,
|
|
26
|
+
kind: field.kind,
|
|
27
|
+
required: field.required,
|
|
28
|
+
fake: 'fake' in field ? (field.fake ?? null) : null,
|
|
29
|
+
...('values' in field ? { values: field.values } : {}),
|
|
30
|
+
...('min' in field ? { min: field.min ?? null, max: field.max ?? null } : {}),
|
|
31
|
+
...('minLength' in field
|
|
32
|
+
? { minLength: field.minLength ?? null, maxLength: field.maxLength ?? null }
|
|
33
|
+
: {}),
|
|
34
|
+
...('cardinality' in field ? { to: field.to, cardinality: field.cardinality } : {}),
|
|
35
|
+
...(field.kind === 'geo' ? { region: field.region ?? null } : {}),
|
|
36
|
+
...('weights' in field ? { weights: field.weights ?? null } : {}),
|
|
37
|
+
...('window' in field ? { window: field.window ?? null } : {}),
|
|
38
|
+
...('trend' in field ? { trend: field.trend ?? null } : {}),
|
|
39
|
+
...('default' in field ? { default: field.default ?? null } : {}),
|
|
40
|
+
...(canvasFields.has(`${entity.name}.${field.name}`) ? { canvas: true } : {}),
|
|
41
|
+
})),
|
|
42
|
+
})),
|
|
43
|
+
};
|
|
44
|
+
return hashString(JSON.stringify(payload));
|
|
45
|
+
}
|
|
46
|
+
export function blueprintGeneratorVersion(blueprint) {
|
|
47
|
+
return ((hashBlueprint(blueprint) ^
|
|
48
|
+
((BLUEPRINT_GENERATOR_SDK * 1_000_003) >>> 0) ^
|
|
49
|
+
((GENERATOR_SDK_VERSION * 97) >>> 0)) >>>
|
|
50
|
+
0);
|
|
51
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { GenerateContext, WorldSeed } from '@protoyard/core';
|
|
2
|
+
import { type GeoRegionId } from '@protoyard/scenarios';
|
|
3
|
+
import type { Blueprint, BlueprintEntity } from './schema.ts';
|
|
4
|
+
/**
|
|
5
|
+
* Deterministic world generator for a blueprint. Import from
|
|
6
|
+
* `@protoyard/blueprint/generator` (lazy) so Faker never enters the initial chunk.
|
|
7
|
+
*
|
|
8
|
+
* Determinism only: ctx.rng, ctx.faker, ctx.ids, ctx.time (no ambient clocks or PRNGs).
|
|
9
|
+
*/
|
|
10
|
+
export { BLUEPRINT_GENERATOR_SDK, blueprintGeneratorVersion, hashBlueprint, } from './generator-version.ts';
|
|
11
|
+
/** Region for geo fields that do not name one. */
|
|
12
|
+
export declare const DEFAULT_GEO_REGION: GeoRegionId;
|
|
13
|
+
/** Faker paths blueprints may name in `field.fake` (everything else is ignored). */
|
|
14
|
+
export declare const ALLOWED_FAKE_PATHS: readonly ["company.name", "company.catchPhrase", "person.fullName", "person.firstName", "person.lastName", "person.jobTitle", "lorem.sentence", "lorem.paragraph", "lorem.words", "internet.email", "internet.url", "internet.username", "phone.number", "commerce.productName", "location.city", "location.country", "location.streetAddress", "commerce.department", "company.buzzPhrase", "word.words"];
|
|
15
|
+
export type AllowedFakePath = (typeof ALLOWED_FAKE_PATHS)[number];
|
|
16
|
+
export interface BlueprintGenerator {
|
|
17
|
+
/** Builds a WorldSeed for the scenario context. */
|
|
18
|
+
generate(ctx: GenerateContext): WorldSeed;
|
|
19
|
+
/**
|
|
20
|
+
* Trace id for ActiveScenarioInfo: mixes blueprint content hash with the
|
|
21
|
+
* blueprint generator SDK and the scenarios package SDK version.
|
|
22
|
+
*/
|
|
23
|
+
readonly generatorVersion: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Entity order so relation targets are generated before dependents.
|
|
27
|
+
* Cycles fall back to declaration order for the remaining nodes.
|
|
28
|
+
*/
|
|
29
|
+
export declare function topologicalEntities(blueprint: Blueprint): BlueprintEntity[];
|
|
30
|
+
/**
|
|
31
|
+
* Builds a generator bound to one blueprint. Prefer:
|
|
32
|
+
* `const { generate } = await import('@protoyard/blueprint/generator').then(m => m.createBlueprintGenerator(bp))`
|
|
33
|
+
*/
|
|
34
|
+
export declare function createBlueprintGenerator(blueprint: Blueprint): BlueprintGenerator;
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { createAvatarPool, generateTeam, geoPoints, timeSeries, } from '@protoyard/scenarios';
|
|
2
|
+
import { canvasFieldsByEntity, seedCanvas } from "./canvas-seed.js";
|
|
3
|
+
import { blueprintGeneratorVersion } from "./generator-version.js";
|
|
4
|
+
import { dateWindowDays, entityTable } from "./schema.js";
|
|
5
|
+
import { FEATURE_TABLES } from "./storage.js";
|
|
6
|
+
import { inputProperty } from "./validation.js";
|
|
7
|
+
/**
|
|
8
|
+
* Deterministic world generator for a blueprint. Import from
|
|
9
|
+
* `@protoyard/blueprint/generator` (lazy) so Faker never enters the initial chunk.
|
|
10
|
+
*
|
|
11
|
+
* Determinism only: ctx.rng, ctx.faker, ctx.ids, ctx.time (no ambient clocks or PRNGs).
|
|
12
|
+
*/
|
|
13
|
+
// The version hash lives in a small module so the runtime definition can read
|
|
14
|
+
// it without pulling this generator into the initial chunk.
|
|
15
|
+
export { BLUEPRINT_GENERATOR_SDK, blueprintGeneratorVersion, hashBlueprint, } from "./generator-version.js";
|
|
16
|
+
/** Region for geo fields that do not name one. */
|
|
17
|
+
export const DEFAULT_GEO_REGION = 'metro-grid';
|
|
18
|
+
/** Faker paths blueprints may name in `field.fake` (everything else is ignored). */
|
|
19
|
+
export const ALLOWED_FAKE_PATHS = [
|
|
20
|
+
'company.name',
|
|
21
|
+
'company.catchPhrase',
|
|
22
|
+
'person.fullName',
|
|
23
|
+
'person.firstName',
|
|
24
|
+
'person.lastName',
|
|
25
|
+
'person.jobTitle',
|
|
26
|
+
'lorem.sentence',
|
|
27
|
+
'lorem.paragraph',
|
|
28
|
+
'lorem.words',
|
|
29
|
+
'internet.email',
|
|
30
|
+
'internet.url',
|
|
31
|
+
'internet.username',
|
|
32
|
+
'phone.number',
|
|
33
|
+
'commerce.productName',
|
|
34
|
+
'location.city',
|
|
35
|
+
'location.country',
|
|
36
|
+
'location.streetAddress',
|
|
37
|
+
'commerce.department',
|
|
38
|
+
'company.buzzPhrase',
|
|
39
|
+
'word.words',
|
|
40
|
+
];
|
|
41
|
+
const ALLOWED = new Set(ALLOWED_FAKE_PATHS);
|
|
42
|
+
const EXAMPLE_DOMAINS = ['example.com', 'example.org'];
|
|
43
|
+
/**
|
|
44
|
+
* Entity order so relation targets are generated before dependents.
|
|
45
|
+
* Cycles fall back to declaration order for the remaining nodes.
|
|
46
|
+
*/
|
|
47
|
+
export function topologicalEntities(blueprint) {
|
|
48
|
+
const byName = new Map(blueprint.entities.map((e) => [e.name, e]));
|
|
49
|
+
const deps = new Map();
|
|
50
|
+
for (const entity of blueprint.entities) {
|
|
51
|
+
const need = new Set();
|
|
52
|
+
for (const field of entity.fields) {
|
|
53
|
+
if (field.kind === 'relation' && byName.has(field.to) && field.to !== entity.name) {
|
|
54
|
+
need.add(field.to);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
deps.set(entity.name, need);
|
|
58
|
+
}
|
|
59
|
+
const remaining = new Set(blueprint.entities.map((e) => e.name));
|
|
60
|
+
const ordered = [];
|
|
61
|
+
while (remaining.size > 0) {
|
|
62
|
+
const ready = [...remaining].filter((name) => [...(deps.get(name) ?? [])].every((d) => !remaining.has(d)));
|
|
63
|
+
const batch = ready.length > 0 ? ready : [[...remaining][0]];
|
|
64
|
+
// Stable: keep declaration order within each batch.
|
|
65
|
+
const sorted = blueprint.entities.map((e) => e.name).filter((name) => batch.includes(name));
|
|
66
|
+
for (const name of sorted) {
|
|
67
|
+
remaining.delete(name);
|
|
68
|
+
ordered.push(byName.get(name));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return ordered;
|
|
72
|
+
}
|
|
73
|
+
function baseCountFor(entity, blueprint) {
|
|
74
|
+
// Roots (nothing points at them less) get a smaller base; dependents a larger one.
|
|
75
|
+
const incoming = blueprint.entities.some((other) => other.fields.some((f) => f.kind === 'relation' && f.to === entity.name));
|
|
76
|
+
return incoming ? 8 : 4;
|
|
77
|
+
}
|
|
78
|
+
function callFake(faker, path) {
|
|
79
|
+
if (!ALLOWED.has(path))
|
|
80
|
+
return undefined;
|
|
81
|
+
const [mod, method] = path.split('.');
|
|
82
|
+
const namespace = faker[mod];
|
|
83
|
+
const fn = namespace?.[method];
|
|
84
|
+
if (typeof fn !== 'function')
|
|
85
|
+
return undefined;
|
|
86
|
+
return fn.call(namespace);
|
|
87
|
+
}
|
|
88
|
+
function emailFrom(faker, rng) {
|
|
89
|
+
const local = String(faker.internet.username())
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.replace(/[^a-z0-9._+-]/g, '')
|
|
92
|
+
.slice(0, 24) || 'user';
|
|
93
|
+
const domain = rng.pick([...EXAMPLE_DOMAINS]);
|
|
94
|
+
return `${local}@${domain}`;
|
|
95
|
+
}
|
|
96
|
+
function exampleUrl(faker, rng) {
|
|
97
|
+
return `https://${rng.pick([...EXAMPLE_DOMAINS])}/${faker.internet.username().toLowerCase()}`;
|
|
98
|
+
}
|
|
99
|
+
function examplePhone(rng) {
|
|
100
|
+
return `+1-555-${String(rng.int(100, 999))}-${String(rng.int(1000, 9999))}`;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Faker emails, URLs and phone numbers can name real domains and numbers, so a
|
|
104
|
+
* hint for one of them (on a text field) is replaced by the fictional form.
|
|
105
|
+
*/
|
|
106
|
+
function exampleOnly(path, hinted, faker, rng) {
|
|
107
|
+
if (path === 'internet.email')
|
|
108
|
+
return emailFrom(faker, rng);
|
|
109
|
+
if (path === 'internet.url')
|
|
110
|
+
return exampleUrl(faker, rng);
|
|
111
|
+
if (path === 'phone.number')
|
|
112
|
+
return examplePhone(rng);
|
|
113
|
+
return hinted;
|
|
114
|
+
}
|
|
115
|
+
const MS_PER_DAY = 86_400_000;
|
|
116
|
+
/**
|
|
117
|
+
* An instant inside a date field's window: a uniform day, then business hours
|
|
118
|
+
* (09:00–17:00 UTC, quarter hours) for datetimes and noon for dates. Never
|
|
119
|
+
* before the organization started; after now only when the window says so.
|
|
120
|
+
*/
|
|
121
|
+
function windowInstant(field, ctx, orgStart) {
|
|
122
|
+
const { from, to } = dateWindowDays(field.window);
|
|
123
|
+
const now = ctx.time.now.getTime();
|
|
124
|
+
const floor = orgStart.getTime();
|
|
125
|
+
const lo = from === null ? floor : Math.max(floor, now + from * MS_PER_DAY);
|
|
126
|
+
const hi = Math.max(lo, now + to * MS_PER_DAY);
|
|
127
|
+
const day = Math.floor((lo + ctx.rng.next() * (hi - lo)) / MS_PER_DAY) * MS_PER_DAY;
|
|
128
|
+
const minutes = field.kind === 'date'
|
|
129
|
+
? 12 * 60
|
|
130
|
+
: ctx.rng.int(9, 16) * 60 + ctx.rng.pick([0, 15, 30, 45]);
|
|
131
|
+
return Math.min(hi, Math.max(lo, day + minutes * 60_000));
|
|
132
|
+
}
|
|
133
|
+
const TREND_RATES = { flat: 0, growth: 0.2, decline: -0.12, seasonal: 0 };
|
|
134
|
+
/**
|
|
135
|
+
* Daily values for a trended field from the organization start to now, built
|
|
136
|
+
* with `timeSeries` (level at now = middle of the field's range). A row takes
|
|
137
|
+
* the value of its `createdAt` day, so charts over createdAt show the trend.
|
|
138
|
+
*/
|
|
139
|
+
function trendByDay(field, entity, ctx, orgCreatedAt) {
|
|
140
|
+
const lo = field.min ?? 0;
|
|
141
|
+
const hi = field.max ?? (field.kind === 'money' ? 100_000 : lo + 100);
|
|
142
|
+
const days = Math.max(1, Math.ceil((ctx.time.now.getTime() - Date.parse(orgCreatedAt)) / MS_PER_DAY) + 1);
|
|
143
|
+
const seasonality = field.trend === 'seasonal' ? 'weekly+annual' : 'none';
|
|
144
|
+
const points = timeSeries({
|
|
145
|
+
ctx,
|
|
146
|
+
key: `blueprint:${entity.name}.${field.name}`,
|
|
147
|
+
days,
|
|
148
|
+
base: (lo + hi) / 2,
|
|
149
|
+
trend: TREND_RATES[field.trend ?? 'flat'],
|
|
150
|
+
seasonality,
|
|
151
|
+
noise: 0.08,
|
|
152
|
+
min: lo,
|
|
153
|
+
max: hi,
|
|
154
|
+
integer: field.kind !== 'number',
|
|
155
|
+
});
|
|
156
|
+
return new Map(points.map((p) => [p.at.slice(0, 10), p.value]));
|
|
157
|
+
}
|
|
158
|
+
function valueForField(field, ctx, personas, tables, byName, orgCreatedAt) {
|
|
159
|
+
const { faker, rng, time } = ctx;
|
|
160
|
+
const orgStart = new Date(orgCreatedAt);
|
|
161
|
+
// Faces are dealt per entity field from a seeded pool (see createBlueprintGenerator).
|
|
162
|
+
if (field.kind === 'avatar')
|
|
163
|
+
return null;
|
|
164
|
+
if ('default' in field && field.default !== undefined && !field.required && rng.chance(0.15)) {
|
|
165
|
+
return field.default;
|
|
166
|
+
}
|
|
167
|
+
// URL and phone fields keep their example-only generators (example.com, 555 numbers).
|
|
168
|
+
if (field.fake && field.kind !== 'url' && field.kind !== 'phone') {
|
|
169
|
+
const hinted = callFake(faker, field.fake);
|
|
170
|
+
if (hinted !== undefined) {
|
|
171
|
+
if (field.kind === 'email')
|
|
172
|
+
return emailFrom(faker, rng);
|
|
173
|
+
if (typeof hinted === 'string') {
|
|
174
|
+
let text = exampleOnly(field.fake, hinted, faker, rng);
|
|
175
|
+
if ('maxLength' in field && typeof field.maxLength === 'number') {
|
|
176
|
+
text = text.slice(0, field.maxLength);
|
|
177
|
+
}
|
|
178
|
+
if ('minLength' in field &&
|
|
179
|
+
typeof field.minLength === 'number' &&
|
|
180
|
+
text.length < field.minLength) {
|
|
181
|
+
text = (text + ' example').slice(0, Math.max(field.minLength, text.length));
|
|
182
|
+
}
|
|
183
|
+
return text;
|
|
184
|
+
}
|
|
185
|
+
return hinted;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
switch (field.kind) {
|
|
189
|
+
case 'text': {
|
|
190
|
+
let text = String(faker.lorem.words(rng.int(2, 4)));
|
|
191
|
+
if (field.maxLength)
|
|
192
|
+
text = text.slice(0, field.maxLength);
|
|
193
|
+
if (field.minLength && text.length < field.minLength) {
|
|
194
|
+
text = `${text} ${'x'.repeat(field.minLength)}`.slice(0, field.minLength);
|
|
195
|
+
}
|
|
196
|
+
return text;
|
|
197
|
+
}
|
|
198
|
+
case 'longText':
|
|
199
|
+
return String(faker.lorem.paragraph()).slice(0, field.maxLength ?? 500);
|
|
200
|
+
case 'number': {
|
|
201
|
+
const min = field.min ?? 0;
|
|
202
|
+
const max = field.max ?? min + 100;
|
|
203
|
+
return rng.float(min, max === min ? min + 1 : max);
|
|
204
|
+
}
|
|
205
|
+
case 'integer': {
|
|
206
|
+
const min = field.min ?? 0;
|
|
207
|
+
const max = field.max ?? min + 100;
|
|
208
|
+
return rng.int(min, max);
|
|
209
|
+
}
|
|
210
|
+
case 'money': {
|
|
211
|
+
const min = field.min ?? 0;
|
|
212
|
+
const max = field.max ?? 100_000;
|
|
213
|
+
return rng.int(min, max);
|
|
214
|
+
}
|
|
215
|
+
case 'boolean':
|
|
216
|
+
return rng.chance(0.5);
|
|
217
|
+
case 'date':
|
|
218
|
+
case 'datetime': {
|
|
219
|
+
// Integrity compares parsed instants to org.createdAt. Date-only
|
|
220
|
+
// `YYYY-MM-DD` is UTC midnight and can fall before org.createdAt on the
|
|
221
|
+
// same calendar day, so both kinds store full ISO instants ≥ org start.
|
|
222
|
+
if (field.window === undefined) {
|
|
223
|
+
const d = time.between(orgStart, time.now, rng.next);
|
|
224
|
+
const ms = Math.max(d.getTime(), orgStart.getTime());
|
|
225
|
+
return new Date(ms).toISOString();
|
|
226
|
+
}
|
|
227
|
+
return new Date(windowInstant(field, ctx, orgStart)).toISOString();
|
|
228
|
+
}
|
|
229
|
+
case 'enum':
|
|
230
|
+
return field.weights
|
|
231
|
+
? rng.weighted(field.values.map((value, i) => [value, field.weights?.[i] ?? 0]))
|
|
232
|
+
: rng.pick(field.values);
|
|
233
|
+
case 'email':
|
|
234
|
+
return emailFrom(faker, rng);
|
|
235
|
+
case 'url':
|
|
236
|
+
return exampleUrl(faker, rng);
|
|
237
|
+
case 'phone':
|
|
238
|
+
return examplePhone(rng);
|
|
239
|
+
case 'tags': {
|
|
240
|
+
const n = rng.int(0, Math.min(3, field.maxItems ?? 8));
|
|
241
|
+
const tags = new Set();
|
|
242
|
+
while (tags.size < n)
|
|
243
|
+
tags.add(faker.lorem.word().toLowerCase());
|
|
244
|
+
return [...tags];
|
|
245
|
+
}
|
|
246
|
+
case 'relation': {
|
|
247
|
+
const target = byName.get(field.to);
|
|
248
|
+
if (!target)
|
|
249
|
+
return field.cardinality === 'many' ? [] : null;
|
|
250
|
+
const pool = tables[entityTable(target)] ?? [];
|
|
251
|
+
if (pool.length === 0)
|
|
252
|
+
return field.cardinality === 'many' ? [] : null;
|
|
253
|
+
if (field.cardinality === 'many') {
|
|
254
|
+
const count = rng.int(0, Math.min(3, pool.length));
|
|
255
|
+
return rng.sample(pool.map((r) => r.id), count);
|
|
256
|
+
}
|
|
257
|
+
if (field.required)
|
|
258
|
+
return rng.pick(pool).id;
|
|
259
|
+
return rng.chance(0.85) ? rng.pick(pool).id : null;
|
|
260
|
+
}
|
|
261
|
+
case 'persona':
|
|
262
|
+
if (personas.length === 0)
|
|
263
|
+
return null;
|
|
264
|
+
return field.required || rng.chance(0.9) ? rng.pick([...personas]).id : null;
|
|
265
|
+
case 'file':
|
|
266
|
+
return field.multiple ? [] : null;
|
|
267
|
+
case 'json':
|
|
268
|
+
return { note: String(faker.lorem.words(2)) };
|
|
269
|
+
default:
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function stampRow(ctx, orgCreatedAt) {
|
|
274
|
+
const start = new Date(orgCreatedAt);
|
|
275
|
+
const createdAt = ctx.time.between(start, ctx.time.now, ctx.rng.next).toISOString();
|
|
276
|
+
const createdMs = Date.parse(createdAt);
|
|
277
|
+
const updatedAt = createdMs >= ctx.time.now.getTime()
|
|
278
|
+
? createdAt
|
|
279
|
+
: ctx.time.between(new Date(createdMs), ctx.time.now, ctx.rng.next).toISOString();
|
|
280
|
+
return { createdAt, updatedAt };
|
|
281
|
+
}
|
|
282
|
+
function seedAuditHistory(ctx, blueprint, tables, personas) {
|
|
283
|
+
if (personas.length === 0)
|
|
284
|
+
return [];
|
|
285
|
+
const audited = blueprint.entities.filter((e) => e.features.audit);
|
|
286
|
+
if (audited.length === 0)
|
|
287
|
+
return [];
|
|
288
|
+
const rng = ctx.rng.fork('blueprint:audit-history');
|
|
289
|
+
const entries = [];
|
|
290
|
+
let seq = 0;
|
|
291
|
+
for (const entity of audited) {
|
|
292
|
+
const rows = tables[entityTable(entity)] ?? [];
|
|
293
|
+
const sample = rng.sample(rows, Math.min(rows.length, Math.max(1, Math.floor(rows.length / 2))));
|
|
294
|
+
for (const row of sample) {
|
|
295
|
+
const at = typeof row.createdAt === 'string' ? row.createdAt : ctx.time.now.toISOString();
|
|
296
|
+
entries.push({
|
|
297
|
+
id: ctx.ids.next('audit'),
|
|
298
|
+
entity: entity.name,
|
|
299
|
+
recordId: row.id,
|
|
300
|
+
actorId: rng.pick([...personas]).id,
|
|
301
|
+
action: 'create',
|
|
302
|
+
at,
|
|
303
|
+
seq: ++seq,
|
|
304
|
+
changes: {},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return entries;
|
|
309
|
+
}
|
|
310
|
+
function seedNotificationHistory(ctx, blueprint, tables, personas) {
|
|
311
|
+
if (personas.length === 0)
|
|
312
|
+
return [];
|
|
313
|
+
const wants = blueprint.notifications.length > 0 || blueprint.entities.some((e) => e.features.notifications);
|
|
314
|
+
if (!wants)
|
|
315
|
+
return [];
|
|
316
|
+
const rng = ctx.rng.fork('blueprint:notification-history');
|
|
317
|
+
const count = ctx.scale(3);
|
|
318
|
+
const rows = [];
|
|
319
|
+
for (let i = 0; i < count; i++) {
|
|
320
|
+
const persona = rng.pick([...personas]);
|
|
321
|
+
const entity = rng.pick(blueprint.entities);
|
|
322
|
+
const pool = tables[entityTable(entity)] ?? [];
|
|
323
|
+
const record = pool.length > 0 ? rng.pick(pool) : null;
|
|
324
|
+
const at = record && typeof record.createdAt === 'string'
|
|
325
|
+
? record.createdAt
|
|
326
|
+
: ctx.time.now.toISOString();
|
|
327
|
+
rows.push({
|
|
328
|
+
id: ctx.ids.next('notification'),
|
|
329
|
+
personaId: persona.id,
|
|
330
|
+
kind: `${entity.name}.create`,
|
|
331
|
+
title: `Update on ${entity.name}`,
|
|
332
|
+
body: '',
|
|
333
|
+
createdAt: at,
|
|
334
|
+
readAt: rng.chance(0.4) ? at : null,
|
|
335
|
+
recordId: record?.id ?? null,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return rows;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Builds a generator bound to one blueprint. Prefer:
|
|
342
|
+
* `const { generate } = await import('@protoyard/blueprint/generator').then(m => m.createBlueprintGenerator(bp))`
|
|
343
|
+
*/
|
|
344
|
+
export function createBlueprintGenerator(blueprint) {
|
|
345
|
+
const order = topologicalEntities(blueprint);
|
|
346
|
+
const byName = new Map(blueprint.entities.map((e) => [e.name, e]));
|
|
347
|
+
const generatorVersion = blueprintGeneratorVersion(blueprint);
|
|
348
|
+
const canvasFields = canvasFieldsByEntity(blueprint);
|
|
349
|
+
const generate = (ctx) => {
|
|
350
|
+
const team = generateTeam(ctx);
|
|
351
|
+
const tables = {};
|
|
352
|
+
const orgCreatedAt = team.organization.createdAt;
|
|
353
|
+
for (const entity of order) {
|
|
354
|
+
const table = entityTable(entity);
|
|
355
|
+
const count = ctx.scale(entity.seedCount ?? baseCountFor(entity, blueprint));
|
|
356
|
+
const rows = [];
|
|
357
|
+
const rng = ctx.rng.fork(`entity:${entity.name}`);
|
|
358
|
+
// One point set per geo field: point i belongs to row i, and more rows keep earlier points.
|
|
359
|
+
const geo = entity.fields.flatMap((field) => field.kind === 'geo'
|
|
360
|
+
? [
|
|
361
|
+
{
|
|
362
|
+
prop: inputProperty(field),
|
|
363
|
+
required: field.required,
|
|
364
|
+
points: geoPoints({
|
|
365
|
+
ctx,
|
|
366
|
+
region: field.region ?? DEFAULT_GEO_REGION,
|
|
367
|
+
count,
|
|
368
|
+
key: `${entity.name}.${field.name}`,
|
|
369
|
+
}),
|
|
370
|
+
},
|
|
371
|
+
]
|
|
372
|
+
: []);
|
|
373
|
+
// One seeded face per row and avatar field: no repeats until the library runs out, and
|
|
374
|
+
// the team's faces are dealt last so records rarely share a face with a persona.
|
|
375
|
+
const avatars = entity.fields.flatMap((field) => field.kind === 'avatar'
|
|
376
|
+
? [
|
|
377
|
+
{
|
|
378
|
+
prop: inputProperty(field),
|
|
379
|
+
ids: createAvatarPool(ctx.rng.fork(`blueprint:avatars:${entity.name}.${field.name}`), {
|
|
380
|
+
avoid: team.personas.map((persona) => persona.avatar),
|
|
381
|
+
}).take(count),
|
|
382
|
+
},
|
|
383
|
+
]
|
|
384
|
+
: []);
|
|
385
|
+
const trends = entity.fields.flatMap((field) => (field.kind === 'number' || field.kind === 'integer' || field.kind === 'money') &&
|
|
386
|
+
field.trend !== undefined
|
|
387
|
+
? [{ prop: inputProperty(field), byDay: trendByDay(field, entity, ctx, orgCreatedAt) }]
|
|
388
|
+
: []);
|
|
389
|
+
// A canvas screen's json field gets a seeded planning board, not the generic json value.
|
|
390
|
+
const canvases = entity.fields.flatMap((field) => canvasFields.get(entity.name)?.has(field.name) ? [inputProperty(field)] : []);
|
|
391
|
+
const titleProp = entity.fields.find((field) => field.name === entity.titleField);
|
|
392
|
+
for (let i = 0; i < count; i++) {
|
|
393
|
+
const fieldCtx = {
|
|
394
|
+
...ctx,
|
|
395
|
+
rng: rng.fork(`row:${i}`),
|
|
396
|
+
};
|
|
397
|
+
const values = {};
|
|
398
|
+
for (const field of entity.fields) {
|
|
399
|
+
values[inputProperty(field)] = valueForField(field, fieldCtx, team.personas, tables, byName, orgCreatedAt);
|
|
400
|
+
}
|
|
401
|
+
for (const { prop, required, points } of geo) {
|
|
402
|
+
const point = points[i];
|
|
403
|
+
values[prop] =
|
|
404
|
+
point && (required || fieldCtx.rng.chance(0.9))
|
|
405
|
+
? { lat: point.lat, lng: point.lng }
|
|
406
|
+
: null;
|
|
407
|
+
}
|
|
408
|
+
for (const { prop, ids } of avatars)
|
|
409
|
+
values[prop] = ids[i] ?? null;
|
|
410
|
+
for (const prop of canvases) {
|
|
411
|
+
const title = titleProp ? values[inputProperty(titleProp)] : undefined;
|
|
412
|
+
values[prop] = seedCanvas(ctx.rng.fork(`blueprint:canvas:${entity.name}.${prop}:${i}`), typeof title === 'string' ? title : '');
|
|
413
|
+
}
|
|
414
|
+
const stamps = stampRow(fieldCtx, orgCreatedAt);
|
|
415
|
+
for (const { prop, byDay } of trends) {
|
|
416
|
+
values[prop] = byDay.get(stamps.createdAt.slice(0, 10)) ?? values[prop];
|
|
417
|
+
}
|
|
418
|
+
rows.push({
|
|
419
|
+
id: ctx.ids.next(entity.name.replace(/-/g, '_')),
|
|
420
|
+
...values,
|
|
421
|
+
...stamps,
|
|
422
|
+
...(entity.features.softDelete ? { deletedAt: null, deletedBy: null } : {}),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
tables[table] = rows;
|
|
426
|
+
}
|
|
427
|
+
if (blueprint.entities.some((e) => e.features.audit)) {
|
|
428
|
+
tables[FEATURE_TABLES.audit] = seedAuditHistory(ctx, blueprint, tables, team.personas);
|
|
429
|
+
}
|
|
430
|
+
if (blueprint.notifications.length > 0 ||
|
|
431
|
+
blueprint.entities.some((e) => e.features.notifications)) {
|
|
432
|
+
tables[FEATURE_TABLES.notifications] = seedNotificationHistory(ctx, blueprint, tables, team.personas);
|
|
433
|
+
}
|
|
434
|
+
if (blueprint.entities.some((e) => e.features.files)) {
|
|
435
|
+
tables[FEATURE_TABLES.files] = [];
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
organizations: team.organizations,
|
|
439
|
+
personas: team.personas,
|
|
440
|
+
memberships: team.memberships,
|
|
441
|
+
tables,
|
|
442
|
+
};
|
|
443
|
+
};
|
|
444
|
+
return { generate, generatorVersion };
|
|
445
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AnyBlueprint } from './kinds/envelope.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Content identity for blueprints (ADR-030). A blueprint's identity is the
|
|
4
|
+
* SHA-256 of its canonical JSON, and its default storage namespace is derived
|
|
5
|
+
* from that digest, so every edit opens a fresh world and two different
|
|
6
|
+
* blueprints on one origin never share IndexedDB data.
|
|
7
|
+
*
|
|
8
|
+
* Uses Web Crypto (`crypto.subtle`), available in browsers and Node 20+.
|
|
9
|
+
* Never the 32-bit `hashString`: generator versions tolerate collisions,
|
|
10
|
+
* storage identity does not.
|
|
11
|
+
*/
|
|
12
|
+
export interface BlueprintIdentity {
|
|
13
|
+
/** Lowercase hex SHA-256 of `canonicalJson(blueprint)` (64 chars). */
|
|
14
|
+
readonly sha256: string;
|
|
15
|
+
/** `bp-` plus 26 base32 chars (130 bits of the digest): a valid manifest namespace. */
|
|
16
|
+
readonly namespace: string;
|
|
17
|
+
}
|
|
18
|
+
/** Prefix of every content-derived namespace. */
|
|
19
|
+
export declare const BLUEPRINT_NAMESPACE_PREFIX = "bp-";
|
|
20
|
+
/**
|
|
21
|
+
* JSON with object keys sorted (by UTF-16 code unit) at every depth and no
|
|
22
|
+
* whitespace. Array order is kept, `undefined` properties are dropped (as
|
|
23
|
+
* `JSON.stringify` does), and values JSON cannot represent (non-finite
|
|
24
|
+
* numbers, functions, symbols, bigints) throw instead of being silently changed.
|
|
25
|
+
*/
|
|
26
|
+
export declare function canonicalJson(value: unknown): string;
|
|
27
|
+
/**
|
|
28
|
+
* The content identity of a parsed blueprint of any kind. Parse first (`parseBlueprint`, `parseAnyBlueprint`)
|
|
29
|
+
* so defaults are applied: a blueprint that omits `tags` and one that sets
|
|
30
|
+
* `tags: []` are the same product and share an identity. Key order and
|
|
31
|
+
* whitespace never matter. `$schema` is an editor hint, not content, and is
|
|
32
|
+
* left out.
|
|
33
|
+
*/
|
|
34
|
+
export declare function blueprintIdentity(blueprint: AnyBlueprint): Promise<BlueprintIdentity>;
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Prefix of every content-derived namespace. */
|
|
2
|
+
export const BLUEPRINT_NAMESPACE_PREFIX = 'bp-';
|
|
3
|
+
/** Base32 characters in a content-derived namespace (130 bits). */
|
|
4
|
+
const NAMESPACE_CHARS = 26;
|
|
5
|
+
/** RFC 4648 base32, lowercased so the namespace stays lowercase kebab-case. */
|
|
6
|
+
const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567';
|
|
7
|
+
/**
|
|
8
|
+
* JSON with object keys sorted (by UTF-16 code unit) at every depth and no
|
|
9
|
+
* whitespace. Array order is kept, `undefined` properties are dropped (as
|
|
10
|
+
* `JSON.stringify` does), and values JSON cannot represent (non-finite
|
|
11
|
+
* numbers, functions, symbols, bigints) throw instead of being silently changed.
|
|
12
|
+
*/
|
|
13
|
+
export function canonicalJson(value) {
|
|
14
|
+
return render(value, new Set());
|
|
15
|
+
}
|
|
16
|
+
function render(value, seen) {
|
|
17
|
+
if (value === null)
|
|
18
|
+
return 'null';
|
|
19
|
+
switch (typeof value) {
|
|
20
|
+
case 'string':
|
|
21
|
+
case 'boolean':
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
case 'number':
|
|
24
|
+
if (!Number.isFinite(value))
|
|
25
|
+
throw new TypeError(`canonicalJson: ${value} is not JSON`);
|
|
26
|
+
return JSON.stringify(value);
|
|
27
|
+
case 'object':
|
|
28
|
+
break;
|
|
29
|
+
default:
|
|
30
|
+
throw new TypeError(`canonicalJson: a ${typeof value} is not JSON`);
|
|
31
|
+
}
|
|
32
|
+
const node = value;
|
|
33
|
+
if (seen.has(node))
|
|
34
|
+
throw new TypeError('canonicalJson: circular structure');
|
|
35
|
+
seen.add(node);
|
|
36
|
+
let out;
|
|
37
|
+
if (Array.isArray(node)) {
|
|
38
|
+
out = `[${node.map((item) => (item === undefined ? 'null' : render(item, seen))).join(',')}]`;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const record = node;
|
|
42
|
+
const keys = Object.keys(record)
|
|
43
|
+
.filter((key) => record[key] !== undefined)
|
|
44
|
+
.sort();
|
|
45
|
+
out = `{${keys.map((key) => `${JSON.stringify(key)}:${render(record[key], seen)}`).join(',')}}`;
|
|
46
|
+
}
|
|
47
|
+
seen.delete(node);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
function toHex(bytes) {
|
|
51
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
52
|
+
}
|
|
53
|
+
/** The first `length` base32 characters of `bytes`, most significant bits first. */
|
|
54
|
+
function toBase32(bytes, length) {
|
|
55
|
+
let out = '';
|
|
56
|
+
let buffer = 0;
|
|
57
|
+
let bits = 0;
|
|
58
|
+
for (const byte of bytes) {
|
|
59
|
+
buffer = ((buffer << 8) | byte) & 0xffff;
|
|
60
|
+
bits += 8;
|
|
61
|
+
while (bits >= 5 && out.length < length) {
|
|
62
|
+
out += BASE32[(buffer >> (bits - 5)) & 31];
|
|
63
|
+
bits -= 5;
|
|
64
|
+
}
|
|
65
|
+
if (out.length === length)
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
async function sha256(text) {
|
|
71
|
+
const subtle = globalThis.crypto?.subtle;
|
|
72
|
+
if (!subtle)
|
|
73
|
+
throw new Error('blueprintIdentity needs Web Crypto (crypto.subtle)');
|
|
74
|
+
const digest = await subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
75
|
+
return new Uint8Array(digest);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The content identity of a parsed blueprint of any kind. Parse first (`parseBlueprint`, `parseAnyBlueprint`)
|
|
79
|
+
* so defaults are applied: a blueprint that omits `tags` and one that sets
|
|
80
|
+
* `tags: []` are the same product and share an identity. Key order and
|
|
81
|
+
* whitespace never matter. `$schema` is an editor hint, not content, and is
|
|
82
|
+
* left out.
|
|
83
|
+
*/
|
|
84
|
+
export async function blueprintIdentity(blueprint) {
|
|
85
|
+
const { $schema: _schema, ...content } = blueprint;
|
|
86
|
+
const bytes = await sha256(canonicalJson(content));
|
|
87
|
+
return {
|
|
88
|
+
sha256: toHex(bytes),
|
|
89
|
+
namespace: `${BLUEPRINT_NAMESPACE_PREFIX}${toBase32(bytes, NAMESPACE_CHARS)}`,
|
|
90
|
+
};
|
|
91
|
+
}
|