@protoyard/blueprint 0.0.0-stage → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1145 -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
@@ -0,0 +1,146 @@
1
+ import { parseExpressionShape } from "./schema.js";
2
+ function pathText(path) {
3
+ let result = '$';
4
+ for (const segment of path) {
5
+ result += typeof segment === 'number' ? `[${segment}]` : `[${JSON.stringify(segment)}]`;
6
+ }
7
+ return result;
8
+ }
9
+ function add(diagnostics, code, path, message, hint) {
10
+ diagnostics.push({ code, path, jsonPath: pathText(path), message, hint });
11
+ }
12
+ function valueType(kind) {
13
+ if (['text', 'longText', 'email', 'url', 'phone', 'enum'].includes(kind))
14
+ return 'string';
15
+ if (['number', 'integer', 'money'].includes(kind))
16
+ return 'number';
17
+ if (kind === 'boolean')
18
+ return 'boolean';
19
+ if (kind === 'date' || kind === 'datetime')
20
+ return 'date';
21
+ return null;
22
+ }
23
+ function infer(expression, context, path, diagnostics) {
24
+ if (expression.op === 'literal') {
25
+ return {
26
+ type: expression.value === null
27
+ ? 'null'
28
+ : typeof expression.value === 'number'
29
+ ? 'number'
30
+ : typeof expression.value === 'boolean'
31
+ ? 'boolean'
32
+ : 'string',
33
+ nullable: expression.value === null,
34
+ };
35
+ }
36
+ if (expression.op === 'field') {
37
+ const field = context.sourceEntity.fields.find((candidate) => candidate.name === expression.field);
38
+ if (!field) {
39
+ add(diagnostics, 'unknown-field', [...path, 'field'], `unknown field "${expression.field}" on "${context.sourceEntity.name}"`, 'Use a field declared on the source entity or add it before referencing it.');
40
+ return null;
41
+ }
42
+ const type = valueType(field.kind);
43
+ if (!type) {
44
+ add(diagnostics, 'unsupported-field-type', [...path, 'field'], `field "${expression.field}" has unsupported computed type "${field.kind}"`, 'Reference a scalar text, number, integer, money, boolean, date, datetime or enum field.');
45
+ return null;
46
+ }
47
+ return { type, nullable: !('required' in field && field.required === true) };
48
+ }
49
+ if (expression.op === 'aggregate') {
50
+ const relation = context.sourceEntity.fields.find((candidate) => candidate.name === expression.relation);
51
+ if (relation?.kind !== 'relation' || !('to' in relation)) {
52
+ add(diagnostics, 'invalid-relation', [...path, 'relation'], `"${expression.relation}" is not a relation field on "${context.sourceEntity.name}"`, 'Set relation to a relation field declared on the source entity.');
53
+ return null;
54
+ }
55
+ const target = context.entities.find((entity) => entity.name === relation.to);
56
+ if (!target) {
57
+ add(diagnostics, 'unknown-relation-target', [...path, 'relation'], `relation "${expression.relation}" targets unknown entity "${relation.to}"`, 'Declare the relation target entity before using an aggregate.');
58
+ return null;
59
+ }
60
+ if (expression.aggregate === 'count') {
61
+ if (expression.field !== undefined) {
62
+ add(diagnostics, 'unexpected-aggregate-field', [...path, 'field'], 'count does not take a field', 'Remove field from a count aggregate.');
63
+ return null;
64
+ }
65
+ return { type: 'number', nullable: false };
66
+ }
67
+ if (expression.field === undefined) {
68
+ add(diagnostics, 'missing-aggregate-field', [...path, 'field'], `${expression.aggregate} needs a field`, 'Set field to a supported scalar field on the relation target.');
69
+ return null;
70
+ }
71
+ const field = target.fields.find((candidate) => candidate.name === expression.field);
72
+ if (!field) {
73
+ add(diagnostics, 'unknown-field', [...path, 'field'], `unknown field "${expression.field}" on relation target "${target.name}"`, 'Use a field declared on the relation target entity.');
74
+ return null;
75
+ }
76
+ const type = valueType(field.kind);
77
+ const accepted = expression.aggregate === 'min' || expression.aggregate === 'max'
78
+ ? type === 'number' || type === 'date'
79
+ : type === 'number';
80
+ if (!accepted) {
81
+ add(diagnostics, 'invalid-aggregate-type', [...path, 'field'], `${expression.aggregate} cannot aggregate field kind "${field.kind}"`, expression.aggregate === 'min' || expression.aggregate === 'max'
82
+ ? 'Choose a number, integer, money, date or datetime field.'
83
+ : 'Choose a number, integer or money field.');
84
+ return null;
85
+ }
86
+ return { type: type, nullable: true };
87
+ }
88
+ if (expression.op === 'concat') {
89
+ const children = expression.values.map((value, index) => infer(value, context, [...path, 'values', index], diagnostics));
90
+ const invalid = children.some((child) => child === null || (child.type !== 'string' && child.type !== 'null'));
91
+ if (invalid) {
92
+ add(diagnostics, 'invalid-operand-type', [...path, 'values'], 'concat accepts only string expressions', 'Use text-like fields or string literals in concat.');
93
+ return null;
94
+ }
95
+ return { type: 'string', nullable: children.some((child) => child?.nullable) };
96
+ }
97
+ if (expression.op === 'dateDiff') {
98
+ const start = infer(expression.start, context, [...path, 'start'], diagnostics);
99
+ const end = infer(expression.end, context, [...path, 'end'], diagnostics);
100
+ if (start && start.type !== 'date' && start.type !== 'null') {
101
+ add(diagnostics, 'invalid-operand-type', [...path, 'start'], 'dateDiff start must be a date or datetime expression', 'Use a date or datetime field.');
102
+ }
103
+ if (end && end.type !== 'date' && end.type !== 'null') {
104
+ add(diagnostics, 'invalid-operand-type', [...path, 'end'], 'dateDiff end must be a date or datetime expression', 'Use a date or datetime field.');
105
+ }
106
+ if (!start ||
107
+ !end ||
108
+ (start.type !== 'date' && start.type !== 'null') ||
109
+ (end.type !== 'date' && end.type !== 'null'))
110
+ return null;
111
+ return { type: 'number', nullable: start.nullable || end.nullable };
112
+ }
113
+ const left = infer(expression.left, context, [...path, 'left'], diagnostics);
114
+ const right = infer(expression.right, context, [...path, 'right'], diagnostics);
115
+ if (left && left.type !== 'number') {
116
+ add(diagnostics, 'invalid-operand-type', [...path, 'left'], `${expression.op} left operand must be numeric`, 'Use a number, integer, money field or numeric literal.');
117
+ }
118
+ if (right && right.type !== 'number') {
119
+ add(diagnostics, 'invalid-operand-type', [...path, 'right'], `${expression.op} right operand must be numeric`, 'Use a number, integer, money field or numeric literal.');
120
+ }
121
+ if (!left || !right || left.type !== 'number' || right.type !== 'number')
122
+ return null;
123
+ return {
124
+ type: 'number',
125
+ nullable: left.nullable || right.nullable || expression.op === 'divide',
126
+ };
127
+ }
128
+ /** Validate AST shape, field references, operator input types and inferred output type. */
129
+ export function validateComputedExpression(input, context) {
130
+ const parsed = parseExpressionShape(input);
131
+ if (!parsed.success) {
132
+ return { valid: false, diagnostics: parsed.diagnostics, resultType: null, nullable: false };
133
+ }
134
+ const diagnostics = [];
135
+ const inferred = infer(parsed.data, context, [], diagnostics);
136
+ return {
137
+ valid: diagnostics.length === 0 && inferred !== null,
138
+ diagnostics,
139
+ resultType: diagnostics.length === 0 ? (inferred?.type ?? null) : null,
140
+ nullable: diagnostics.length === 0 ? (inferred?.nullable ?? false) : false,
141
+ };
142
+ }
143
+ /** Helper for callers that need to use the public entity shape as context. */
144
+ export function computedContext(sourceEntity, entities) {
145
+ return { sourceEntity, entities };
146
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Spreadsheet formula guard shared by every CSV exporter (blueprint entity
3
+ * export, crud analytics export). Kept dependency-free and tiny so the main
4
+ * `@protoyard/blueprint` entry can re-export it without pulling in the codec.
5
+ */
6
+ /** Limits used by the opt-in CSV shape authoring importer. */
7
+ export declare const CSV_SHAPE_MAX_COLUMNS = 100;
8
+ export declare const CSV_SHAPE_MAX_SAMPLE_ROWS = 100;
9
+ export declare const CSV_SHAPE_MAX_CELL_BYTES: number;
10
+ /** True when `text` would be evaluated as a formula and is not a plain number. */
11
+ export declare function needsCsvFormulaGuard(text: string): boolean;
12
+ /** Prefix formula-like text with `'` so spreadsheets show it as text. */
13
+ export declare function guardCsvFormula(text: string): string;
14
+ /** Inverse of {@link guardCsvFormula}: strips the `'` only when export would have added it. */
15
+ export declare function unguardCsvFormula(text: string): string;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Spreadsheet formula guard shared by every CSV exporter (blueprint entity
3
+ * export, crud analytics export). Kept dependency-free and tiny so the main
4
+ * `@protoyard/blueprint` entry can re-export it without pulling in the codec.
5
+ */
6
+ /**
7
+ * Leading characters that make Excel, LibreOffice or Sheets read a cell as a
8
+ * formula: `= + - @` (also after leading whitespace, and their full-width
9
+ * forms), or a leading tab or carriage return.
10
+ */
11
+ const FORMULA_TRIGGER = /^(?:[\t\r]|\s*[=+\-@\uFF1D\uFF0B\uFF0D\uFF20])/;
12
+ /** A whole-cell plain number (`-5`, `+1.25`, `.5`, `1e3`): inert in every spreadsheet. */
13
+ const PLAIN_NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
14
+ /** Limits used by the opt-in CSV shape authoring importer. */
15
+ export const CSV_SHAPE_MAX_COLUMNS = 100;
16
+ export const CSV_SHAPE_MAX_SAMPLE_ROWS = 100;
17
+ export const CSV_SHAPE_MAX_CELL_BYTES = 16 * 1024;
18
+ /** True when `text` would be evaluated as a formula and is not a plain number. */
19
+ export function needsCsvFormulaGuard(text) {
20
+ return FORMULA_TRIGGER.test(text) && !PLAIN_NUMBER.test(text);
21
+ }
22
+ /** Prefix formula-like text with `'` so spreadsheets show it as text. */
23
+ export function guardCsvFormula(text) {
24
+ return needsCsvFormulaGuard(text) ? `'${text}` : text;
25
+ }
26
+ /** Inverse of {@link guardCsvFormula}: strips the `'` only when export would have added it. */
27
+ export function unguardCsvFormula(text) {
28
+ return text.startsWith("'") && needsCsvFormulaGuard(text.slice(1)) ? text.slice(1) : text;
29
+ }
package/dist/csv.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ import type { BlueprintEntity, BlueprintField } from './schema.ts';
2
+ import { type EntityInput } from './validation.ts';
3
+ /**
4
+ * Dependency-free RFC 4180 CSV codec for blueprint import/export.
5
+ * Load via `import('@protoyard/blueprint/csv')` so the main blueprint entry
6
+ * stays free of this code until the user opens import/export.
7
+ */
8
+ /** Default hard limits — override per call when a larger demo is intentional. */
9
+ export declare const CSV_MAX_BYTES: number;
10
+ export declare const CSV_MAX_ROWS = 10000;
11
+ export { guardCsvFormula, needsCsvFormulaGuard, unguardCsvFormula } from './csv-guard.ts';
12
+ export type CsvErrorCode = 'too-large' | 'too-many-rows' | 'empty' | 'header' | 'parse' | 'columns';
13
+ export declare class CsvError extends Error {
14
+ readonly code: CsvErrorCode;
15
+ constructor(code: CsvErrorCode, message: string);
16
+ }
17
+ export interface CsvLimits {
18
+ maxBytes?: number;
19
+ /** Data rows only (header is not counted). */
20
+ maxRows?: number;
21
+ }
22
+ export interface CsvParseOptions extends CsvLimits {
23
+ /** When false, leave leading BOM in the first cell (default strips it). */
24
+ keepBom?: boolean;
25
+ }
26
+ export interface CsvSerializeOptions {
27
+ /**
28
+ * Prefix cells that start with `= + - @`, a tab or a carriage return with a
29
+ * single quote so spreadsheets do not treat them as formulas, unless the
30
+ * whole cell is a plain number such as `-5` or `+1.5` (default true).
31
+ */
32
+ guardFormulas?: boolean;
33
+ /** Line ending (default `\r\n` per RFC 4180). */
34
+ newline?: '\r\n' | '\n';
35
+ }
36
+ export interface CsvColumn {
37
+ /** Row / input property (`customerId`, `subject`, …). */
38
+ property: string;
39
+ /** Header text written on export / matched on import. */
40
+ header: string;
41
+ field?: BlueprintField;
42
+ /** Geo fields export as two columns, `<name>Lat` and `<name>Lng`. */
43
+ part?: 'lat' | 'lng';
44
+ }
45
+ export interface CsvRowError {
46
+ /** 1-based data row index (first row after the header is 1). */
47
+ row: number;
48
+ /** Input property or `form` / `row` for whole-row problems. */
49
+ field: string;
50
+ message: string;
51
+ }
52
+ export interface CsvValidationResult {
53
+ headers: string[];
54
+ columns: CsvColumn[];
55
+ /** Successfully validated create-shaped rows (coerced). */
56
+ valid: EntityInput[];
57
+ errors: CsvRowError[];
58
+ /** Total data rows seen (valid + invalid). */
59
+ rowCount: number;
60
+ }
61
+ /**
62
+ * Parse RFC 4180 CSV into a matrix of cell strings.
63
+ * Handles quoted fields, escaped quotes (`""`), CRLF / LF / CR, and a leading BOM.
64
+ */
65
+ export declare function parseCsv(text: string, options?: CsvParseOptions): string[][];
66
+ /** Escape one cell for RFC 4180; optionally neutralize spreadsheet formulas. */
67
+ export declare function escapeCsvCell(value: unknown, options?: CsvSerializeOptions): string;
68
+ /** Serialize a matrix of cells to CSV text (CRLF by default). */
69
+ export declare function serializeCsv(rows: readonly (readonly unknown[])[], options?: CsvSerializeOptions): string;
70
+ /** Format a row value for export (money as major units, tags joined, …). */
71
+ export declare function formatCsvValue(value: unknown, field?: BlueprintField): string;
72
+ /** Columns for an entity: header is label (or name), property is the input key. */
73
+ export declare function entityCsvColumns(entity: BlueprintEntity): CsvColumn[];
74
+ /**
75
+ * Map header cells to entity columns by case-insensitive match on field name,
76
+ * input property, or label.
77
+ */
78
+ export declare function mapCsvHeaders(headers: string[], entity: BlueprintEntity): CsvColumn[];
79
+ /** Serialize entity-shaped records using the entity's column headers. */
80
+ export declare function serializeEntityCsv(entity: BlueprintEntity, records: readonly EntityInput[], options?: CsvSerializeOptions): string;
81
+ /**
82
+ * Parse CSV text and validate each data row against the entity's create schema.
83
+ * Unknown columns fail at the header; per-cell issues become `{ row, field, message }`.
84
+ */
85
+ export declare function validateCsv(entity: BlueprintEntity, text: string, options?: CsvParseOptions): CsvValidationResult;
package/dist/csv.js ADDED
@@ -0,0 +1,288 @@
1
+ import { parseInput, ValidationError } from '@protoyard/core';
2
+ import { guardCsvFormula, unguardCsvFormula } from "./csv-guard.js";
3
+ import { deriveInputSchemas, fieldLabel, inputProperty } from "./validation.js";
4
+ /**
5
+ * Dependency-free RFC 4180 CSV codec for blueprint import/export.
6
+ * Load via `import('@protoyard/blueprint/csv')` so the main blueprint entry
7
+ * stays free of this code until the user opens import/export.
8
+ */
9
+ /** Default hard limits — override per call when a larger demo is intentional. */
10
+ export const CSV_MAX_BYTES = 2 * 1024 * 1024;
11
+ export const CSV_MAX_ROWS = 10_000;
12
+ export { guardCsvFormula, needsCsvFormulaGuard, unguardCsvFormula } from "./csv-guard.js";
13
+ export class CsvError extends Error {
14
+ code;
15
+ constructor(code, message) {
16
+ super(message);
17
+ this.name = 'CsvError';
18
+ this.code = code;
19
+ }
20
+ }
21
+ function assertLimits(text, limits = {}) {
22
+ const maxBytes = limits.maxBytes ?? CSV_MAX_BYTES;
23
+ const bytes = typeof TextEncoder !== 'undefined' ? new TextEncoder().encode(text).length : text.length;
24
+ if (bytes > maxBytes) {
25
+ throw new CsvError('too-large', `CSV is ${bytes.toLocaleString()} bytes; the limit is ${maxBytes.toLocaleString()} bytes`);
26
+ }
27
+ }
28
+ /**
29
+ * Parse RFC 4180 CSV into a matrix of cell strings.
30
+ * Handles quoted fields, escaped quotes (`""`), CRLF / LF / CR, and a leading BOM.
31
+ */
32
+ export function parseCsv(text, options = {}) {
33
+ assertLimits(text, options);
34
+ let source = text;
35
+ if (!options.keepBom && source.charCodeAt(0) === 0xfeff) {
36
+ source = source.slice(1);
37
+ }
38
+ if (source.length === 0) {
39
+ throw new CsvError('empty', 'CSV is empty');
40
+ }
41
+ const maxRows = options.maxRows ?? CSV_MAX_ROWS;
42
+ const rows = [];
43
+ let row = [];
44
+ let field = '';
45
+ let i = 0;
46
+ let inQuotes = false;
47
+ const pushField = () => {
48
+ row.push(field);
49
+ field = '';
50
+ };
51
+ const pushRow = () => {
52
+ // A trailing newline after the last row yields an empty trailing row — drop it.
53
+ if (row.length === 1 && row[0] === '' && rows.length > 0 && i >= source.length) {
54
+ row = [];
55
+ return;
56
+ }
57
+ rows.push(row);
58
+ row = [];
59
+ // Header + data rows: allow header free, then enforce max data rows.
60
+ if (rows.length > maxRows + 1) {
61
+ throw new CsvError('too-many-rows', `CSV has more than ${maxRows.toLocaleString()} data rows; split the file or raise maxRows`);
62
+ }
63
+ };
64
+ while (i < source.length) {
65
+ const ch = source[i];
66
+ if (inQuotes) {
67
+ if (ch === '"') {
68
+ if (source[i + 1] === '"') {
69
+ field += '"';
70
+ i += 2;
71
+ continue;
72
+ }
73
+ inQuotes = false;
74
+ i += 1;
75
+ continue;
76
+ }
77
+ field += ch;
78
+ i += 1;
79
+ continue;
80
+ }
81
+ if (ch === '"') {
82
+ inQuotes = true;
83
+ i += 1;
84
+ continue;
85
+ }
86
+ if (ch === ',') {
87
+ pushField();
88
+ i += 1;
89
+ continue;
90
+ }
91
+ if (ch === '\r' || ch === '\n') {
92
+ pushField();
93
+ pushRow();
94
+ if (ch === '\r' && source[i + 1] === '\n')
95
+ i += 2;
96
+ else
97
+ i += 1;
98
+ continue;
99
+ }
100
+ field += ch;
101
+ i += 1;
102
+ }
103
+ if (inQuotes) {
104
+ throw new CsvError('parse', 'CSV ends inside a quoted field (missing closing quote)');
105
+ }
106
+ // Final field/row when the file does not end with a newline.
107
+ if (field.length > 0 || row.length > 0 || source.endsWith(',')) {
108
+ pushField();
109
+ // Avoid double-pushing an empty row when the file ended with a newline
110
+ // (pushRow already ran) and we have nothing left.
111
+ if (row.length > 1 || (row.length === 1 && (row[0] !== '' || rows.length === 0))) {
112
+ pushRow();
113
+ }
114
+ else if (row.length === 1 && row[0] === '' && rows.length === 0) {
115
+ pushRow();
116
+ }
117
+ }
118
+ if (rows.length === 0) {
119
+ throw new CsvError('empty', 'CSV has no rows');
120
+ }
121
+ return rows;
122
+ }
123
+ /** Escape one cell for RFC 4180; optionally neutralize spreadsheet formulas. */
124
+ export function escapeCsvCell(value, options = {}) {
125
+ let text = value === null || value === undefined ? '' : String(value);
126
+ const guard = options.guardFormulas !== false;
127
+ if (guard)
128
+ text = guardCsvFormula(text);
129
+ if (/[",\r\n]/.test(text)) {
130
+ return `"${text.replace(/"/g, '""')}"`;
131
+ }
132
+ return text;
133
+ }
134
+ /** Serialize a matrix of cells to CSV text (CRLF by default). */
135
+ export function serializeCsv(rows, options = {}) {
136
+ const newline = options.newline ?? '\r\n';
137
+ return rows.map((row) => row.map((cell) => escapeCsvCell(cell, options)).join(',')).join(newline);
138
+ }
139
+ /** Format a row value for export (money as major units, tags joined, …). */
140
+ export function formatCsvValue(value, field) {
141
+ if (value === null || value === undefined)
142
+ return '';
143
+ if (field?.kind === 'money' && typeof value === 'number') {
144
+ const sign = value < 0 ? '-' : '';
145
+ const abs = Math.abs(value);
146
+ return `${sign}${(abs / 100).toFixed(2)}`;
147
+ }
148
+ if (typeof value === 'boolean')
149
+ return value ? 'true' : 'false';
150
+ if (Array.isArray(value))
151
+ return value.map(String).join(',');
152
+ if (value instanceof Date)
153
+ return value.toISOString();
154
+ if (typeof value === 'object')
155
+ return JSON.stringify(value);
156
+ return String(value);
157
+ }
158
+ /** Columns for an entity: header is label (or name), property is the input key. */
159
+ export function entityCsvColumns(entity) {
160
+ return entity.fields.flatMap((field) => field.kind === 'geo'
161
+ ? ['lat', 'lng'].map((part) => ({
162
+ property: inputProperty(field),
163
+ header: `${field.name}${part === 'lat' ? 'Lat' : 'Lng'}`,
164
+ field,
165
+ part,
166
+ }))
167
+ : [{ property: inputProperty(field), header: fieldLabel(field), field }]);
168
+ }
169
+ /** Header keys a column answers to (case-insensitive). */
170
+ function columnKeys(col) {
171
+ if (col.part && col.field) {
172
+ const suffix = col.part === 'lat' ? 'lat' : 'lng';
173
+ return [col.header, `${col.field.name} ${suffix}`, `${fieldLabel(col.field)} ${suffix}`];
174
+ }
175
+ return [col.property, col.header, col.field?.name, col.field?.label].filter((k) => typeof k === 'string' && k.length > 0);
176
+ }
177
+ const columnId = (col) => (col.part ? `${col.property}.${col.part}` : col.property);
178
+ /**
179
+ * Map header cells to entity columns by case-insensitive match on field name,
180
+ * input property, or label.
181
+ */
182
+ export function mapCsvHeaders(headers, entity) {
183
+ if (headers.length === 0 || headers.every((h) => h.trim() === '')) {
184
+ throw new CsvError('header', 'CSV has no header row');
185
+ }
186
+ const columns = entityCsvColumns(entity);
187
+ const byKey = new Map();
188
+ for (const col of columns) {
189
+ for (const key of columnKeys(col)) {
190
+ byKey.set(key.trim().toLowerCase(), col);
191
+ }
192
+ }
193
+ const mapped = [];
194
+ const seen = new Set();
195
+ for (const header of headers) {
196
+ const key = header.trim().toLowerCase();
197
+ if (!key) {
198
+ mapped.push({ property: '', header, field: undefined });
199
+ continue;
200
+ }
201
+ const col = byKey.get(key);
202
+ if (!col) {
203
+ throw new CsvError('header', `Unknown CSV column "${header}". Expected field names or labels such as ${columns
204
+ .slice(0, 5)
205
+ .map((c) => c.header)
206
+ .join(', ')}`);
207
+ }
208
+ if (seen.has(columnId(col))) {
209
+ throw new CsvError('header', `Duplicate CSV column for "${col.header}"`);
210
+ }
211
+ seen.add(columnId(col));
212
+ mapped.push({ ...col, header });
213
+ }
214
+ return mapped;
215
+ }
216
+ /** Serialize entity-shaped records using the entity's column headers. */
217
+ export function serializeEntityCsv(entity, records, options = {}) {
218
+ const columns = entityCsvColumns(entity);
219
+ const header = columns.map((c) => c.header);
220
+ const body = records.map((record) => columns.map((col) => {
221
+ const value = record[col.property];
222
+ if (!col.part)
223
+ return formatCsvValue(value, col.field);
224
+ const point = value;
225
+ return formatCsvValue(point?.[col.part]);
226
+ }));
227
+ return serializeCsv([header, ...body], options);
228
+ }
229
+ /**
230
+ * Parse CSV text and validate each data row against the entity's create schema.
231
+ * Unknown columns fail at the header; per-cell issues become `{ row, field, message }`.
232
+ */
233
+ export function validateCsv(entity, text, options = {}) {
234
+ const matrix = parseCsv(text, options);
235
+ const [headerRow, ...dataRows] = matrix;
236
+ if (!headerRow) {
237
+ throw new CsvError('empty', 'CSV has no header row');
238
+ }
239
+ const columns = mapCsvHeaders(headerRow, entity);
240
+ const { create } = deriveInputSchemas(entity);
241
+ const valid = [];
242
+ const errors = [];
243
+ dataRows.forEach((cells, index) => {
244
+ const rowNumber = index + 1;
245
+ // Skip fully empty lines (common trailing artifact).
246
+ if (cells.every((c) => c.trim() === ''))
247
+ return;
248
+ const input = {};
249
+ columns.forEach((col, colIndex) => {
250
+ if (!col.property)
251
+ return;
252
+ // Undo the export formula guard so exported values round-trip exactly.
253
+ const raw = unguardCsvFormula(cells[colIndex] ?? '');
254
+ if (col.part) {
255
+ // Both halves of a geo point land in one `{ lat, lng }` input.
256
+ const point = (input[col.property] ?? {});
257
+ input[col.property] = { ...point, [col.part]: raw };
258
+ }
259
+ else {
260
+ input[col.property] = raw;
261
+ }
262
+ });
263
+ try {
264
+ valid.push(parseInput(create, input));
265
+ }
266
+ catch (error) {
267
+ if (error instanceof ValidationError) {
268
+ for (const [field, message] of Object.entries(error.issues)) {
269
+ errors.push({ row: rowNumber, field, message });
270
+ }
271
+ }
272
+ else {
273
+ errors.push({
274
+ row: rowNumber,
275
+ field: 'row',
276
+ message: error instanceof Error ? error.message : String(error),
277
+ });
278
+ }
279
+ }
280
+ });
281
+ return {
282
+ headers: headerRow,
283
+ columns,
284
+ valid,
285
+ errors,
286
+ rowCount: dataRows.filter((cells) => !cells.every((c) => c.trim() === '')).length,
287
+ };
288
+ }
@@ -0,0 +1,35 @@
1
+ import { type PrototypeRuntime } from '@protoyard/core';
2
+ import { type Blueprint } from './schema.ts';
3
+ export declare const DEFAULT_FILE_QUOTA_BYTES: number;
4
+ export interface FileUploadProgress {
5
+ loaded: number;
6
+ total: number;
7
+ percent: number;
8
+ }
9
+ export interface BlueprintFileMetadata {
10
+ id: string;
11
+ name: string;
12
+ type: string;
13
+ size: number;
14
+ ownerId: string;
15
+ entity: string;
16
+ recordId: string;
17
+ createdAt: string;
18
+ }
19
+ export interface BlueprintFile extends BlueprintFileMetadata {
20
+ blob: Blob;
21
+ }
22
+ export interface FileService {
23
+ upload(input: unknown, onProgress?: (progress: FileUploadProgress) => void): Promise<BlueprintFileMetadata>;
24
+ list(entityName: string, recordId: string, fieldName?: string): Promise<BlueprintFileMetadata[]>;
25
+ get(id: string): Promise<BlueprintFile | undefined>;
26
+ remove(id: string): Promise<boolean>;
27
+ }
28
+ export type FileServices = Record<string, FileService>;
29
+ export declare class FileUploadError extends Error {
30
+ readonly code: 'not-found' | 'size-limit' | 'type-not-allowed' | 'quota-exceeded' | 'files-disabled' | 'data-unavailable';
31
+ constructor(code: FileUploadError['code'], message: string);
32
+ }
33
+ export declare function createFileServices(runtime: PrototypeRuntime, blueprint: Blueprint, options?: {
34
+ prototypeQuotaBytes?: number;
35
+ }): FileServices;