@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/files.js ADDED
@@ -0,0 +1,246 @@
1
+ import { createRuntimeId, parseInput, ValidationError, } from '@protoyard/core';
2
+ import { z } from 'zod';
3
+ import { entityTable, } from "./schema.js";
4
+ import { FEATURE_TABLES, relationProperty } from "./storage.js";
5
+ import { fieldLabel } from "./validation.js";
6
+ export const DEFAULT_FILE_QUOTA_BYTES = 50 * 1024 * 1024;
7
+ const UploadInputSchema = z.strictObject({
8
+ entity: z.string().min(1),
9
+ recordId: z.string().min(1),
10
+ field: z.string().min(1),
11
+ name: z.string().min(1).max(255).optional(),
12
+ });
13
+ export class FileUploadError extends Error {
14
+ code;
15
+ constructor(code, message) {
16
+ super(message);
17
+ this.name = 'FileUploadError';
18
+ this.code = code;
19
+ }
20
+ }
21
+ function asMetadata(row) {
22
+ const { blob: _blob, ...metadata } = row;
23
+ return metadata;
24
+ }
25
+ function accepts(field, type) {
26
+ if (field.accept.length === 0)
27
+ return true;
28
+ const mime = type.toLowerCase();
29
+ return field.accept.some((pattern) => {
30
+ const accepted = pattern.toLowerCase();
31
+ return accepted.endsWith('/*') ? mime.startsWith(accepted.slice(0, -1)) : mime === accepted;
32
+ });
33
+ }
34
+ function assertUpdateAllowed(runtime, entity, row) {
35
+ const capability = `${entity.name}:update`;
36
+ const owner = entity.ownerField
37
+ ? entity.fields.find((field) => field.name === entity.ownerField)
38
+ : undefined;
39
+ const ownerId = owner ? row[relationProperty(owner) ?? owner.name] : undefined;
40
+ if (runtime.session.can(capability, row) ||
41
+ (typeof ownerId === 'string' && runtime.session.can(`${entity.name}:update:own`, row))) {
42
+ return;
43
+ }
44
+ runtime.session.assert(capability, row);
45
+ }
46
+ function entityMap(blueprint) {
47
+ return new Map(blueprint.entities.map((entity) => [entity.name, entity]));
48
+ }
49
+ export function createFileServices(runtime, blueprint, options = {}) {
50
+ const db = runtime.db;
51
+ const files = db.table(FEATURE_TABLES.files);
52
+ const entities = entityMap(blueprint);
53
+ const quotaLimit = parseInput(z.number().int().nonnegative(), options.prototypeQuotaBytes ?? DEFAULT_FILE_QUOTA_BYTES);
54
+ function fileField(entity, fieldName) {
55
+ const field = entity.fields.find((candidate) => candidate.name === fieldName);
56
+ if (field?.kind !== 'file') {
57
+ throw new ValidationError({ [fieldName]: 'Choose a file field on this entity' });
58
+ }
59
+ return field;
60
+ }
61
+ function getEntity(entityName) {
62
+ const entity = entities.get(entityName);
63
+ if (!entity)
64
+ throw new FileUploadError('not-found', `No blueprint entity named "${entityName}".`);
65
+ if (!entity.features.files) {
66
+ throw new FileUploadError('files-disabled', `File uploads are disabled for "${entityName}".`);
67
+ }
68
+ return entity;
69
+ }
70
+ function actorId() {
71
+ const id = runtime.session.principal()?.persona.id;
72
+ if (!id)
73
+ throw new Error('No active persona. Load a scenario first.');
74
+ return id;
75
+ }
76
+ async function assertQuota(size) {
77
+ const stored = await files.toArray();
78
+ const prototypeUsage = stored.reduce((total, row) => {
79
+ const rowSize = row.size;
80
+ if (typeof rowSize !== 'number' || !Number.isSafeInteger(rowSize) || rowSize < 0) {
81
+ throw new FileUploadError('quota-exceeded', 'Stored file metadata exceeds the available quota.');
82
+ }
83
+ return total + rowSize;
84
+ }, 0);
85
+ const storage = runtime.storage();
86
+ const browserRemaining = storage.quotaBytes === undefined || storage.usageBytes === undefined
87
+ ? Number.POSITIVE_INFINITY
88
+ : Math.max(0, storage.quotaBytes - storage.usageBytes);
89
+ const limit = Math.min(quotaLimit, storage.quotaBytes ?? quotaLimit);
90
+ if (prototypeUsage + size > limit || size > browserRemaining) {
91
+ throw new FileUploadError('quota-exceeded', 'This prototype does not have enough storage quota for that file.');
92
+ }
93
+ }
94
+ async function record(entity, recordId) {
95
+ const row = await db.table(entityTable(entity)).get(recordId);
96
+ if (!row || row.deletedAt)
97
+ throw new FileUploadError('not-found', `No ${entity.name} with id "${recordId}".`);
98
+ assertUpdateAllowed(runtime, entity, row);
99
+ return row;
100
+ }
101
+ async function readRecord(entity, recordId) {
102
+ const row = await db.table(entityTable(entity)).get(recordId);
103
+ if (!row || row.deletedAt)
104
+ throw new FileUploadError('not-found', `No ${entity.name} with id "${recordId}".`);
105
+ runtime.session.assert(`${entity.name}:read`, row);
106
+ return row;
107
+ }
108
+ const serviceFor = (entity) => ({
109
+ async upload(input, onProgress) {
110
+ if (!entity.features.files) {
111
+ throw new FileUploadError('files-disabled', `File uploads are disabled for "${entity.name}".`);
112
+ }
113
+ const recordInput = typeof input === 'object' && input !== null ? input : {};
114
+ const { file: candidate, ...metadataInput } = recordInput;
115
+ if (typeof recordInput.recordId !== 'string') {
116
+ throw new ValidationError({ recordId: 'Choose a record' });
117
+ }
118
+ const current = await record(entity, recordInput.recordId);
119
+ const data = parseInput(UploadInputSchema, metadataInput);
120
+ if (typeof Blob === 'undefined' || !(candidate instanceof Blob)) {
121
+ throw new ValidationError({ file: 'Choose a file to upload' });
122
+ }
123
+ if (data.entity !== entity.name) {
124
+ throw new ValidationError({ entity: `Use the ${entity.name} file service` });
125
+ }
126
+ const field = fileField(entity, data.field);
127
+ const file = candidate;
128
+ const name = data.name ?? file.name ?? 'upload';
129
+ if (file.size > field.maxSizeBytes) {
130
+ throw new FileUploadError('size-limit', `${fieldLabel(field)} must be at most ${field.maxSizeBytes} bytes.`);
131
+ }
132
+ if (!accepts(field, file.type)) {
133
+ throw new FileUploadError('type-not-allowed', `${fieldLabel(field)} does not accept ${file.type || 'an unknown file type'}.`);
134
+ }
135
+ await assertQuota(file.size);
136
+ const id = createRuntimeId('file');
137
+ const ownerId = actorId();
138
+ const property = relationProperty(field);
139
+ const oldIds = field.multiple
140
+ ? (current[property] ?? [])
141
+ : typeof current[property] === 'string'
142
+ ? [current[property]]
143
+ : [];
144
+ const nextIds = field.multiple ? [...new Set([...oldIds, id])] : [id];
145
+ const metadata = {
146
+ id,
147
+ name,
148
+ type: file.type,
149
+ size: file.size,
150
+ ownerId,
151
+ entity: entity.name,
152
+ recordId: data.recordId,
153
+ createdAt: runtime.clock.now().toISOString(),
154
+ };
155
+ onProgress?.({ loaded: 0, total: file.size, percent: 0 });
156
+ return runtime.environment.run('files.upload', async () => {
157
+ onProgress?.({ loaded: Math.floor(file.size / 2), total: file.size, percent: 50 });
158
+ // Keep binary bytes in IndexedDB; the service reconstructs a Blob on demand.
159
+ const storedBytes = await file.arrayBuffer();
160
+ await db.transaction('rw', [entityTable(entity), FEATURE_TABLES.files, 'py_personas'], async () => {
161
+ await record(entity, data.recordId);
162
+ await assertQuota(file.size);
163
+ if (!field.multiple) {
164
+ for (const oldId of oldIds)
165
+ await files.delete(oldId);
166
+ }
167
+ await files.put({ ...metadata, blob: storedBytes });
168
+ await db.table(entityTable(entity)).update(data.recordId, {
169
+ [property]: field.multiple ? nextIds : id,
170
+ updatedAt: runtime.clock.now().toISOString(),
171
+ });
172
+ });
173
+ onProgress?.({ loaded: file.size, total: file.size, percent: 100 });
174
+ return metadata;
175
+ });
176
+ },
177
+ async list(entityName, recordId, fieldName) {
178
+ const targetEntity = getEntity(entityName);
179
+ if (targetEntity.name !== entity.name)
180
+ throw new ValidationError({ entity: 'Use the matching file service' });
181
+ const row = await readRecord(targetEntity, recordId);
182
+ const selectedFields = targetEntity.fields.filter((field) => field.kind === 'file' && (!fieldName || field.name === fieldName));
183
+ const ids = new Set(selectedFields.flatMap((field) => {
184
+ const value = row[relationProperty(field)];
185
+ return Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
186
+ }));
187
+ const all = await files.toArray();
188
+ return all.filter((file) => ids.has(file.id)).map(asMetadata);
189
+ },
190
+ async get(id) {
191
+ if (!entity.features.files) {
192
+ throw new FileUploadError('files-disabled', `File uploads are disabled for "${entity.name}".`);
193
+ }
194
+ const file = await files.get(id);
195
+ if (!file || file.entity !== entity.name)
196
+ return undefined;
197
+ const target = getEntity(entity.name);
198
+ await readRecord(target, file.recordId);
199
+ const storedBlob = file.blob;
200
+ let blob;
201
+ if (typeof Blob !== 'undefined' && storedBlob instanceof Blob) {
202
+ blob = storedBlob;
203
+ }
204
+ else if (storedBlob instanceof ArrayBuffer) {
205
+ blob = new Blob([storedBlob], { type: file.type });
206
+ }
207
+ else {
208
+ throw new FileUploadError('data-unavailable', 'This attachment has no binary data. File contents are not preserved in JSON snapshots.');
209
+ }
210
+ return { ...file, blob };
211
+ },
212
+ async remove(id) {
213
+ if (!entity.features.files) {
214
+ throw new FileUploadError('files-disabled', `File uploads are disabled for "${entity.name}".`);
215
+ }
216
+ const file = await files.get(id);
217
+ if (!file || file.entity !== entity.name)
218
+ return false;
219
+ const target = getEntity(entity.name);
220
+ const row = await record(target, file.recordId);
221
+ const matchingField = target.fields.find((candidate) => candidate.kind === 'file' &&
222
+ (Array.isArray(row[relationProperty(candidate)])
223
+ ? row[relationProperty(candidate)].includes(id)
224
+ : row[relationProperty(candidate)] === id));
225
+ if (!matchingField)
226
+ return false;
227
+ const property = relationProperty(matchingField);
228
+ const fileFieldSchema = matchingField;
229
+ return runtime.environment.run('files.remove', () => db.transaction('rw', [entityTable(target), FEATURE_TABLES.files], async () => {
230
+ const latest = await record(target, file.recordId);
231
+ const value = latest[property];
232
+ await files.delete(id);
233
+ await db.table(entityTable(target)).update(file.recordId, {
234
+ [property]: fileFieldSchema?.kind === 'file' && fileFieldSchema.multiple
235
+ ? Array.isArray(value)
236
+ ? value.filter((fileId) => fileId !== id)
237
+ : []
238
+ : null,
239
+ updatedAt: runtime.clock.now().toISOString(),
240
+ });
241
+ return true;
242
+ }));
243
+ },
244
+ });
245
+ return Object.fromEntries(blueprint.entities.map((entity) => [entity.name, serviceFor(entity)]));
246
+ }
@@ -0,0 +1,124 @@
1
+ import type { BlueprintEntity, BlueprintField, FieldKind } from './schema.ts';
2
+ import { type EntityInput } from './validation.ts';
3
+ /**
4
+ * Blueprint fields → a `@texuli/forms` schema (plain data, no Texuli import
5
+ * at runtime). `@protoyard/crud` paints entity forms with Texuli's
6
+ * `SchemaForm` from this mapping; the entity services stay the source of
7
+ * truth for validation (session.assert → zod → environment.run →
8
+ * db.transaction), so the schema only carries the rules that agree with the
9
+ * service: required, length and number bounds, whole-number and cent steps,
10
+ * and the tag count.
11
+ *
12
+ * Kinds Texuli has no control for (a paged relation picker, map points, the
13
+ * face library, uploads) are *hosted*: they are Texuli `custom` fields (G113)
14
+ * whose `kind` is the blueprint kind, so the host paints them with its own
15
+ * controls inside the same form, and Texuli labels, validates and submits
16
+ * them with the rest.
17
+ */
18
+ /** The `@texuli/forms` field types this mapping emits. */
19
+ export type EntityFormFieldType = 'text' | 'textarea' | 'number' | 'email' | 'url' | 'date' | 'datetime' | 'select' | 'checkbox' | 'tags' | 'custom';
20
+ /** One choice of a `select` field (`@texuli/forms` `FormOption`). */
21
+ export interface EntityFormOption {
22
+ readonly value: string;
23
+ readonly label: string;
24
+ }
25
+ /**
26
+ * One field of the emitted schema: the subset of `@texuli/forms` `FormField`
27
+ * this mapping uses (structurally assignable to it).
28
+ */
29
+ export interface EntityFormField {
30
+ readonly name: string;
31
+ readonly type: EntityFormFieldType;
32
+ readonly label: string;
33
+ readonly description?: string;
34
+ readonly placeholder?: string;
35
+ readonly required?: boolean;
36
+ readonly options?: readonly EntityFormOption[];
37
+ readonly minLength?: number;
38
+ readonly maxLength?: number;
39
+ readonly min?: number;
40
+ readonly max?: number;
41
+ readonly step?: number;
42
+ readonly maxItems?: number;
43
+ readonly rows?: number;
44
+ /** Url fields: the schemes the service accepts. */
45
+ readonly protocols?: readonly ('http' | 'https')[];
46
+ /** Custom fields: the blueprint field kind the host paints. */
47
+ readonly kind?: string;
48
+ /** Conditions; this mapping only emits a constant `enable` (a read-only form). */
49
+ readonly when?: {
50
+ readonly enable: {
51
+ readonly op: 'bool';
52
+ readonly value: boolean;
53
+ };
54
+ };
55
+ }
56
+ /** A one-page `@texuli/forms` `FormSchema` (structurally assignable to it). */
57
+ export interface EntityFormSchema {
58
+ readonly title: string;
59
+ readonly submitLabel?: string;
60
+ readonly fields: readonly EntityFormField[];
61
+ }
62
+ /**
63
+ * Field kinds the host paints itself (no matching `@texuli/forms` control),
64
+ * as Texuli `custom` fields. Persona fields without people to pick and enums
65
+ * past Texuli's option cap are hosted too (see `ENTITY_FORM_CUSTOM_KINDS`).
66
+ */
67
+ export declare const HOSTED_FORM_KINDS: ReadonlySet<FieldKind>;
68
+ /**
69
+ * Every `kind` a hosted (`custom`) field of this mapping can carry. Pass it
70
+ * as `validateFormSchema(schema, { kinds })` so Texuli checks them.
71
+ */
72
+ export declare const ENTITY_FORM_CUSTOM_KINDS: readonly FieldKind[];
73
+ /**
74
+ * @deprecated No longer emitted. Since Texuli 0.10.0 an optional persona
75
+ * select offers its own empty "None" choice (G118), so the mapping adds no
76
+ * sentinel option. `fromFormValues` still reads it as unassigned.
77
+ */
78
+ export declare const UNASSIGNED_OPTION = "~unassigned";
79
+ export interface EntityFormSchemaOptions {
80
+ /** Create or edit (the default title and submit label follow it). Default `create`. */
81
+ mode?: 'create' | 'edit';
82
+ /** Form title (Texuli forms always have one). Default `New <entity>` / `Edit <entity>`. */
83
+ title?: string;
84
+ submitLabel?: string;
85
+ /**
86
+ * Paint every field disabled (`when.enable` is never true). Disabled
87
+ * fields are neither validated nor submitted by Texuli. A host that only
88
+ * needs a disabled form can pass SchemaForm's own `disabled` (G116).
89
+ */
90
+ readOnly?: boolean;
91
+ /**
92
+ * The people a persona field can pick. Without them persona fields are
93
+ * hosted (the host paints its own picker).
94
+ */
95
+ personaOptions?: readonly EntityFormOption[];
96
+ }
97
+ export interface EntityFormModel {
98
+ /** The schema to pass to `validateFormSchema` from `@texuli/forms`. */
99
+ readonly schema: EntityFormSchema;
100
+ /** Fields Texuli paints with its own controls, in blueprint order. */
101
+ readonly painted: readonly BlueprintField[];
102
+ /** Fields the host paints, as Texuli `custom` fields of the same form, in blueprint order. */
103
+ readonly hosted: readonly BlueprintField[];
104
+ /**
105
+ * The schema field name of each field, by `inputProperty`. It is the
106
+ * `inputProperty` itself unless Texuli refuses that name (`FORM_NAME`, an
107
+ * `Object.prototype` member); then it is `field_<index>`.
108
+ */
109
+ readonly names: ReadonlyMap<string, string>;
110
+ /** The blueprint field behind a schema field name. */
111
+ fieldFor(name: string): BlueprintField | undefined;
112
+ /**
113
+ * Starting form values by schema field name: the row's values (an edited
114
+ * record, or a create prefill), then the field defaults on create.
115
+ */
116
+ toFormValues(row: Readonly<Record<string, unknown>> | null | undefined): Record<string, unknown>;
117
+ /** Submitted form values → entity service input (keyed by `inputProperty`). */
118
+ fromFormValues(values: Readonly<Record<string, unknown>>): EntityInput;
119
+ }
120
+ /**
121
+ * Map an entity to a `@texuli/forms` schema plus the value converters for
122
+ * the fields Texuli paints. Pure and deterministic.
123
+ */
124
+ export declare function entityFormModel(entity: BlueprintEntity, options?: EntityFormSchemaOptions): EntityFormModel;
@@ -0,0 +1,300 @@
1
+ import { fieldLabel, inputProperty } from "./validation.js";
2
+ /**
3
+ * Field kinds the host paints itself (no matching `@texuli/forms` control),
4
+ * as Texuli `custom` fields. Persona fields without people to pick and enums
5
+ * past Texuli's option cap are hosted too (see `ENTITY_FORM_CUSTOM_KINDS`).
6
+ */
7
+ export const HOSTED_FORM_KINDS = new Set([
8
+ 'relation',
9
+ 'geo',
10
+ 'avatar',
11
+ 'file',
12
+ ]);
13
+ /**
14
+ * Every `kind` a hosted (`custom`) field of this mapping can carry. Pass it
15
+ * as `validateFormSchema(schema, { kinds })` so Texuli checks them.
16
+ */
17
+ export const ENTITY_FORM_CUSTOM_KINDS = [
18
+ ...HOSTED_FORM_KINDS,
19
+ 'persona',
20
+ 'enum',
21
+ ];
22
+ /**
23
+ * @deprecated No longer emitted. Since Texuli 0.10.0 an optional persona
24
+ * select offers its own empty "None" choice (G118), so the mapping adds no
25
+ * sentinel option. `fromFormValues` still reads it as unassigned.
26
+ */
27
+ export const UNASSIGNED_OPTION = '~unassigned';
28
+ /** A `when` whose `enable` is never true. */
29
+ const READ_ONLY = { enable: { op: 'bool', value: false } };
30
+ /** `@texuli/forms` field-name rule and the names it refuses (`FORM_NAME`, `FORM_RESERVED_NAMES`). */
31
+ const TEXULI_NAME = /^[a-z][A-Za-z0-9_]{0,63}$/;
32
+ const RESERVED_NAMES = new Set(Object.getOwnPropertyNames(Object.prototype).filter((name) => TEXULI_NAME.test(name)));
33
+ /** `@texuli/forms` `FORM_LIMITS.options`. */
34
+ const MAX_OPTIONS = 200;
35
+ /** `@texuli/forms` `FORM_LIMITS.valueLength`. */
36
+ const MAX_TEXT = 10_000;
37
+ /** `support-ticket` → `support ticket`. */
38
+ function entityNoun(entity) {
39
+ return entity.name.replace(/-/g, ' ');
40
+ }
41
+ function truncate(text, max) {
42
+ return text.length > max ? text.slice(0, max) : text;
43
+ }
44
+ function personaOptionsFor(options) {
45
+ const list = [...options];
46
+ const seen = new Set();
47
+ for (const option of list) {
48
+ if (!option.value || seen.has(option.value))
49
+ return null;
50
+ seen.add(option.value);
51
+ }
52
+ return list.length > 0 && list.length <= MAX_OPTIONS ? list : null;
53
+ }
54
+ /** The schema field name of a blueprint field (its `inputProperty`, unless Texuli refuses it). */
55
+ function schemaName(field, index) {
56
+ const name = inputProperty(field);
57
+ return TEXULI_NAME.test(name) && !RESERVED_NAMES.has(name) ? name : `field_${index}`;
58
+ }
59
+ /** A hosted field: a Texuli `custom` field the host paints for its blueprint kind. */
60
+ function customField(field, name) {
61
+ return {
62
+ name,
63
+ type: 'custom',
64
+ kind: field.kind,
65
+ label: fieldLabel(field),
66
+ ...(field.description ? { description: field.description } : {}),
67
+ ...(field.required ? { required: true } : {}),
68
+ };
69
+ }
70
+ /** The Texuli field for a blueprint field, or `null` when the host paints it. */
71
+ function paintField(field, name, personaOptions) {
72
+ if (HOSTED_FORM_KINDS.has(field.kind))
73
+ return null;
74
+ const label = fieldLabel(field);
75
+ const base = {
76
+ name,
77
+ label,
78
+ ...(field.description ? { description: field.description } : {}),
79
+ ...(field.required ? { required: true } : {}),
80
+ };
81
+ switch (field.kind) {
82
+ case 'text':
83
+ return {
84
+ ...base,
85
+ type: 'text',
86
+ ...(field.minLength ? { minLength: field.minLength } : {}),
87
+ maxLength: Math.min(field.maxLength, MAX_TEXT),
88
+ };
89
+ case 'longText':
90
+ return {
91
+ ...base,
92
+ type: 'textarea',
93
+ rows: 4,
94
+ ...(field.minLength ? { minLength: field.minLength } : {}),
95
+ maxLength: Math.min(field.maxLength, MAX_TEXT),
96
+ };
97
+ case 'json':
98
+ // The service parses the text and reports "must be valid JSON" inline.
99
+ return { ...base, type: 'textarea', rows: 6 };
100
+ case 'number':
101
+ return {
102
+ ...base,
103
+ type: 'number',
104
+ ...(field.min !== undefined ? { min: field.min } : {}),
105
+ ...(field.max !== undefined ? { max: field.max } : {}),
106
+ };
107
+ case 'integer':
108
+ return {
109
+ ...base,
110
+ type: 'number',
111
+ step: 1,
112
+ ...(field.min !== undefined ? { min: field.min } : {}),
113
+ ...(field.max !== undefined ? { max: field.max } : {}),
114
+ };
115
+ case 'money':
116
+ // Shown in major units; stored as integer cents (see `fromFormValues`).
117
+ return {
118
+ ...base,
119
+ type: 'number',
120
+ step: 0.01,
121
+ placeholder: '0.00',
122
+ ...(field.min !== undefined ? { min: field.min / 100 } : {}),
123
+ ...(field.max !== undefined ? { max: field.max / 100 } : {}),
124
+ };
125
+ case 'boolean': {
126
+ // A required checkbox means "must be checked" in Texuli; a blueprint
127
+ // boolean is required to have a value, which a checkbox always has.
128
+ const { required: _required, ...rest } = base;
129
+ return { ...rest, type: 'checkbox' };
130
+ }
131
+ case 'date':
132
+ return { ...base, type: 'date' };
133
+ case 'datetime':
134
+ // `YYYY-MM-DDTHH:MM`, the value the service stores for a datetime field.
135
+ return { ...base, type: 'datetime' };
136
+ case 'enum': {
137
+ if (field.values.length > MAX_OPTIONS)
138
+ return null;
139
+ return {
140
+ ...base,
141
+ type: 'select',
142
+ placeholder: `Select ${label.toLowerCase()}`,
143
+ options: field.values.map((value) => ({ value, label: field.labels?.[value] ?? value })),
144
+ };
145
+ }
146
+ case 'email':
147
+ return { ...base, type: 'email' };
148
+ case 'url':
149
+ // The service accepts http and https (G121).
150
+ return { ...base, type: 'url', placeholder: 'https://', protocols: ['http', 'https'] };
151
+ case 'phone':
152
+ return { ...base, type: 'text' };
153
+ case 'tags':
154
+ return { ...base, type: 'tags', maxItems: field.maxItems };
155
+ case 'persona': {
156
+ // An optional select offers Texuli's empty "None" choice (G118).
157
+ const options = personaOptions ? personaOptionsFor(personaOptions) : null;
158
+ if (!options)
159
+ return null;
160
+ return {
161
+ ...base,
162
+ type: 'select',
163
+ placeholder: `Select ${label.toLowerCase()}`,
164
+ options,
165
+ };
166
+ }
167
+ default:
168
+ return null;
169
+ }
170
+ }
171
+ /** A stored value → the value its Texuli control holds. */
172
+ function toFormValue(field, value) {
173
+ switch (field.kind) {
174
+ case 'money':
175
+ return typeof value === 'number' && Number.isFinite(value) ? value / 100 : null;
176
+ case 'number':
177
+ case 'integer':
178
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
179
+ case 'boolean':
180
+ return value === true;
181
+ case 'date':
182
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)
183
+ ? value.slice(0, 10)
184
+ : null;
185
+ case 'datetime': {
186
+ // The UTC wall time (`YYYY-MM-DDTHH:MM`) of the stored instant, the same on every machine.
187
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value))
188
+ return null;
189
+ const date = new Date(value);
190
+ return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 16);
191
+ }
192
+ case 'enum':
193
+ return typeof value === 'string' && field.values.includes(value) ? value : null;
194
+ case 'persona':
195
+ return typeof value === 'string' && value ? value : null;
196
+ case 'tags':
197
+ return Array.isArray(value)
198
+ ? value.filter((tag) => typeof tag === 'string')
199
+ : [];
200
+ case 'json':
201
+ if (value === null || value === undefined)
202
+ return '';
203
+ return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
204
+ default:
205
+ return typeof value === 'string' ? value : value == null ? '' : String(value);
206
+ }
207
+ }
208
+ /** A hosted field's starting value (any JSON its host control holds). */
209
+ function toHostedValue(field, value) {
210
+ if (value !== undefined && value !== null)
211
+ return value;
212
+ return field.kind === 'boolean' ? false : field.kind === 'tags' ? [] : null;
213
+ }
214
+ /** A submitted Texuli value → the entity service input value. */
215
+ function fromFormValue(field, value) {
216
+ switch (field.kind) {
217
+ case 'money':
218
+ return typeof value === 'number' && Number.isFinite(value) ? Math.round(value * 100) : null;
219
+ case 'persona':
220
+ return typeof value === 'string' && value !== '' && value !== UNASSIGNED_OPTION
221
+ ? value
222
+ : null;
223
+ case 'json':
224
+ return typeof value === 'string' && value.trim() !== '' ? value : null;
225
+ case 'datetime':
226
+ // Texuli edits the stored instant's UTC wall time (`toFormValue`), so it goes back as UTC.
227
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)
228
+ ? `${value}:00.000Z`
229
+ : typeof value === 'string' && value !== ''
230
+ ? value
231
+ : null;
232
+ case 'enum':
233
+ case 'date':
234
+ return typeof value === 'string' && value !== '' ? value : null;
235
+ default:
236
+ return value === undefined ? null : value;
237
+ }
238
+ }
239
+ /**
240
+ * Map an entity to a `@texuli/forms` schema plus the value converters for
241
+ * the fields Texuli paints. Pure and deterministic.
242
+ */
243
+ export function entityFormModel(entity, options = {}) {
244
+ const mode = options.mode ?? 'create';
245
+ const fields = [];
246
+ const painted = [];
247
+ const hosted = [];
248
+ const names = new Map();
249
+ const byName = new Map();
250
+ entity.fields.forEach((field, index) => {
251
+ const name = schemaName(field, index);
252
+ names.set(inputProperty(field), name);
253
+ byName.set(name, field);
254
+ const mapped = paintField(field, name, options.personaOptions);
255
+ const emitted = mapped ?? customField(field, name);
256
+ fields.push(options.readOnly ? { ...emitted, when: READ_ONLY } : emitted);
257
+ (mapped ? painted : hosted).push(field);
258
+ });
259
+ const title = truncate(options.title ?? `${mode === 'create' ? 'New' : 'Edit'} ${entityNoun(entity)}`, 200);
260
+ const schema = {
261
+ title,
262
+ ...(options.submitLabel ? { submitLabel: truncate(options.submitLabel, 200) } : {}),
263
+ fields,
264
+ };
265
+ const isHosted = (field) => hosted.includes(field);
266
+ return {
267
+ schema,
268
+ painted,
269
+ hosted,
270
+ names,
271
+ fieldFor: (name) => byName.get(name),
272
+ toFormValues(row) {
273
+ const values = {};
274
+ for (const field of entity.fields) {
275
+ const key = inputProperty(field);
276
+ const name = names.get(key) ?? key;
277
+ const convert = isHosted(field) ? toHostedValue : toFormValue;
278
+ if (row && key in row)
279
+ values[name] = convert(field, row[key]);
280
+ else if (mode === 'create' && 'default' in field && field.default !== undefined) {
281
+ values[name] = convert(field, field.default);
282
+ }
283
+ else
284
+ values[name] = convert(field, null);
285
+ }
286
+ return values;
287
+ },
288
+ fromFormValues(values) {
289
+ const input = {};
290
+ for (const field of entity.fields) {
291
+ const key = inputProperty(field);
292
+ const name = names.get(key) ?? key;
293
+ if (!Object.hasOwn(values, name))
294
+ continue;
295
+ input[key] = isHosted(field) ? (values[name] ?? null) : fromFormValue(field, values[name]);
296
+ }
297
+ return input;
298
+ },
299
+ };
300
+ }
@@ -0,0 +1,11 @@
1
+ import type { Blueprint } from './schema.ts';
2
+ /**
3
+ * The world generator's version, apart from the generator itself: the
4
+ * runtime definition needs it synchronously, while the generator
5
+ * (`@protoyard/blueprint/generator`) loads lazily when a scenario seeds.
6
+ */
7
+ /** Bump when the same blueprint + seed can produce a different world shape. */
8
+ export declare const BLUEPRINT_GENERATOR_SDK = 6;
9
+ /** Stable hash of a blueprint's generative surface (entities, fields, features). */
10
+ export declare function hashBlueprint(blueprint: Blueprint): number;
11
+ export declare function blueprintGeneratorVersion(blueprint: Blueprint): number;