@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.
Files changed (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1149 -2
  3. package/dist/aggregate.d.ts +83 -0
  4. package/dist/aggregate.js +268 -0
  5. package/dist/app-model.d.ts +76 -0
  6. package/dist/app-model.js +287 -0
  7. package/dist/archetypes/index.d.ts +1 -0
  8. package/dist/archetypes/index.js +1 -0
  9. package/dist/archetypes/schema.d.ts +126 -0
  10. package/dist/archetypes/schema.js +77 -0
  11. package/dist/audit.d.ts +52 -0
  12. package/dist/audit.js +149 -0
  13. package/dist/authoring/deck-lint.d.ts +32 -0
  14. package/dist/authoring/deck-lint.js +567 -0
  15. package/dist/authoring/index.d.ts +1 -0
  16. package/dist/authoring/index.js +1 -0
  17. package/dist/authoring/select-deck-layouts.d.ts +50 -0
  18. package/dist/authoring/select-deck-layouts.js +213 -0
  19. package/dist/authoring-apis.d.ts +13 -0
  20. package/dist/authoring-apis.js +13 -0
  21. package/dist/authoring-validation.d.ts +65 -0
  22. package/dist/authoring-validation.js +267 -0
  23. package/dist/avatar.d.ts +25 -0
  24. package/dist/avatar.js +30 -0
  25. package/dist/brand.d.ts +70 -0
  26. package/dist/brand.js +294 -0
  27. package/dist/brief/archetypes.d.ts +28 -0
  28. package/dist/brief/archetypes.js +798 -0
  29. package/dist/brief/content-synthesize.d.ts +31 -0
  30. package/dist/brief/content-synthesize.js +210 -0
  31. package/dist/brief/deck-outlines.d.ts +96 -0
  32. package/dist/brief/deck-outlines.js +561 -0
  33. package/dist/brief/draft-bodies.d.ts +29 -0
  34. package/dist/brief/draft-bodies.js +224 -0
  35. package/dist/brief/fields.d.ts +19 -0
  36. package/dist/brief/fields.js +229 -0
  37. package/dist/brief/index.d.ts +8 -0
  38. package/dist/brief/index.js +8 -0
  39. package/dist/brief/kind-detection.d.ts +69 -0
  40. package/dist/brief/kind-detection.js +353 -0
  41. package/dist/brief/limits.d.ts +117 -0
  42. package/dist/brief/limits.js +217 -0
  43. package/dist/brief/page-doc-outlines.d.ts +80 -0
  44. package/dist/brief/page-doc-outlines.js +485 -0
  45. package/dist/brief/synthesize.d.ts +59 -0
  46. package/dist/brief/synthesize.js +511 -0
  47. package/dist/brief/text.d.ts +13 -0
  48. package/dist/brief/text.js +59 -0
  49. package/dist/canvas-screen.d.ts +31 -0
  50. package/dist/canvas-screen.js +33 -0
  51. package/dist/canvas-seed.d.ts +58 -0
  52. package/dist/canvas-seed.js +82 -0
  53. package/dist/computed/evaluate.d.ts +11 -0
  54. package/dist/computed/evaluate.js +101 -0
  55. package/dist/computed/index.d.ts +4 -0
  56. package/dist/computed/index.js +3 -0
  57. package/dist/computed/public.d.ts +14 -0
  58. package/dist/computed/public.js +19 -0
  59. package/dist/computed/schema.d.ts +10 -0
  60. package/dist/computed/schema.js +140 -0
  61. package/dist/computed/types.d.ts +70 -0
  62. package/dist/computed/types.js +1 -0
  63. package/dist/computed/validate.d.ts +6 -0
  64. package/dist/computed/validate.js +146 -0
  65. package/dist/csv-guard.d.ts +15 -0
  66. package/dist/csv-guard.js +29 -0
  67. package/dist/csv.d.ts +85 -0
  68. package/dist/csv.js +288 -0
  69. package/dist/files.d.ts +35 -0
  70. package/dist/files.js +246 -0
  71. package/dist/form-schema.d.ts +124 -0
  72. package/dist/form-schema.js +300 -0
  73. package/dist/generator-version.d.ts +11 -0
  74. package/dist/generator-version.js +51 -0
  75. package/dist/generator.d.ts +34 -0
  76. package/dist/generator.js +445 -0
  77. package/dist/identity.d.ts +34 -0
  78. package/dist/identity.js +91 -0
  79. package/dist/import/csv-shape.d.ts +40 -0
  80. package/dist/import/csv-shape.js +265 -0
  81. package/dist/import/json-shape.d.ts +55 -0
  82. package/dist/import/json-shape.js +491 -0
  83. package/dist/import/limits.d.ts +13 -0
  84. package/dist/import/limits.js +14 -0
  85. package/dist/import/sqlite-schema.d.ts +36 -0
  86. package/dist/import/sqlite-schema.js +729 -0
  87. package/dist/index.d.ts +60 -0
  88. package/dist/index.js +58 -0
  89. package/dist/kinds/anchors.d.ts +18 -0
  90. package/dist/kinds/anchors.js +46 -0
  91. package/dist/kinds/bodies.d.ts +6 -0
  92. package/dist/kinds/bodies.js +32 -0
  93. package/dist/kinds/brand-runtime.d.ts +19 -0
  94. package/dist/kinds/brand-runtime.js +35 -0
  95. package/dist/kinds/deck.d.ts +8 -0
  96. package/dist/kinds/deck.js +34 -0
  97. package/dist/kinds/doc.d.ts +19 -0
  98. package/dist/kinds/doc.js +41 -0
  99. package/dist/kinds/embed/index.d.ts +39 -0
  100. package/dist/kinds/embed/index.js +91 -0
  101. package/dist/kinds/envelope.d.ts +159 -0
  102. package/dist/kinds/envelope.js +78 -0
  103. package/dist/kinds/index.d.ts +11 -0
  104. package/dist/kinds/index.js +11 -0
  105. package/dist/kinds/kind.d.ts +36 -0
  106. package/dist/kinds/kind.js +55 -0
  107. package/dist/kinds/limits.d.ts +64 -0
  108. package/dist/kinds/limits.js +190 -0
  109. package/dist/kinds/page-doc-runtime.d.ts +37 -0
  110. package/dist/kinds/page-doc-runtime.js +55 -0
  111. package/dist/kinds/page.d.ts +16 -0
  112. package/dist/kinds/page.js +67 -0
  113. package/dist/kinds/parse.d.ts +18 -0
  114. package/dist/kinds/parse.js +27 -0
  115. package/dist/kinds/runtime.d.ts +128 -0
  116. package/dist/kinds/runtime.js +206 -0
  117. package/dist/kinds/services.d.ts +49 -0
  118. package/dist/kinds/services.js +249 -0
  119. package/dist/kinds/testing.d.ts +9 -0
  120. package/dist/kinds/testing.js +16 -0
  121. package/dist/migrations.d.ts +97 -0
  122. package/dist/migrations.js +351 -0
  123. package/dist/motion.d.ts +50 -0
  124. package/dist/motion.js +52 -0
  125. package/dist/notifications.d.ts +19 -0
  126. package/dist/notifications.js +177 -0
  127. package/dist/patch.d.ts +122 -0
  128. package/dist/patch.js +322 -0
  129. package/dist/policy.d.ts +23 -0
  130. package/dist/policy.js +105 -0
  131. package/dist/runtime-definition.d.ts +39 -0
  132. package/dist/runtime-definition.js +74 -0
  133. package/dist/scenarios.d.ts +9 -0
  134. package/dist/scenarios.js +49 -0
  135. package/dist/schema/blueprint.schema.json +10191 -0
  136. package/dist/schema-json.d.ts +6 -0
  137. package/dist/schema-json.js +36 -0
  138. package/dist/schema.d.ts +6348 -0
  139. package/dist/schema.js +1269 -0
  140. package/dist/search.d.ts +16 -0
  141. package/dist/search.js +96 -0
  142. package/dist/services.d.ts +89 -0
  143. package/dist/services.js +898 -0
  144. package/dist/share.d.ts +78 -0
  145. package/dist/share.js +234 -0
  146. package/dist/sheet-screen.d.ts +49 -0
  147. package/dist/sheet-screen.js +91 -0
  148. package/dist/storage.d.ts +43 -0
  149. package/dist/storage.js +179 -0
  150. package/dist/stream.d.ts +26 -0
  151. package/dist/stream.js +173 -0
  152. package/dist/untrusted.d.ts +63 -0
  153. package/dist/untrusted.js +502 -0
  154. package/dist/validation-tasks.d.ts +178 -0
  155. package/dist/validation-tasks.js +199 -0
  156. package/dist/validation.d.ts +35 -0
  157. package/dist/validation.js +312 -0
  158. package/dist/variants.d.ts +112 -0
  159. package/dist/variants.js +283 -0
  160. package/dist/workflows/index.d.ts +3 -0
  161. package/dist/workflows/index.js +3 -0
  162. package/dist/workflows/schema.d.ts +50 -0
  163. package/dist/workflows/schema.js +34 -0
  164. package/dist/workflows/transition.d.ts +15 -0
  165. package/dist/workflows/transition.js +25 -0
  166. package/dist/workflows/validate.d.ts +17 -0
  167. package/dist/workflows/validate.js +140 -0
  168. package/package.json +120 -3
package/dist/schema.js ADDED
@@ -0,0 +1,1269 @@
1
+ import { CatalogSchema, LayoutIdSchema } from '@protoyard/core';
2
+ import { GEO_REGIONS } from '@protoyard/scenarios';
3
+ import { z } from 'zod';
4
+ import { ArchetypeSchema } from "./archetypes/schema.js";
5
+ import { canvasScreenIssues, canvasScreenSchema } from "./canvas-screen.js";
6
+ import { BlueprintMotionSchema } from "./motion.js";
7
+ import { sheetScreenIssues, sheetScreenSchema } from "./sheet-screen.js";
8
+ import { ValidationSchema, validationIssues } from "./validation-tasks.js";
9
+ /**
10
+ * `blueprint.json` — a data-only description of a product (ADR-017):
11
+ * entities with typed fields and relations, roles with per-entity actions,
12
+ * and screens. It contains no executable code; every derivation (stores,
13
+ * inputs, policy, generators, services) reads this validated shape.
14
+ *
15
+ * Shapes are strict so typos fail loudly, and cross-references (relation
16
+ * targets, title fields, owner fields, board columns) are checked after
17
+ * parsing with path-specific messages.
18
+ */
19
+ export const BLUEPRINT_SCHEMA_URL = 'https://github.com/DailybotHQ/protoyard/schema/blueprint/v1';
20
+ /** Entity, plural and role names: lowercase kebab-case. */
21
+ const NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
22
+ /** Field names become row properties: lowerCamelCase. */
23
+ const FIELD_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
24
+ /** Table names allow `_` only so the reserved `py_` prefix gets a clear message. */
25
+ const TABLE_PATTERN = /^[a-z][a-zA-Z0-9_]*$/;
26
+ const RESERVED_PREFIX = 'py_';
27
+ /** Columns the platform manages on every row; blueprints cannot redeclare them. */
28
+ export const RESERVED_FIELD_NAMES = [
29
+ 'id',
30
+ 'createdAt',
31
+ 'updatedAt',
32
+ 'deletedAt',
33
+ 'deletedBy',
34
+ ];
35
+ const name = (what) => z.string().regex(NAME_PATTERN, { message: `${what} must be lowercase kebab-case` });
36
+ const fieldName = z
37
+ .string()
38
+ .regex(FIELD_PATTERN, { message: 'field names must be lowerCamelCase' });
39
+ const cents = z.number().int({ message: 'money is integer cents, never a float' });
40
+ const fieldBase = {
41
+ name: fieldName,
42
+ label: z.string().min(1).max(60).optional(),
43
+ description: z.string().max(200).optional(),
44
+ required: z.boolean().default(false),
45
+ /** Redacted from audit diffs. */
46
+ sensitive: z.boolean().default(false),
47
+ /** Generator hint: a Faker path such as `company.name`. */
48
+ fake: z
49
+ .string()
50
+ .regex(/^[a-z]+\.[a-zA-Z]+$/, { message: 'fake must look like "module.method"' })
51
+ .optional(),
52
+ };
53
+ const lengths = (max) => ({
54
+ minLength: z.number().int().nonnegative().optional(),
55
+ maxLength: z.number().int().positive().max(max).default(max),
56
+ });
57
+ /**
58
+ * How generated values of a number, integer or money field evolve with the
59
+ * row's `createdAt`: `growth` and `decline` trend over time, `seasonal` adds
60
+ * weekly and yearly rhythm. Generation hint only; inputs are not affected.
61
+ */
62
+ export const FIELD_TRENDS = ['flat', 'growth', 'decline', 'seasonal'];
63
+ const trend = z.enum(FIELD_TRENDS).optional();
64
+ /**
65
+ * Where generated dates fall, relative to the scenario's now: `past` (since
66
+ * the organization started, the default), `future` (the next 30 days),
67
+ * `around` (14 days either side), or `{ from, to }` in days (negative is past).
68
+ * Past dates never precede the organization; dates after now are allowed and
69
+ * are therefore left out of the "nothing in the future" integrity check.
70
+ */
71
+ export const DateWindowSchema = z.union([
72
+ z.enum(['past', 'future', 'around']),
73
+ z.strictObject({
74
+ from: z.number().int().min(-3650).max(3650),
75
+ to: z.number().int().min(-3650).max(3650),
76
+ }),
77
+ ]);
78
+ /** A window as day offsets from now; `null` from means "since the organization started". */
79
+ export function dateWindowDays(window) {
80
+ if (window === undefined || window === 'past')
81
+ return { from: null, to: 0 };
82
+ if (window === 'future')
83
+ return { from: 0, to: 30 };
84
+ if (window === 'around')
85
+ return { from: -14, to: 14 };
86
+ return window;
87
+ }
88
+ const field = (kind, extra) => z.strictObject({ ...fieldBase, kind: z.literal(kind), ...extra });
89
+ export const FieldSchema = z.discriminatedUnion('kind', [
90
+ field('text', {
91
+ ...lengths(200),
92
+ default: z.string().optional(),
93
+ }),
94
+ field('longText', {
95
+ ...lengths(10_000),
96
+ default: z.string().optional(),
97
+ }),
98
+ field('number', {
99
+ min: z.number().optional(),
100
+ max: z.number().optional(),
101
+ default: z.number().optional(),
102
+ trend,
103
+ }),
104
+ field('integer', {
105
+ min: z.number().int().optional(),
106
+ max: z.number().int().optional(),
107
+ default: z.number().int().optional(),
108
+ trend,
109
+ }),
110
+ field('money', {
111
+ currency: z
112
+ .string()
113
+ .regex(/^[A-Z]{3}$/, { message: 'currency must be an ISO 4217 code such as "USD"' })
114
+ .default('USD'),
115
+ min: cents.optional(),
116
+ max: cents.optional(),
117
+ default: cents.optional(),
118
+ trend,
119
+ }),
120
+ field('boolean', { default: z.boolean().optional() }),
121
+ field('date', { window: DateWindowSchema.optional() }),
122
+ field('datetime', { window: DateWindowSchema.optional() }),
123
+ field('enum', {
124
+ values: z
125
+ .array(z.string().regex(/^[a-z0-9][a-z0-9_-]*$/, { message: 'enum values are lowercase' }))
126
+ .min(1, { message: 'enum fields need at least one value' }),
127
+ labels: z.record(z.string(), z.string().min(1)).optional(),
128
+ default: z.string().optional(),
129
+ /** Relative generation weights, one per value (default: equal). */
130
+ weights: z.array(z.number().nonnegative()).optional(),
131
+ }),
132
+ field('email'),
133
+ field('url'),
134
+ field('phone'),
135
+ field('tags', {
136
+ maxItems: z.number().int().positive().max(64).default(8),
137
+ }),
138
+ field('relation', {
139
+ /** Target entity `name`; the declaring entity owns the stored foreign key. */
140
+ to: z.string().min(1),
141
+ /** `required: false` is a nullable one-reference / empty many-collection. */
142
+ cardinality: z.enum(['one', 'many']).default('one'),
143
+ /** Delete behavior for rows that reference this field's target. */
144
+ onDelete: z.enum(['restrict', 'cascade', 'nullify']).default('restrict'),
145
+ }),
146
+ /** References a persona in the reserved `py_personas` table. */
147
+ field('persona'),
148
+ field('file', {
149
+ /** MIME types or wildcards (`image/*`). Empty accepts any type. */
150
+ accept: z.array(z.string().regex(/^[a-z]+\/(?:\*|[a-z0-9.+-]+)$/)).default([]),
151
+ maxSizeBytes: z
152
+ .number()
153
+ .int()
154
+ .positive()
155
+ .max(50 * 1024 * 1024)
156
+ .default(5 * 1024 * 1024),
157
+ multiple: z.boolean().default(false),
158
+ }),
159
+ field('json'),
160
+ /**
161
+ * A point stored as `{ lat, lng }` (5 decimals). `region` names a fictional
162
+ * region from `@protoyard/scenarios` that generated points fall inside.
163
+ */
164
+ field('geo', {
165
+ region: z.enum(Object.keys(GEO_REGIONS)).optional(),
166
+ }),
167
+ /**
168
+ * A face from the avatar library, stored as its id (`av-001` … `av-175`).
169
+ * Generated rows get distinct seeded faces; never indexed, searched or sorted.
170
+ */
171
+ field('avatar'),
172
+ ]);
173
+ export const FIELD_KINDS = FieldSchema.options.map((option) => option.shape.kind.value);
174
+ export const EntityFeaturesSchema = z.strictObject({
175
+ softDelete: z.boolean().default(false),
176
+ audit: z.boolean().default(false),
177
+ notifications: z.boolean().default(false),
178
+ files: z.boolean().default(false),
179
+ csv: z.boolean().default(false),
180
+ });
181
+ const EntityScreenDefaultsSchema = z
182
+ .strictObject({
183
+ /** Standard CRUD views; when present, this is the entity's enabled view set. */
184
+ views: z.array(z.enum(['list', 'detail', 'form'])).min(1),
185
+ list: z
186
+ .strictObject({
187
+ /** Visible list columns; defaults to the entity's ordinary columns. */
188
+ columns: z.array(fieldName).optional(),
189
+ })
190
+ .optional(),
191
+ detail: z
192
+ .strictObject({
193
+ /** Default related sections and built-in detail tabs. */
194
+ tabs: z
195
+ .array(z.union([
196
+ z.enum(['overview', 'activity', 'files', 'audit']),
197
+ z.string().regex(/^related:[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/, {
198
+ message: 'tabs are overview, activity, files, audit or related:<entity>',
199
+ }),
200
+ ]))
201
+ .min(1)
202
+ .optional(),
203
+ })
204
+ .optional(),
205
+ })
206
+ .superRefine((defaults, ctx) => {
207
+ for (const view of duplicates(defaults.views)) {
208
+ ctx.addIssue({
209
+ code: 'custom',
210
+ path: ['views'],
211
+ message: `duplicate view "${view}"`,
212
+ });
213
+ }
214
+ if (defaults.list && !defaults.views.includes('list')) {
215
+ ctx.addIssue({
216
+ code: 'custom',
217
+ path: ['list'],
218
+ message: 'list defaults require "list" in views',
219
+ });
220
+ }
221
+ if (defaults.detail && !defaults.views.includes('detail')) {
222
+ ctx.addIssue({
223
+ code: 'custom',
224
+ path: ['detail'],
225
+ message: 'detail defaults require "detail" in views',
226
+ });
227
+ }
228
+ });
229
+ export const EntitySchema = z.strictObject({
230
+ /** Singular kebab-case name: `deal`, `support-ticket`. */
231
+ name: name('entity name'),
232
+ plural: name('entity plural'),
233
+ label: z.string().min(1).max(60).optional(),
234
+ /** Dexie table name; defaults to the plural in lowerCamelCase (see `entityTable`). */
235
+ table: z.string().regex(TABLE_PATTERN, { message: 'table must be lowerCamelCase' }).optional(),
236
+ fields: z.array(FieldSchema).min(1, { message: 'an entity needs at least one field' }),
237
+ /** Field shown as the record's title in lists, pickers and search. */
238
+ titleField: fieldName,
239
+ searchable: z.array(fieldName).default([]),
240
+ /** A `persona` field naming who owns the record (enables `:own` actions). */
241
+ ownerField: fieldName.optional(),
242
+ indexes: z.array(fieldName).default([]),
243
+ defaultSort: z
244
+ .strictObject({ field: fieldName, direction: z.enum(['asc', 'desc']).default('asc') })
245
+ .optional(),
246
+ /** Rows to generate at `normal` density (scaled by density); default 4 for roots, 8 for dependents. */
247
+ seedCount: z.number().int().min(1).max(500).optional(),
248
+ /** Optional CRUD defaults; missing keeps the legacy list/detail routes. */
249
+ screenDefaults: EntityScreenDefaultsSchema.optional(),
250
+ features: EntityFeaturesSchema.default({
251
+ softDelete: false,
252
+ audit: false,
253
+ notifications: false,
254
+ files: false,
255
+ csv: false,
256
+ }),
257
+ });
258
+ export const ENTITY_ACTIONS = [
259
+ 'read',
260
+ 'create',
261
+ 'update',
262
+ 'update:own',
263
+ 'delete',
264
+ 'delete:own',
265
+ 'import',
266
+ 'export',
267
+ ];
268
+ /** Exact capability denials are accepted for the runtime's `-capability` semantics. */
269
+ export const PermissionActionSchema = z.union([
270
+ z.enum([...ENTITY_ACTIONS, '*']),
271
+ z.string().regex(/^-[a-z][a-z0-9-]*(?::[a-z][a-z0-9:*-]*)?$/, {
272
+ message: 'denials must name an exact capability such as "-ticket:delete"',
273
+ }),
274
+ ]);
275
+ export const RoleSchema = z.strictObject({
276
+ name: name('role name'),
277
+ label: z.string().min(1).max(40).optional(),
278
+ description: z.string().max(200).optional(),
279
+ /** Entity name (or `*` for every entity) → allowed actions (`*` for all). */
280
+ permissions: z.record(z.string(), z.array(PermissionActionSchema)),
281
+ });
282
+ /**
283
+ * Lucide icon names (kebab-case) a screen's `nav.icon` may use. An allow-list
284
+ * keeps generated imports static and tree-shakeable.
285
+ */
286
+ export const SCREEN_NAV_ICONS = [
287
+ 'activity',
288
+ 'bell',
289
+ 'book-open',
290
+ 'briefcase',
291
+ 'building',
292
+ 'calendar',
293
+ 'calendar-days',
294
+ 'chart-bar',
295
+ 'chart-gantt',
296
+ 'chart-line',
297
+ 'chart-pie',
298
+ 'clock',
299
+ 'cpu',
300
+ 'database',
301
+ 'dollar-sign',
302
+ 'file-text',
303
+ 'folder',
304
+ 'globe',
305
+ 'graduation-cap',
306
+ 'heart-pulse',
307
+ 'house',
308
+ 'inbox',
309
+ 'kanban',
310
+ 'layout-dashboard',
311
+ 'list',
312
+ 'map',
313
+ 'map-pin',
314
+ 'message-square',
315
+ 'package',
316
+ 'receipt',
317
+ 'settings',
318
+ 'shopping-cart',
319
+ 'star',
320
+ 'table',
321
+ 'tag',
322
+ 'ticket',
323
+ 'truck',
324
+ 'users',
325
+ 'wallet',
326
+ 'wrench',
327
+ 'zap',
328
+ ];
329
+ /** Sidebar entry options shared by every screen. */
330
+ export const ScreenNavSchema = z.strictObject({
331
+ label: z.string().min(1).max(40).optional(),
332
+ icon: z.enum(SCREEN_NAV_ICONS).optional(),
333
+ /** Lower comes first; screens without an order keep blueprint order after ordered ones. */
334
+ order: z.number().int().min(0).max(999).optional(),
335
+ /** Routed but not listed in the sidebar. */
336
+ hidden: z.boolean().default(false),
337
+ });
338
+ const screenBase = {
339
+ /** Hash route; defaults are derived from the kind and entity (see `screenPath`). */
340
+ path: z
341
+ .string()
342
+ .regex(/^\/[a-z0-9/:-]*$/, { message: 'path must be a hash route such as "/deals"' })
343
+ .optional(),
344
+ title: z.string().min(1).max(60).optional(),
345
+ nav: ScreenNavSchema.optional(),
346
+ };
347
+ export const AGGREGATES = ['count', 'sum', 'avg', 'min', 'max'];
348
+ export const BUCKET_UNITS = ['day', 'week', 'month'];
349
+ export const CHART_KINDS = ['line', 'area', 'bar', 'donut'];
350
+ export const DETAIL_TABS = ['overview', 'activity', 'files', 'audit'];
351
+ export const SETTINGS_SECTIONS = [
352
+ 'profile',
353
+ 'workspace',
354
+ 'appearance',
355
+ 'notifications',
356
+ 'danger',
357
+ ];
358
+ const filterValue = z.union([z.string(), z.number(), z.boolean()]);
359
+ /** Restricts the rows a widget reads: `{ field, op, value }`; `in` takes an array. */
360
+ export const ScreenFilterSchema = z.strictObject({
361
+ field: fieldName,
362
+ op: z.enum(['eq', 'in', 'gte', 'lte']),
363
+ value: z.union([filterValue, z.array(filterValue).min(1)]),
364
+ });
365
+ /** Date bucketing for time series: `{ field: 'createdAt', unit: 'week' }`. */
366
+ export const ScreenBucketSchema = z.strictObject({
367
+ /** A `date` or `datetime` field, or the managed `createdAt` / `updatedAt`. */
368
+ field: fieldName,
369
+ unit: z.enum(BUCKET_UNITS),
370
+ });
371
+ const widgetBase = {
372
+ label: z.string().min(1).max(60),
373
+ entity: z.string().min(1),
374
+ filter: ScreenFilterSchema.optional(),
375
+ /** Grid columns the widget spans (1–4). */
376
+ span: z.number().int().min(1).max(4).optional(),
377
+ };
378
+ const measure = {
379
+ aggregate: z.enum(AGGREGATES).default('count'),
380
+ /** Required for every aggregate except `count`. */
381
+ field: fieldName.optional(),
382
+ };
383
+ export const DashboardWidgetSchema = z.discriminatedUnion('kind', [
384
+ z.strictObject({ ...widgetBase, ...measure, kind: z.literal('kpi') }),
385
+ z.strictObject({
386
+ ...widgetBase,
387
+ ...measure,
388
+ kind: z.literal('chart'),
389
+ chart: z.enum(CHART_KINDS),
390
+ /** An `enum`, `relation`, `persona` or `boolean` field: one series or slice per value. */
391
+ groupBy: fieldName.optional(),
392
+ bucket: ScreenBucketSchema.optional(),
393
+ }),
394
+ z.strictObject({
395
+ ...widgetBase,
396
+ kind: z.literal('list'),
397
+ limit: z.number().int().min(1).max(20).default(5),
398
+ sort: z
399
+ .strictObject({ field: fieldName, direction: z.enum(['asc', 'desc']).default('desc') })
400
+ .optional(),
401
+ }),
402
+ z.strictObject({
403
+ ...widgetBase,
404
+ kind: z.literal('board-summary'),
405
+ /** An `enum` field: one count per value, linking to the board. */
406
+ groupBy: fieldName,
407
+ }),
408
+ ]);
409
+ /** formatVersion 1 dashboard metric; parsed into a `kpi` widget. */
410
+ const LegacyMetricSchema = z.strictObject({
411
+ label: z.string().min(1).max(60),
412
+ entity: z.string().min(1),
413
+ aggregate: z.enum(['count', 'sum']),
414
+ /** Required for `sum`: a number, integer or money field. */
415
+ field: fieldName.optional(),
416
+ });
417
+ const entityScreen = (type, extra) => z.strictObject({
418
+ ...screenBase,
419
+ type: z.literal(type),
420
+ entity: z.string().min(1),
421
+ ...extra,
422
+ });
423
+ const DetailTabSchema = z.union([
424
+ z.enum(DETAIL_TABS),
425
+ z.string().regex(/^related:[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/, {
426
+ message: 'tabs are overview, activity, files, audit or related:<entity>',
427
+ }),
428
+ ]);
429
+ /**
430
+ * Screens a generated prototype routes. Additive to formatVersion 1: older
431
+ * files parse unchanged. Parsing keeps what the file says so issues point at
432
+ * real paths; consumers normalize with `dashboardWidgets` (legacy `metrics`
433
+ * become `kpi` widgets) and `screenPath` (default routes).
434
+ */
435
+ export const ScreenSchema = z.discriminatedUnion('type', [
436
+ entityScreen('list', { columns: z.array(fieldName).optional() }),
437
+ entityScreen('detail', {
438
+ /** Record page tabs; omitted means the edit form only. */
439
+ tabs: z.array(DetailTabSchema).min(1).optional(),
440
+ }),
441
+ entityScreen('form'),
442
+ entityScreen('board', {
443
+ /** An `enum` field; each value becomes a column. */
444
+ groupBy: fieldName,
445
+ cardFields: z.array(fieldName).max(6).optional(),
446
+ /** A number, integer or money field summed per column. */
447
+ sumField: fieldName.optional(),
448
+ }),
449
+ entityScreen('calendar', {
450
+ /** A `date` or `datetime` field. */
451
+ dateField: fieldName,
452
+ /** Optional end of the event, same kinds as `dateField`. */
453
+ endField: fieldName.optional(),
454
+ /** An `enum` field that colors events. */
455
+ colorBy: fieldName.optional(),
456
+ view: z.enum(['month', 'week', 'day', 'agenda']).default('month'),
457
+ }),
458
+ entityScreen('timeline', {
459
+ startField: fieldName,
460
+ endField: fieldName,
461
+ /** An `enum`, `relation` or `persona` field: one row per value. */
462
+ groupBy: fieldName.optional(),
463
+ zoom: z.enum(['week', 'month', 'quarter']).default('month'),
464
+ }),
465
+ entityScreen('map', {
466
+ /** A `geo` field. */
467
+ geoField: fieldName,
468
+ colorBy: fieldName.optional(),
469
+ }),
470
+ entityScreen('analytics', {
471
+ /** A `date` or `datetime` field (or `createdAt`) the explorer buckets by. */
472
+ dateField: fieldName,
473
+ measures: z
474
+ .array(z.strictObject({ label: z.string().min(1).max(60).optional(), ...measure }))
475
+ .min(1),
476
+ /** `enum`, `relation`, `persona` or `boolean` fields to break measures down by. */
477
+ dimensions: z.array(fieldName).default([]),
478
+ }),
479
+ z.strictObject({
480
+ ...screenBase,
481
+ type: z.literal('dashboard'),
482
+ /** formatVersion 1 KPI list; read both through `dashboardWidgets`. */
483
+ metrics: z.array(LegacyMetricSchema).optional(),
484
+ widgets: z.array(DashboardWidgetSchema).default([]),
485
+ }),
486
+ sheetScreenSchema(screenBase, fieldName),
487
+ canvasScreenSchema(screenBase, fieldName),
488
+ z.strictObject({
489
+ ...screenBase,
490
+ type: z.literal('settings'),
491
+ sections: z
492
+ .array(z.enum(SETTINGS_SECTIONS))
493
+ .min(1)
494
+ .default(['profile', 'workspace', 'appearance']),
495
+ }),
496
+ ]);
497
+ export const SCREEN_TYPES = [
498
+ 'list',
499
+ 'detail',
500
+ 'form',
501
+ 'board',
502
+ 'calendar',
503
+ 'timeline',
504
+ 'map',
505
+ 'analytics',
506
+ 'dashboard',
507
+ 'settings',
508
+ 'sheet',
509
+ 'canvas',
510
+ ];
511
+ /** A dashboard's widgets with its legacy `metrics` first, as `kpi` widgets. */
512
+ export function dashboardWidgets(screen) {
513
+ const legacy = (screen.metrics ?? []).map((metric) => ({ ...metric, kind: 'kpi' }));
514
+ return [...legacy, ...screen.widgets];
515
+ }
516
+ /** Route segment each entity-scoped view kind gets below `/<plural>`. */
517
+ const VIEW_SEGMENT = {
518
+ board: 'board',
519
+ calendar: 'calendar',
520
+ timeline: 'timeline',
521
+ map: 'map',
522
+ analytics: 'analytics',
523
+ sheet: 'sheet',
524
+ canvas: 'canvas',
525
+ };
526
+ /**
527
+ * The hash route a screen gets: its `path`, or the default for its kind —
528
+ * `/<plural>`, `/<plural>/:id`, `/<plural>/new`, `/<plural>/<kind>`,
529
+ * `/dashboard` and `/settings`. Null when the entity is unknown.
530
+ */
531
+ export function screenPath(screen, entities) {
532
+ if (screen.path)
533
+ return screen.path;
534
+ if (screen.type === 'dashboard')
535
+ return '/dashboard';
536
+ if (screen.type === 'settings')
537
+ return '/settings';
538
+ const entity = entities.find((e) => e.name === screen.entity);
539
+ if (!entity)
540
+ return null;
541
+ if (screen.type === 'list')
542
+ return `/${entity.plural}`;
543
+ if (screen.type === 'detail')
544
+ return `/${entity.plural}/:id`;
545
+ if (screen.type === 'form')
546
+ return `/${entity.plural}/new`;
547
+ return `/${entity.plural}/${VIEW_SEGMENT[screen.type]}`;
548
+ }
549
+ /** Routes the generated app always owns; screens cannot claim them. */
550
+ export const RESERVED_SCREEN_PATHS = ['/members'];
551
+ const NotificationRuleSchema = z
552
+ .strictObject({
553
+ on: z.enum(['create', 'update', 'assign']),
554
+ entity: name('notification entity'),
555
+ field: fieldName.optional(),
556
+ notify: z.string().regex(/^(owner|assignee|role:[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$/),
557
+ /** Plain text; `{field}` tokens are substituted from the changed entity row. */
558
+ title: z
559
+ .string()
560
+ .min(1)
561
+ .max(120)
562
+ .refine((value) => value.length <= 120 && !/<\/?[a-z][^>]*>/i.test(value), {
563
+ message: 'notification titles must be plain text, not HTML',
564
+ }),
565
+ })
566
+ .superRefine((rule, ctx) => {
567
+ if (rule.on === 'assign' && !rule.field) {
568
+ ctx.addIssue({
569
+ code: 'custom',
570
+ path: ['field'],
571
+ message: 'assign rules need a persona field',
572
+ });
573
+ }
574
+ if (rule.on !== 'assign' && rule.field !== undefined) {
575
+ ctx.addIssue({
576
+ code: 'custom',
577
+ path: ['field'],
578
+ message: 'field is only valid for assign rules',
579
+ });
580
+ }
581
+ });
582
+ /**
583
+ * A previous blueprint kept by `protoyard generate` so every database version
584
+ * can be rebuilt from data alone: version 1 is the first entry's stores, and
585
+ * each later entry (and finally the current blueprint) is diffed against the
586
+ * one before it. `migrateNext` holds the options of that next diff.
587
+ */
588
+ export const BlueprintHistoryEntrySchema = z.strictObject({
589
+ schemaVersion: z.number().int().positive(),
590
+ /** The blueprint as it was (without its own history); validated when versions are derived. */
591
+ blueprint: z.record(z.string(), z.unknown()),
592
+ migrateNext: z.strictObject({ allowDrop: z.boolean().optional() }).optional(),
593
+ });
594
+ /** Brand fields a `libraryTheme` replaces; setting any of them alongside it is an error. */
595
+ const LIBRARY_EXCLUSIVE_KEYS = [
596
+ 'id',
597
+ 'seed',
598
+ 'secondary',
599
+ 'preset',
600
+ 'styleProfile',
601
+ 'font',
602
+ 'radius',
603
+ 'density',
604
+ ];
605
+ /** Brand hex seed (`#rgb` or `#rrggbb`). */
606
+ const brandHex = z.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, {
607
+ message: 'brand colors must be #rgb or #rrggbb hex',
608
+ });
609
+ /**
610
+ * Optional product brand for `protoyard generate`. When set, the CLI builds a
611
+ * WCAG AA theme (seed palette and/or built-in preset + style profile + font)
612
+ * and wires it as the prototype default. `libraryTheme` instead names one of
613
+ * the `@protoyard/themes` library looks: the prototype ships it by id, with no
614
+ * generated theme module. Absent brand → template default theme.
615
+ */
616
+ export const BlueprintBrandSchema = z
617
+ .strictObject({
618
+ /**
619
+ * Theme id written into the prototype (kebab-case: no leading, trailing or
620
+ * doubled hyphens, so it always maps to a valid export name). Defaults from name.
621
+ */
622
+ id: z
623
+ .string()
624
+ .regex(NAME_PATTERN, { message: 'brand id must be lowercase kebab-case' })
625
+ .max(40)
626
+ .optional(),
627
+ /** Display name for the generated theme (max 40; ThemeSchema limit). */
628
+ name: z.string().min(1).max(40).optional(),
629
+ /** Brand seed for the palette engine. Required unless `preset` is set. */
630
+ seed: brandHex.optional(),
631
+ /** Optional secondary seed for charts and secondary fills. */
632
+ secondary: brandHex.optional(),
633
+ /**
634
+ * Built-in preset id (`violet`, `ocean`, …). Used as the full theme when
635
+ * `seed` is omitted; ignored for palette when `seed` is present.
636
+ */
637
+ preset: z.enum(['violet', 'ocean', 'emerald', 'rose', 'amber', 'monochrome']).optional(),
638
+ /**
639
+ * Named style profile: radius, density and elevation knobs applied after
640
+ * the palette (or preset) is chosen.
641
+ */
642
+ styleProfile: z.enum(['soft', 'crisp', 'flat', 'rounded', 'editorial', 'mono']).optional(),
643
+ /** `FONT_CATALOG` id from `@protoyard/themes`. */
644
+ font: z
645
+ .enum([
646
+ 'system',
647
+ 'humanist',
648
+ 'geometric',
649
+ 'grotesk',
650
+ 'editorial',
651
+ 'rounded',
652
+ 'condensed',
653
+ 'mono',
654
+ ])
655
+ .optional(),
656
+ /** CSS radius length (`0.5rem`, `8px`). */
657
+ radius: z
658
+ .string()
659
+ .regex(/^(?:0|\d{1,3}(?:\.\d{1,4})?(?:px|rem|em))$/, {
660
+ message: 'radius must look like 0.5rem or 8px',
661
+ })
662
+ .optional(),
663
+ /** Spacing multiplier 0.75–1.25. */
664
+ density: z.number().min(0.75).max(1.25).optional(),
665
+ /**
666
+ * Library theme id (`fintech-ledger`, `health-clinic`, … any of the 204
667
+ * in `ALL_LIBRARY_THEME_IDS`, catalog or expanded) used as-is.
668
+ * Its palette, fonts, radius and density come from the library (AA-tested
669
+ * there), so it excludes `id`, `seed`, `secondary`, `preset`,
670
+ * `styleProfile`, `font`, `radius` and `density`. The id is checked against
671
+ * the library by `brandLibraryThemeId` (generate, `blueprint validate`), not
672
+ * here: `parseBlueprint` runs in prototype bundles, which load the library
673
+ * lazily.
674
+ */
675
+ libraryTheme: z
676
+ .string()
677
+ .regex(/^[a-z][a-z0-9-]*$/, { message: 'libraryTheme must be a lowercase kebab-case id' })
678
+ .max(40)
679
+ .optional(),
680
+ })
681
+ .superRefine((brand, ctx) => {
682
+ if (brand.libraryTheme !== undefined) {
683
+ for (const key of LIBRARY_EXCLUSIVE_KEYS) {
684
+ if (brand[key] !== undefined) {
685
+ ctx.addIssue({
686
+ code: 'custom',
687
+ path: [key],
688
+ message: `brand.${key} cannot be combined with libraryTheme (the library theme is used as-is)`,
689
+ });
690
+ }
691
+ }
692
+ return;
693
+ }
694
+ if (!brand.seed && !brand.preset) {
695
+ ctx.addIssue({
696
+ code: 'custom',
697
+ path: ['seed'],
698
+ message: 'brand needs a seed color, a theme preset or a libraryTheme',
699
+ });
700
+ }
701
+ });
702
+ /**
703
+ * Root keys every blueprint kind shares (ADR-036): identity, catalog metadata
704
+ * and brand. App-only keys (entities, roles, screens, layout, archetype,
705
+ * notifications, history, validation) stay on the app shape; content kinds
706
+ * add their own body key (`deck`, `page`, `doc`) in `kinds/`.
707
+ */
708
+ export const BLUEPRINT_BASE_SHAPE = {
709
+ $schema: z.string().optional(),
710
+ formatVersion: z.literal(1),
711
+ name: z.string().min(1).max(80),
712
+ description: z.string().max(280).optional(),
713
+ /**
714
+ * Marketplace shelf metadata (optional). Catalog blueprints under
715
+ * `blueprints/` set this so the registry can index them without a
716
+ * `protoyard.json`. Same shape as the manifest `catalog` block.
717
+ */
718
+ catalog: CatalogSchema.optional(),
719
+ /** Gallery tags when this file is indexed as a catalog blueprint. */
720
+ tags: z.array(z.string().min(1).max(32)).max(16).default([]),
721
+ /** Emoji or short glyph for catalog cards. */
722
+ icon: z.string().max(8).optional(),
723
+ /** Coarse category string (falls back to `catalog.category` when indexed). */
724
+ category: z.string().min(1).max(40).optional(),
725
+ /** Optional brand → generated on-brand theme for `protoyard generate`. */
726
+ brand: BlueprintBrandSchema.optional(),
727
+ /** Optional motion ceiling (`full`, `subtle`, `none`) rendered by `texuli/motion` (motion.ts). */
728
+ motion: BlueprintMotionSchema.optional(),
729
+ };
730
+ /** @internal Shared app input shape used by the generated kind-union schema. */
731
+ export const BlueprintShape = z.strictObject({
732
+ ...BLUEPRINT_BASE_SHAPE,
733
+ /**
734
+ * The blueprint kind (ADR-036). A missing kind means an app; an explicit
735
+ * `"app"` is accepted and removed by parsing, so both share one identity.
736
+ * There is deliberately no default: a default would change every identity.
737
+ */
738
+ kind: z.literal('app').optional(),
739
+ /** Shared app layout selected by generated apps. */
740
+ layout: z
741
+ .strictObject({
742
+ id: LayoutIdSchema,
743
+ mobile: LayoutIdSchema.optional(),
744
+ })
745
+ .optional(),
746
+ /** Portable kit-backed runtime data; omitted by ordinary CRUD blueprints. */
747
+ archetype: ArchetypeSchema.optional(),
748
+ entities: z.array(EntitySchema).min(1, { message: 'a blueprint needs at least one entity' }),
749
+ roles: z.array(RoleSchema).min(1, { message: 'a blueprint needs at least one role' }),
750
+ notifications: z.array(NotificationRuleSchema).default([]),
751
+ screens: z.array(ScreenSchema).default([]),
752
+ /** Earlier versions, oldest first. Written by `protoyard generate`; do not edit by hand. */
753
+ history: z.array(BlueprintHistoryEntrySchema).default([]),
754
+ /** Optional validation tasks for exploration sessions (see validation-tasks.ts). */
755
+ validation: ValidationSchema.optional(),
756
+ });
757
+ /** `plural` in lowerCamelCase unless `table` is set: `support-tickets` → `supportTickets`. */
758
+ export function entityTable(entity) {
759
+ return entity.table ?? entity.plural.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
760
+ }
761
+ const TITLE_KINDS = new Set(['text', 'email', 'url', 'phone', 'enum', 'integer']);
762
+ const SUM_KINDS = new Set(['number', 'integer', 'money']);
763
+ /** Kinds that are never indexed, searched or used to sort a list. */
764
+ const UNSORTABLE_KINDS = new Set(['geo', 'avatar']);
765
+ function duplicates(values) {
766
+ const seen = new Set();
767
+ const dupes = new Set();
768
+ for (const value of values)
769
+ (seen.has(value) ? dupes : seen).add(value);
770
+ return dupes;
771
+ }
772
+ function checkEntity(entity, at, names, add) {
773
+ const base = ['entities', at];
774
+ const fields = new Map(entity.fields.map((f) => [f.name, f]));
775
+ const table = entityTable(entity);
776
+ if (table.startsWith(RESERVED_PREFIX)) {
777
+ add([...base, 'table'], `table "${table}" uses the reserved "${RESERVED_PREFIX}" prefix`);
778
+ }
779
+ const dupes = duplicates(entity.fields.map((f) => f.name));
780
+ entity.fields.forEach((field, i) => {
781
+ const path = [...base, 'fields', i];
782
+ if (RESERVED_FIELD_NAMES.includes(field.name)) {
783
+ add([...path, 'name'], `"${field.name}" is managed by the platform and cannot be declared`);
784
+ }
785
+ if (dupes.has(field.name)) {
786
+ add([...path, 'name'], `duplicate field "${field.name}"`);
787
+ }
788
+ if (field.kind === 'relation') {
789
+ if (!names.has(field.to)) {
790
+ add([...path, 'to'], `relation "${entity.name}.${field.name}" targets unknown entity "${field.to}"`);
791
+ }
792
+ if (field.onDelete === 'nullify' && field.required) {
793
+ add([...path, 'onDelete'], `required relation "${field.name}" cannot use onDelete "nullify"`);
794
+ }
795
+ }
796
+ if (field.kind === 'enum') {
797
+ for (const value of duplicates(field.values)) {
798
+ add([...path, 'values'], `duplicate enum value "${value}"`);
799
+ }
800
+ if (field.default !== undefined && !field.values.includes(field.default)) {
801
+ add([...path, 'default'], `default "${field.default}" is not one of ${field.values.join(', ')}`);
802
+ }
803
+ if (field.weights !== undefined) {
804
+ if (field.weights.length !== field.values.length) {
805
+ add([...path, 'weights'], `weights needs one entry per value (${field.values.length})`);
806
+ }
807
+ else if (!field.weights.some((w) => w > 0)) {
808
+ add([...path, 'weights'], 'weights needs at least one positive entry');
809
+ }
810
+ }
811
+ }
812
+ if ((field.kind === 'date' || field.kind === 'datetime') && typeof field.window === 'object') {
813
+ if (field.window.from > field.window.to) {
814
+ add([...path, 'window'], 'window.from must not be after window.to');
815
+ }
816
+ }
817
+ if ('min' in field && field.min !== undefined && field.max !== undefined) {
818
+ if (field.min > field.max) {
819
+ add([...path, 'min'], 'min must not exceed max');
820
+ }
821
+ }
822
+ });
823
+ const title = fields.get(entity.titleField);
824
+ if (!title) {
825
+ add([...base, 'titleField'], `titleField "${entity.titleField}" is not a field of "${entity.name}"`);
826
+ }
827
+ else if (!TITLE_KINDS.has(title.kind)) {
828
+ add([...base, 'titleField'], `titleField "${entity.titleField}" must be a text-like field, not ${title.kind}`);
829
+ }
830
+ if (entity.ownerField !== undefined && fields.get(entity.ownerField)?.kind !== 'persona') {
831
+ add([...base, 'ownerField'], `ownerField "${entity.ownerField}" must be a persona field of "${entity.name}"`);
832
+ }
833
+ for (const key of ['searchable', 'indexes']) {
834
+ entity[key].forEach((ref, i) => {
835
+ if (!fields.has(ref)) {
836
+ add([...base, key, i], `unknown field "${ref}"`);
837
+ }
838
+ else if (UNSORTABLE_KINDS.has(fields.get(ref)?.kind)) {
839
+ add([...base, key, i], `${fields.get(ref)?.kind} field "${ref}" cannot be ${key === 'indexes' ? 'indexed' : 'searched'}`);
840
+ }
841
+ });
842
+ }
843
+ if (entity.defaultSort && !fields.has(entity.defaultSort.field)) {
844
+ add([...base, 'defaultSort', 'field'], `unknown field "${entity.defaultSort.field}"`);
845
+ }
846
+ else if (entity.defaultSort &&
847
+ UNSORTABLE_KINDS.has(fields.get(entity.defaultSort.field)?.kind)) {
848
+ add([...base, 'defaultSort', 'field'], `${fields.get(entity.defaultSort.field)?.kind} field "${entity.defaultSort.field}" cannot sort a list`);
849
+ }
850
+ if (entity.features.files && !entity.fields.some((f) => f.kind === 'file')) {
851
+ add([...base, 'features', 'files'], `features.files needs at least one file field on "${entity.name}"`);
852
+ }
853
+ }
854
+ function checkEntityScreenDefaults(entity, at, entities, add) {
855
+ const defaults = entity.screenDefaults;
856
+ if (!defaults)
857
+ return;
858
+ const base = ['entities', at, 'screenDefaults'];
859
+ defaults.list?.columns?.forEach((field, i) => {
860
+ if (!entity.fields.some((candidate) => candidate.name === field)) {
861
+ add([...base, 'list', 'columns', i], `unknown field "${field}" on "${entity.name}"`);
862
+ }
863
+ });
864
+ defaults.detail?.tabs?.forEach((tab, i) => {
865
+ const path = [...base, 'detail', 'tabs', i];
866
+ if ((tab === 'activity' || tab === 'audit') && !entity.features.audit) {
867
+ add(path, `the ${tab} tab needs features.audit on "${entity.name}"`);
868
+ }
869
+ else if (tab === 'files' && !entity.features.files) {
870
+ add(path, `the files tab needs features.files on "${entity.name}"`);
871
+ }
872
+ else if (tab.startsWith('related:')) {
873
+ const targetName = tab.slice('related:'.length);
874
+ const target = entities.get(targetName);
875
+ if (!target) {
876
+ add(path, `unknown entity "${targetName}"`);
877
+ }
878
+ else if (!target.fields.some((field) => field.kind === 'relation' && field.to === entity.name)) {
879
+ add(path, `"${target.name}" has no relation field to "${entity.name}"`);
880
+ }
881
+ }
882
+ });
883
+ for (const tab of duplicates(defaults.detail?.tabs ?? [])) {
884
+ add([...base, 'detail', 'tabs'], `duplicate tab "${tab}"`);
885
+ }
886
+ }
887
+ /** Cross-reference checks that a JSON shape cannot express. */
888
+ export function blueprintIssues(blueprint) {
889
+ const issues = [];
890
+ const add = (path, message) => {
891
+ issues.push({ path, message });
892
+ };
893
+ const entities = new Map(blueprint.entities.map((e) => [e.name, e]));
894
+ const names = new Set(entities.keys());
895
+ for (const [key, values] of [
896
+ ['name', blueprint.entities.map((e) => e.name)],
897
+ ['plural', blueprint.entities.map((e) => e.plural)],
898
+ ['table', blueprint.entities.map(entityTable)],
899
+ ]) {
900
+ const dupes = duplicates(values);
901
+ values.forEach((value, i) => {
902
+ if (dupes.has(value)) {
903
+ add(['entities', i, key], `duplicate entity ${key} "${value}"`);
904
+ }
905
+ });
906
+ }
907
+ blueprint.entities.forEach((entity, i) => {
908
+ checkEntity(entity, i, names, add);
909
+ checkEntityScreenDefaults(entity, i, entities, add);
910
+ });
911
+ if (blueprint.validation)
912
+ validationIssues(blueprint.validation, entities, add);
913
+ if (blueprint.archetype) {
914
+ for (const [slot, entityName] of Object.entries(blueprint.archetype.entities)) {
915
+ if (!entities.has(entityName)) {
916
+ add(['archetype', 'entities', slot], `archetype entity slot "${slot}" references unknown entity "${entityName}"`);
917
+ }
918
+ }
919
+ }
920
+ blueprint.notifications.forEach((rule, i) => {
921
+ const path = ['notifications', i];
922
+ const entity = entities.get(rule.entity);
923
+ if (!entity) {
924
+ add([...path, 'entity'], `unknown entity "${rule.entity}"`);
925
+ return;
926
+ }
927
+ const fields = new Map(entity.fields.map((field) => [field.name, field]));
928
+ if (rule.on === 'assign' && rule.field && fields.get(rule.field)?.kind !== 'persona') {
929
+ add([...path, 'field'], `assign field "${rule.field}" must be a persona field of "${entity.name}"`);
930
+ }
931
+ if (rule.notify === 'owner' &&
932
+ (!entity.ownerField || fields.get(entity.ownerField)?.kind !== 'persona')) {
933
+ add([...path, 'notify'], `notify "owner" needs an ownerField on "${entity.name}"`);
934
+ }
935
+ if (rule.notify === 'assignee' &&
936
+ !entity.fields.some((field) => field.name === 'assignee' && field.kind === 'persona')) {
937
+ add([...path, 'notify'], `notify "assignee" needs an assignee persona field on "${entity.name}"`);
938
+ }
939
+ if (rule.notify.startsWith('role:')) {
940
+ const roleName = rule.notify.slice('role:'.length);
941
+ if (!blueprint.roles.some((role) => role.name === roleName)) {
942
+ add([...path, 'notify'], `unknown notification role "${roleName}"`);
943
+ }
944
+ }
945
+ for (const match of rule.title.matchAll(/\{([^{}]+)\}/g)) {
946
+ const field = fields.get(match[1] ?? '');
947
+ if (!field) {
948
+ add([...path, 'title'], `unknown title placeholder "${match[1]}" on "${entity.name}"`);
949
+ }
950
+ else if (field.sensitive) {
951
+ add([...path, 'title'], `sensitive field "${field.name}" cannot be used in a notification title`);
952
+ }
953
+ }
954
+ });
955
+ const roleDupes = duplicates(blueprint.roles.map((r) => r.name));
956
+ blueprint.roles.forEach((role, i) => {
957
+ if (roleDupes.has(role.name)) {
958
+ add(['roles', i, 'name'], `duplicate role "${role.name}"`);
959
+ }
960
+ for (const [target, actions] of Object.entries(role.permissions)) {
961
+ const path = ['roles', i, 'permissions', target];
962
+ const entity = entities.get(target);
963
+ if (target !== '*' && !entity) {
964
+ add(path, `unknown entity "${target}"`);
965
+ continue;
966
+ }
967
+ const own = actions.filter((a) => a.endsWith(':own'));
968
+ if (own.length > 0) {
969
+ const lacking = target === '*'
970
+ ? blueprint.entities.filter((e) => !e.ownerField)
971
+ : entity && !entity.ownerField
972
+ ? [entity]
973
+ : [];
974
+ for (const e of lacking) {
975
+ add(path, `"${own.join('", "')}" needs an ownerField on entity "${e.name}"`);
976
+ }
977
+ }
978
+ }
979
+ });
980
+ checkScreens(blueprint, add);
981
+ const explicitViews = new Set(blueprint.screens.flatMap((screen) => 'entity' in screen ? [`${screen.entity}:${screen.type}`] : []));
982
+ const explicitPaths = new Set([
983
+ ...blueprint.screens.map((screen) => screenPath(screen, blueprint.entities)),
984
+ ...(blueprint.archetype?.screens.map((screen) => screen.path) ?? []),
985
+ ].filter(Boolean));
986
+ blueprint.entities.forEach((entity, entityIndex) => {
987
+ entity.screenDefaults?.views.forEach((view, viewIndex) => {
988
+ if (explicitViews.has(`${entity.name}:${view}`))
989
+ return;
990
+ const path = view === 'list'
991
+ ? `/${entity.plural}`
992
+ : view === 'detail'
993
+ ? `/${entity.plural}/:id`
994
+ : `/${entity.plural}/new`;
995
+ if (explicitPaths.has(path)) {
996
+ add(['entities', entityIndex, 'screenDefaults', 'views', viewIndex], `path "${path}" is already used by a top-level screen`);
997
+ }
998
+ });
999
+ });
1000
+ return issues;
1001
+ }
1002
+ /** The managed timestamps are valid date fields for buckets and analytics. */
1003
+ const MANAGED_DATE_FIELDS = new Set(['createdAt', 'updatedAt']);
1004
+ const DATE_KINDS = new Set(['date', 'datetime']);
1005
+ const GROUP_KINDS = new Set(['enum', 'relation', 'persona', 'boolean']);
1006
+ const MIN_MAX_KINDS = new Set([...SUM_KINDS, ...DATE_KINDS]);
1007
+ /** Field kinds a map screen can plot. */
1008
+ const GEO_KINDS = new Set(['geo']);
1009
+ /** `a number, integer or money` / `an enum`: the kinds as an English list with its article. */
1010
+ function kindList(kinds) {
1011
+ const list = [...kinds];
1012
+ const text = list.length === 1 ? list[0] : `${list.slice(0, -1).join(', ')} or ${list.at(-1)}`;
1013
+ return `${/^[aeiou]/.test(text) ? 'an' : 'a'} ${text}`;
1014
+ }
1015
+ /** Cross-reference checks for screens, dashboard widgets and routes. */
1016
+ function checkScreens(blueprint, add) {
1017
+ const entities = new Map(blueprint.entities.map((e) => [e.name, e]));
1018
+ /** Looks up `name` on `entity` and reports it unless its kind is in `kinds`. */
1019
+ const expectField = (entity, name, kinds, path, what, managedDates = false) => {
1020
+ if (managedDates && MANAGED_DATE_FIELDS.has(name))
1021
+ return undefined;
1022
+ const field = entity.fields.find((f) => f.name === name);
1023
+ if (!field) {
1024
+ add(path, `unknown field "${name}" on "${entity.name}"`);
1025
+ }
1026
+ else if (kinds && !kinds.has(field.kind)) {
1027
+ const managed = managedDates ? ' (or createdAt / updatedAt)' : '';
1028
+ add(path, `${what} "${name}" must be ${kindList(kinds)} field of "${entity.name}"${managed}`);
1029
+ }
1030
+ return field;
1031
+ };
1032
+ const checkMeasure = (entity, measure, path) => {
1033
+ if (measure.aggregate === 'count') {
1034
+ if (measure.field !== undefined)
1035
+ add([...path, 'field'], 'count does not take a field');
1036
+ return;
1037
+ }
1038
+ const kinds = measure.aggregate === 'min' || measure.aggregate === 'max' ? MIN_MAX_KINDS : SUM_KINDS;
1039
+ const field = entity.fields.find((f) => f.name === measure.field);
1040
+ if (!field || !kinds.has(field.kind)) {
1041
+ add([...path, 'field'], `${measure.aggregate} needs ${kindList(kinds)} field`);
1042
+ }
1043
+ };
1044
+ const checkFilter = (entity, filter, path) => {
1045
+ const field = expectField(entity, filter.field, null, [...path, 'field'], 'filter field');
1046
+ if ((filter.op === 'in') !== Array.isArray(filter.value)) {
1047
+ add([...path, 'value'], filter.op === 'in'
1048
+ ? 'op "in" needs an array value'
1049
+ : `op "${filter.op}" needs a single value`);
1050
+ }
1051
+ if (field?.kind === 'enum') {
1052
+ const values = Array.isArray(filter.value) ? filter.value : [filter.value];
1053
+ for (const value of values) {
1054
+ if (!field.values.includes(String(value))) {
1055
+ add([...path, 'value'], `"${value}" is not one of ${field.values.join(', ')}`);
1056
+ }
1057
+ }
1058
+ }
1059
+ };
1060
+ const seen = new Map();
1061
+ blueprint.screens.forEach((screen, i) => {
1062
+ const path = ['screens', i];
1063
+ const route = screenPath(screen, blueprint.entities);
1064
+ if (route !== null) {
1065
+ if (RESERVED_SCREEN_PATHS.includes(route)) {
1066
+ add([...path, 'path'], `path "${route}" is reserved by the generated app`);
1067
+ }
1068
+ const first = seen.get(route);
1069
+ if (first !== undefined) {
1070
+ add([...path, 'path'], `path "${route}" is already used by screens.${first}`);
1071
+ }
1072
+ else {
1073
+ seen.set(route, i);
1074
+ }
1075
+ }
1076
+ if (screen.type === 'settings') {
1077
+ if (screen.sections.includes('notifications') &&
1078
+ !blueprint.entities.some((e) => e.features.notifications)) {
1079
+ add([...path, 'sections'], 'the notifications section needs an entity with features.notifications');
1080
+ }
1081
+ for (const section of duplicates(screen.sections)) {
1082
+ add([...path, 'sections'], `duplicate section "${section}"`);
1083
+ }
1084
+ return;
1085
+ }
1086
+ if (screen.type === 'dashboard') {
1087
+ const legacy = screen.metrics ?? [];
1088
+ dashboardWidgets(screen).forEach((widget, n) => {
1089
+ const at = n < legacy.length ? [...path, 'metrics', n] : [...path, 'widgets', n - legacy.length];
1090
+ const entity = entities.get(widget.entity);
1091
+ if (!entity) {
1092
+ add([...at, 'entity'], `unknown entity "${widget.entity}"`);
1093
+ return;
1094
+ }
1095
+ if (widget.filter)
1096
+ checkFilter(entity, widget.filter, [...at, 'filter']);
1097
+ if (widget.kind === 'kpi' || widget.kind === 'chart')
1098
+ checkMeasure(entity, widget, at);
1099
+ if (widget.kind === 'list' && widget.sort) {
1100
+ expectField(entity, widget.sort.field, null, [...at, 'sort', 'field'], 'sort field');
1101
+ }
1102
+ if (widget.kind === 'board-summary') {
1103
+ expectField(entity, widget.groupBy, new Set(['enum']), [...at, 'groupBy'], 'groupBy');
1104
+ }
1105
+ if (widget.kind === 'chart') {
1106
+ if (widget.groupBy !== undefined) {
1107
+ expectField(entity, widget.groupBy, GROUP_KINDS, [...at, 'groupBy'], 'groupBy');
1108
+ }
1109
+ if (widget.bucket) {
1110
+ expectField(entity, widget.bucket.field, DATE_KINDS, [...at, 'bucket', 'field'], 'bucket field', true);
1111
+ }
1112
+ if (widget.chart === 'donut' && (!widget.groupBy || widget.bucket)) {
1113
+ add([...at, 'chart'], 'a donut chart needs groupBy and no bucket');
1114
+ }
1115
+ if ((widget.chart === 'line' || widget.chart === 'area') && !widget.bucket) {
1116
+ add([...at, 'bucket'], `a ${widget.chart} chart needs a date bucket`);
1117
+ }
1118
+ if (widget.chart === 'bar' && !widget.groupBy && !widget.bucket) {
1119
+ add([...at, 'chart'], 'a bar chart needs groupBy or a date bucket');
1120
+ }
1121
+ }
1122
+ });
1123
+ return;
1124
+ }
1125
+ const entity = entities.get(screen.entity);
1126
+ if (!entity) {
1127
+ add([...path, 'entity'], `unknown entity "${screen.entity}"`);
1128
+ return;
1129
+ }
1130
+ const fields = new Map(entity.fields.map((f) => [f.name, f]));
1131
+ const enumOnly = new Set(['enum']);
1132
+ switch (screen.type) {
1133
+ case 'list':
1134
+ screen.columns?.forEach((column, j) => {
1135
+ if (!fields.has(column))
1136
+ add([...path, 'columns', j], `unknown field "${column}"`);
1137
+ });
1138
+ break;
1139
+ case 'detail':
1140
+ for (const tab of duplicates(screen.tabs ?? [])) {
1141
+ add([...path, 'tabs'], `duplicate tab "${tab}"`);
1142
+ }
1143
+ screen.tabs?.forEach((tab, j) => {
1144
+ const at = [...path, 'tabs', j];
1145
+ if ((tab === 'activity' || tab === 'audit') && !entity.features.audit) {
1146
+ add(at, `the ${tab} tab needs features.audit on "${entity.name}"`);
1147
+ }
1148
+ else if (tab === 'files' && !entity.features.files) {
1149
+ add(at, `the files tab needs features.files on "${entity.name}"`);
1150
+ }
1151
+ else if (tab.startsWith('related:')) {
1152
+ const other = entities.get(tab.slice('related:'.length));
1153
+ if (!other) {
1154
+ add(at, `unknown entity "${tab.slice('related:'.length)}"`);
1155
+ }
1156
+ else if (!other.fields.some((f) => f.kind === 'relation' && f.to === entity.name)) {
1157
+ add(at, `"${other.name}" has no relation field to "${entity.name}"`);
1158
+ }
1159
+ }
1160
+ });
1161
+ break;
1162
+ case 'board':
1163
+ if (fields.get(screen.groupBy)?.kind !== 'enum') {
1164
+ add([...path, 'groupBy'], `groupBy "${screen.groupBy}" must be an enum field of "${entity.name}"`);
1165
+ }
1166
+ screen.cardFields?.forEach((name, j) => {
1167
+ expectField(entity, name, null, [...path, 'cardFields', j], 'card field');
1168
+ });
1169
+ if (screen.sumField !== undefined) {
1170
+ expectField(entity, screen.sumField, SUM_KINDS, [...path, 'sumField'], 'sumField');
1171
+ }
1172
+ break;
1173
+ case 'calendar':
1174
+ expectField(entity, screen.dateField, DATE_KINDS, [...path, 'dateField'], 'dateField');
1175
+ if (screen.endField !== undefined) {
1176
+ expectField(entity, screen.endField, DATE_KINDS, [...path, 'endField'], 'endField');
1177
+ if (screen.endField === screen.dateField) {
1178
+ add([...path, 'endField'], 'endField must differ from dateField');
1179
+ }
1180
+ }
1181
+ if (screen.colorBy !== undefined) {
1182
+ expectField(entity, screen.colorBy, enumOnly, [...path, 'colorBy'], 'colorBy');
1183
+ }
1184
+ break;
1185
+ case 'timeline':
1186
+ expectField(entity, screen.startField, DATE_KINDS, [...path, 'startField'], 'startField');
1187
+ expectField(entity, screen.endField, DATE_KINDS, [...path, 'endField'], 'endField');
1188
+ if (screen.endField === screen.startField) {
1189
+ add([...path, 'endField'], 'endField must differ from startField');
1190
+ }
1191
+ if (screen.groupBy !== undefined) {
1192
+ expectField(entity, screen.groupBy, new Set(['enum', 'relation', 'persona']), [...path, 'groupBy'], 'groupBy');
1193
+ }
1194
+ break;
1195
+ case 'map':
1196
+ expectField(entity, screen.geoField, GEO_KINDS, [...path, 'geoField'], 'geoField');
1197
+ if (screen.colorBy !== undefined) {
1198
+ expectField(entity, screen.colorBy, enumOnly, [...path, 'colorBy'], 'colorBy');
1199
+ }
1200
+ break;
1201
+ case 'analytics':
1202
+ expectField(entity, screen.dateField, DATE_KINDS, [...path, 'dateField'], 'dateField', true);
1203
+ screen.measures.forEach((measure, j) => {
1204
+ checkMeasure(entity, measure, [...path, 'measures', j]);
1205
+ });
1206
+ screen.dimensions.forEach((name, j) => {
1207
+ expectField(entity, name, GROUP_KINDS, [...path, 'dimensions', j], 'dimension');
1208
+ });
1209
+ break;
1210
+ case 'form':
1211
+ break;
1212
+ case 'sheet':
1213
+ sheetScreenIssues(entity, screen, path, add);
1214
+ break;
1215
+ case 'canvas':
1216
+ canvasScreenIssues(entity, screen, path, add);
1217
+ break;
1218
+ }
1219
+ });
1220
+ blueprint.archetype?.screens.forEach((screen, i) => {
1221
+ const path = ['archetype', 'screens', i, 'path'];
1222
+ if (RESERVED_SCREEN_PATHS.includes(screen.path)) {
1223
+ add(path, `path "${screen.path}" is reserved by the generated app`);
1224
+ }
1225
+ const first = seen.get(screen.path);
1226
+ if (first !== undefined) {
1227
+ add(path, `path "${screen.path}" is already used by screens.${first}`);
1228
+ }
1229
+ else {
1230
+ seen.set(screen.path, blueprint.screens.length + i);
1231
+ }
1232
+ });
1233
+ }
1234
+ /**
1235
+ * The parsed app blueprint: an explicit `kind: "app"` is dropped (a missing
1236
+ * kind means app), then entity CRUD defaults are materialized.
1237
+ */
1238
+ function withoutAppKind(blueprint) {
1239
+ const { kind: _kind, ...app } = withEntityScreenDefaults(blueprint);
1240
+ return app;
1241
+ }
1242
+ /** Materializes entity CRUD defaults into ordinary screens for every consumer. */
1243
+ function withEntityScreenDefaults(blueprint) {
1244
+ const screens = [...blueprint.screens];
1245
+ const explicitViews = new Set(screens.flatMap((screen) => ('entity' in screen ? [`${screen.entity}:${screen.type}`] : [])));
1246
+ for (const entity of blueprint.entities) {
1247
+ const defaults = entity.screenDefaults;
1248
+ if (!defaults)
1249
+ continue;
1250
+ for (const view of defaults.views) {
1251
+ if (explicitViews.has(`${entity.name}:${view}`))
1252
+ continue;
1253
+ const config = view === 'list' ? defaults.list : view === 'detail' ? defaults.detail : undefined;
1254
+ screens.push(ScreenSchema.parse({ type: view, entity: entity.name, ...config }));
1255
+ }
1256
+ }
1257
+ return { ...blueprint, screens };
1258
+ }
1259
+ export const BlueprintSchema = BlueprintShape.superRefine((blueprint, ctx) => {
1260
+ for (const issue of blueprintIssues(blueprint)) {
1261
+ ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message });
1262
+ }
1263
+ }).transform(withoutAppKind);
1264
+ export function parseBlueprint(input) {
1265
+ return BlueprintSchema.parse(input);
1266
+ }
1267
+ export function safeParseBlueprint(input) {
1268
+ return BlueprintSchema.safeParse(input);
1269
+ }