@byline/core 3.20.4 → 4.0.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 (103) hide show
  1. package/dist/@types/collection-types.d.ts +72 -31
  2. package/dist/@types/db-types.d.ts +93 -47
  3. package/dist/@types/field-data-types.d.ts +8 -10
  4. package/dist/@types/field-data-types.js +1 -1
  5. package/dist/@types/field-data-types.test.node.d.ts +1 -0
  6. package/dist/@types/field-data-types.test.node.js +70 -0
  7. package/dist/@types/field-types.d.ts +25 -1
  8. package/dist/@types/index.d.ts +1 -0
  9. package/dist/@types/index.js +1 -0
  10. package/dist/@types/query-predicate.d.ts +3 -3
  11. package/dist/@types/relation-types.d.ts +38 -0
  12. package/dist/@types/relation-types.js +8 -0
  13. package/dist/@types/search-types.d.ts +3 -4
  14. package/dist/@types/site-config.d.ts +52 -13
  15. package/dist/auth/apply-before-read.d.ts +23 -11
  16. package/dist/auth/apply-before-read.js +139 -33
  17. package/dist/auth/apply-before-read.test.node.js +241 -3
  18. package/dist/auth/index.d.ts +1 -1
  19. package/dist/auth/index.js +1 -1
  20. package/dist/auth/read-context-scope.d.ts +20 -0
  21. package/dist/auth/read-context-scope.js +48 -0
  22. package/dist/codegen/fixtures/all-fields.d.ts +342 -0
  23. package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
  24. package/dist/codegen/fixtures/all-fields.expected.js +1 -0
  25. package/dist/codegen/fixtures/all-fields.js +94 -0
  26. package/dist/codegen/index.d.ts +16 -0
  27. package/dist/codegen/index.js +431 -0
  28. package/dist/codegen/index.test.node.d.ts +1 -0
  29. package/dist/codegen/index.test.node.js +230 -0
  30. package/dist/config/attach-hooks.d.ts +25 -0
  31. package/dist/config/attach-hooks.js +130 -0
  32. package/dist/config/attach-hooks.test.node.d.ts +1 -0
  33. package/dist/config/attach-hooks.test.node.js +173 -0
  34. package/dist/config/config-hooks.test.node.d.ts +1 -0
  35. package/dist/config/config-hooks.test.node.js +56 -0
  36. package/dist/config/config.d.ts +9 -5
  37. package/dist/config/config.js +20 -2
  38. package/dist/config/routes.d.ts +5 -5
  39. package/dist/config/routes.js +42 -9
  40. package/dist/config/routes.test.node.d.ts +1 -0
  41. package/dist/config/routes.test.node.js +152 -0
  42. package/dist/core.d.ts +3 -3
  43. package/dist/core.js +20 -14
  44. package/dist/core.test.node.d.ts +1 -0
  45. package/dist/core.test.node.js +28 -0
  46. package/dist/index.d.ts +4 -3
  47. package/dist/index.js +4 -3
  48. package/dist/lib/errors.d.ts +13 -0
  49. package/dist/lib/errors.js +14 -0
  50. package/dist/query/parse-where.d.ts +10 -1
  51. package/dist/query/parse-where.js +146 -2
  52. package/dist/query/parse-where.test.node.js +60 -1
  53. package/dist/schemas/zod/builder.js +7 -3
  54. package/dist/schemas/zod/builder.test.node.d.ts +1 -0
  55. package/dist/schemas/zod/builder.test.node.js +49 -0
  56. package/dist/services/collection-bootstrap.d.ts +1 -1
  57. package/dist/services/collection-bootstrap.test.node.js +30 -59
  58. package/dist/services/discover-counter-groups.d.ts +1 -1
  59. package/dist/services/discover-counter-groups.test.node.js +23 -0
  60. package/dist/services/document-lifecycle/audit.d.ts +22 -1
  61. package/dist/services/document-lifecycle/audit.js +32 -1
  62. package/dist/services/document-lifecycle/create.d.ts +2 -2
  63. package/dist/services/document-lifecycle/create.js +18 -8
  64. package/dist/services/document-lifecycle/delete.d.ts +17 -1
  65. package/dist/services/document-lifecycle/delete.js +91 -26
  66. package/dist/services/document-lifecycle/index.d.ts +1 -1
  67. package/dist/services/document-lifecycle/internals.d.ts +0 -20
  68. package/dist/services/document-lifecycle/internals.js +22 -46
  69. package/dist/services/document-lifecycle/status.d.ts +1 -1
  70. package/dist/services/document-lifecycle/status.js +20 -3
  71. package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
  72. package/dist/services/document-lifecycle/system-fields.js +112 -74
  73. package/dist/services/document-lifecycle/tree.d.ts +22 -24
  74. package/dist/services/document-lifecycle/tree.js +244 -123
  75. package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
  76. package/dist/services/document-lifecycle/tree.test.node.js +663 -0
  77. package/dist/services/document-lifecycle/update.d.ts +4 -4
  78. package/dist/services/document-lifecycle/update.js +11 -5
  79. package/dist/services/document-lifecycle.test.node.js +426 -16
  80. package/dist/services/document-read.d.ts +14 -6
  81. package/dist/services/document-read.js +47 -9
  82. package/dist/services/field-upload.test.node.js +70 -0
  83. package/dist/services/index.d.ts +3 -2
  84. package/dist/services/index.js +3 -2
  85. package/dist/services/normalize-numeric-fields.d.ts +23 -0
  86. package/dist/services/normalize-numeric-fields.js +89 -0
  87. package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
  88. package/dist/services/normalize-numeric-fields.test.node.js +99 -0
  89. package/dist/services/populate.d.ts +9 -23
  90. package/dist/services/populate.js +180 -28
  91. package/dist/services/populate.test.node.js +59 -0
  92. package/dist/services/richtext-embed.d.ts +39 -2
  93. package/dist/services/richtext-embed.js +4 -34
  94. package/dist/services/richtext-embed.test.node.js +15 -0
  95. package/dist/services/richtext-populate.d.ts +21 -3
  96. package/dist/services/richtext-populate.js +160 -19
  97. package/dist/services/richtext-populate.test.node.js +523 -2
  98. package/dist/services/validate-search-config.d.ts +1 -1
  99. package/dist/storage/collection-fingerprint.js +6 -0
  100. package/dist/storage/collection-fingerprint.test.node.js +21 -0
  101. package/dist/utils/root-relative-redirect.d.ts +6 -0
  102. package/dist/utils/root-relative-redirect.js +37 -0
  103. package/package.json +7 -2
@@ -0,0 +1,431 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ const FORMAT_VERSION = 1;
10
+ const HASH_DOMAIN = '@byline/core/codegen:collection-types:v1\n';
11
+ const IMPORT_ORDER = ['JsonObject', 'JsonValue', 'RelatedDocumentValue', 'StoredFileValue'];
12
+ function compareStrings(a, b) {
13
+ return a < b ? -1 : a > b ? 1 : 0;
14
+ }
15
+ function sha256(value) {
16
+ return createHash('sha256').update(value).digest('hex');
17
+ }
18
+ function quote(value) {
19
+ return `'${value.replace(/[\\'\b\f\n\r\t\v\0\u2028\u2029]/g, (character) => {
20
+ switch (character) {
21
+ case '\\':
22
+ return '\\\\';
23
+ case "'":
24
+ return "\\'";
25
+ case '\b':
26
+ return '\\b';
27
+ case '\f':
28
+ return '\\f';
29
+ case '\n':
30
+ return '\\n';
31
+ case '\r':
32
+ return '\\r';
33
+ case '\t':
34
+ return '\\t';
35
+ case '\v':
36
+ return '\\v';
37
+ case '\0':
38
+ return '\\0';
39
+ case '\u2028':
40
+ return '\\u2028';
41
+ case '\u2029':
42
+ return '\\u2029';
43
+ default:
44
+ return character;
45
+ }
46
+ })}'`;
47
+ }
48
+ function propertyKey(value) {
49
+ return /^[$_\p{ID_Start}](?:[$_\p{ID_Continue}]|\u200C|\u200D)*$/u.test(value)
50
+ ? value
51
+ : quote(value);
52
+ }
53
+ function normalizeIdentifier(value) {
54
+ const ascii = value
55
+ .normalize('NFKD')
56
+ .replace(/[\u0300-\u036f]/g, '')
57
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
58
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2');
59
+ const words = ascii.match(/[A-Za-z0-9]+/g) ?? [];
60
+ const normalized = words
61
+ .map((word) => {
62
+ const lower = word.toLowerCase();
63
+ return `${lower[0]?.toUpperCase() ?? ''}${lower.slice(1)}`;
64
+ })
65
+ .join('');
66
+ if (normalized.length === 0)
67
+ return 'Unnamed';
68
+ return /^[0-9]/.test(normalized) ? `N${normalized}` : normalized;
69
+ }
70
+ function uniqueValues(values) {
71
+ return [...new Set(values)];
72
+ }
73
+ function assignNames(values, baseName, identity) {
74
+ const groups = new Map();
75
+ for (const value of values) {
76
+ const base = baseName(value);
77
+ const group = groups.get(base);
78
+ if (group)
79
+ group.push(value);
80
+ else
81
+ groups.set(base, [value]);
82
+ }
83
+ const result = new Map();
84
+ for (const [base, group] of groups) {
85
+ if (group.length === 1) {
86
+ const only = group[0];
87
+ if (only !== undefined)
88
+ result.set(only, base);
89
+ continue;
90
+ }
91
+ const ordered = [...group].sort((a, b) => compareStrings(identity(a), identity(b)));
92
+ const hashes = ordered.map((value) => sha256(identity(value)));
93
+ let suffixLength = 8;
94
+ while (suffixLength < 64 &&
95
+ new Set(hashes.map((hash) => hash.slice(0, suffixLength))).size !== hashes.length) {
96
+ suffixLength += 1;
97
+ }
98
+ for (const [index, value] of ordered.entries()) {
99
+ const hash = hashes[index];
100
+ if (hash === undefined)
101
+ continue;
102
+ const duplicateHashIndex = hashes.indexOf(hash);
103
+ const suffix = duplicateHashIndex === index ? hash.slice(0, suffixLength) : `${hash}_${index + 1}`;
104
+ result.set(value, `${base}_${suffix}`);
105
+ }
106
+ }
107
+ return result;
108
+ }
109
+ function assertObject(value, location) {
110
+ if (value === null || typeof value !== 'object') {
111
+ throw new Error(`emitCollectionTypes: expected an object at ${location}`);
112
+ }
113
+ }
114
+ function analyze(collections) {
115
+ const sortedCollections = [...collections].sort((a, b) => compareStrings(a.path, b.path));
116
+ const seenPaths = new Set();
117
+ const active = new Set();
118
+ const blocks = new Map();
119
+ const blockKeyByDefinition = new WeakMap();
120
+ function describeFieldSet(fields, location) {
121
+ if (!Array.isArray(fields)) {
122
+ throw new Error(`emitCollectionTypes: expected fields array at ${location}`);
123
+ }
124
+ if (active.has(fields))
125
+ throw new Error(`emitCollectionTypes: cycle detected at ${location}`);
126
+ active.add(fields);
127
+ const seenNames = new Set();
128
+ const descriptions = [];
129
+ for (const [index, fieldValue] of fields.entries()) {
130
+ const fieldLocation = `${location}[${index}]`;
131
+ assertObject(fieldValue, fieldLocation);
132
+ const field = fieldValue;
133
+ if (typeof field.name !== 'string') {
134
+ throw new Error(`emitCollectionTypes: expected field name at ${fieldLocation}`);
135
+ }
136
+ if (seenNames.has(field.name)) {
137
+ throw new Error(`emitCollectionTypes: duplicate sibling field ${quote(field.name)} at ${location}`);
138
+ }
139
+ seenNames.add(field.name);
140
+ descriptions.push(describeField(field, fieldLocation));
141
+ }
142
+ active.delete(fields);
143
+ return JSON.stringify(descriptions);
144
+ }
145
+ function describeBlock(blockValue, location) {
146
+ assertObject(blockValue, location);
147
+ const block = blockValue;
148
+ if (active.has(block))
149
+ throw new Error(`emitCollectionTypes: cycle detected at ${location}`);
150
+ active.add(block);
151
+ if (typeof block.blockType !== 'string') {
152
+ throw new Error(`emitCollectionTypes: expected blockType at ${location}`);
153
+ }
154
+ const fieldsSignature = describeFieldSet(block.fields, `${location}.fields`);
155
+ const key = JSON.stringify([block.blockType, fieldsSignature]);
156
+ blockKeyByDefinition.set(block, key);
157
+ if (!blocks.has(key)) {
158
+ blocks.set(key, { blockType: block.blockType, fields: block.fields, key });
159
+ }
160
+ active.delete(block);
161
+ return key;
162
+ }
163
+ function describeField(field, location) {
164
+ if (active.has(field))
165
+ throw new Error(`emitCollectionTypes: cycle detected at ${location}`);
166
+ active.add(field);
167
+ const modifiers = [field.optional === true, field.localized === true];
168
+ let specific;
169
+ switch (field.type) {
170
+ case 'array':
171
+ case 'group':
172
+ specific = describeFieldSet(field.fields, `${location}.fields`);
173
+ break;
174
+ case 'blocks': {
175
+ if (!Array.isArray(field.blocks)) {
176
+ throw new Error(`emitCollectionTypes: expected blocks array at ${location}.blocks`);
177
+ }
178
+ if (active.has(field.blocks)) {
179
+ throw new Error(`emitCollectionTypes: cycle detected at ${location}.blocks`);
180
+ }
181
+ active.add(field.blocks);
182
+ const seenBlockTypes = new Set();
183
+ specific = field.blocks.map((block, index) => {
184
+ if (seenBlockTypes.has(block.blockType)) {
185
+ throw new Error(`emitCollectionTypes: duplicate local block type ${quote(block.blockType)} at ${location}`);
186
+ }
187
+ seenBlockTypes.add(block.blockType);
188
+ return describeBlock(block, `${location}.blocks[${index}]`);
189
+ });
190
+ active.delete(field.blocks);
191
+ break;
192
+ }
193
+ case 'select':
194
+ specific = uniqueValues(field.options.map((option) => option.value));
195
+ break;
196
+ case 'relation':
197
+ specific = field.hasMany === true;
198
+ break;
199
+ case 'boolean':
200
+ case 'checkbox':
201
+ case 'counter':
202
+ case 'date':
203
+ case 'datetime':
204
+ case 'decimal':
205
+ case 'file':
206
+ case 'float':
207
+ case 'image':
208
+ case 'integer':
209
+ case 'json':
210
+ case 'object':
211
+ case 'richText':
212
+ case 'text':
213
+ case 'textArea':
214
+ case 'time':
215
+ specific = null;
216
+ break;
217
+ default: {
218
+ const exhaustive = field;
219
+ throw new Error(`emitCollectionTypes: unsupported runtime field type ${quote(String(exhaustive.type))} at ${location}`);
220
+ }
221
+ }
222
+ active.delete(field);
223
+ return JSON.stringify([field.name, field.type, ...modifiers, specific]);
224
+ }
225
+ for (const collection of sortedCollections) {
226
+ assertObject(collection, 'collections[]');
227
+ if (typeof collection.path !== 'string') {
228
+ throw new Error('emitCollectionTypes: expected collection path');
229
+ }
230
+ if (seenPaths.has(collection.path)) {
231
+ throw new Error(`emitCollectionTypes: duplicate collection path ${quote(collection.path)}`);
232
+ }
233
+ seenPaths.add(collection.path);
234
+ describeFieldSet(collection.fields, `collection ${quote(collection.path)}.fields`);
235
+ }
236
+ return { blockKeyByDefinition, blocks, collections: sortedCollections };
237
+ }
238
+ function indentLines(lines, spaces) {
239
+ const indentation = ' '.repeat(spaces);
240
+ return lines.map((line) => `${indentation}${line}`);
241
+ }
242
+ function objectType(properties) {
243
+ if (properties.length === 0)
244
+ return ['{}'];
245
+ const lines = ['{'];
246
+ for (const property of properties) {
247
+ const first = property.value[0] ?? 'never';
248
+ const suffix = property.optional ? ' | undefined' : '';
249
+ if (property.value.length === 1) {
250
+ lines.push(` ${propertyKey(property.key)}${property.optional ? '?' : ''}: ${first}${suffix}`);
251
+ }
252
+ else {
253
+ lines.push(` ${propertyKey(property.key)}${property.optional ? '?' : ''}: ${first}`);
254
+ lines.push(...indentLines(property.value.slice(1), 2));
255
+ if (suffix)
256
+ lines[lines.length - 1] = `${lines[lines.length - 1]}${suffix}`;
257
+ }
258
+ }
259
+ lines.push('}');
260
+ return lines;
261
+ }
262
+ function wrapGeneric(name, value) {
263
+ if (value.length === 1)
264
+ return [`${name}<${value[0]}>`];
265
+ return [`${name}<`, ...indentLines(value, 2), '>'];
266
+ }
267
+ function wrapLocaleMap(value) {
268
+ if (value.length === 1)
269
+ return [`{ [locale: string]: ${value[0]} }`];
270
+ return ['{', ` [locale: string]: ${value[0]}`, ...indentLines(value.slice(1), 2), '}'];
271
+ }
272
+ function emitBody(analysis) {
273
+ const imports = new Set();
274
+ const rawBlocks = [...analysis.blocks.values()];
275
+ const blockNames = assignNames(rawBlocks, (block) => {
276
+ const stem = normalizeIdentifier(block.blockType);
277
+ return stem.endsWith('Block') ? `${stem}Data` : `${stem}BlockData`;
278
+ }, (block) => block.key);
279
+ const blocks = rawBlocks
280
+ .map((block) => ({ ...block, alias: blockNames.get(block) ?? 'UnnamedBlock' }))
281
+ .sort((a, b) => compareStrings(a.alias, b.alias));
282
+ const collectionNames = assignNames(analysis.collections, (collection) => `${normalizeIdentifier(collection.path)}Fields`, (collection) => collection.path);
283
+ const collectionContracts = analysis.collections.map((definition) => ({
284
+ alias: collectionNames.get(definition) ?? 'UnnamedFields',
285
+ definition,
286
+ }));
287
+ const blockAliasByKey = new Map(blocks.map((block) => [block.key, block.alias]));
288
+ function fieldSetType(fields, mode) {
289
+ return objectType(fields.map((field) => ({
290
+ key: field.name,
291
+ optional: field.optional === true,
292
+ value: fieldType(field, mode),
293
+ })));
294
+ }
295
+ function fieldType(field, mode) {
296
+ let value;
297
+ switch (field.type) {
298
+ case 'array':
299
+ value = wrapGeneric('Array', objectType([
300
+ { key: '_id', value: ['string'] },
301
+ ...field.fields.map((child) => ({
302
+ key: child.name,
303
+ optional: child.optional === true,
304
+ value: fieldType(child, mode),
305
+ })),
306
+ ]));
307
+ break;
308
+ case 'blocks': {
309
+ const aliases = field.blocks.map((block) => {
310
+ const key = analysis.blockKeyByDefinition.get(block);
311
+ const alias = key === undefined ? undefined : blockAliasByKey.get(key);
312
+ if (alias === undefined) {
313
+ throw new Error(`emitCollectionTypes: missing analyzed block ${quote(block.blockType)}`);
314
+ }
315
+ return mode === 'all' ? `${alias}AllLocales` : alias;
316
+ });
317
+ value = [`Array<${aliases.length === 0 ? 'never' : aliases.join(' | ')}>`];
318
+ break;
319
+ }
320
+ case 'group':
321
+ value = fieldSetType(field.fields, mode);
322
+ break;
323
+ case 'select':
324
+ value = [
325
+ field.options.length === 0
326
+ ? 'never'
327
+ : uniqueValues(field.options.map((option) => option.value))
328
+ .map(quote)
329
+ .join(' | '),
330
+ ];
331
+ break;
332
+ case 'relation':
333
+ imports.add('RelatedDocumentValue');
334
+ value = [field.hasMany === true ? 'RelatedDocumentValue[]' : 'RelatedDocumentValue'];
335
+ break;
336
+ case 'boolean':
337
+ case 'checkbox':
338
+ value = ['boolean'];
339
+ break;
340
+ case 'date':
341
+ case 'datetime':
342
+ value = ['Date'];
343
+ break;
344
+ case 'decimal':
345
+ case 'text':
346
+ case 'textArea':
347
+ case 'time':
348
+ value = ['string'];
349
+ break;
350
+ case 'counter':
351
+ case 'float':
352
+ case 'integer':
353
+ value = ['number'];
354
+ break;
355
+ case 'json':
356
+ case 'richText':
357
+ imports.add('JsonValue');
358
+ value = ['JsonValue'];
359
+ break;
360
+ case 'object':
361
+ imports.add('JsonObject');
362
+ value = ['JsonObject'];
363
+ break;
364
+ case 'file':
365
+ case 'image':
366
+ imports.add('StoredFileValue');
367
+ value = ['StoredFileValue'];
368
+ break;
369
+ default: {
370
+ const exhaustive = field;
371
+ throw new Error(`emitCollectionTypes: unsupported runtime field type ${String(exhaustive)}`);
372
+ }
373
+ }
374
+ return mode === 'all' && field.localized === true ? wrapLocaleMap(value) : value;
375
+ }
376
+ const declarations = [];
377
+ for (const block of blocks) {
378
+ const baseProperties = [
379
+ { key: '_id', value: ['string'] },
380
+ { key: '_type', value: [quote(block.blockType)] },
381
+ ];
382
+ declarations.push(`export type ${block.alias} = ${objectType([
383
+ ...baseProperties,
384
+ ...block.fields.map((field) => ({
385
+ key: field.name,
386
+ optional: field.optional === true,
387
+ value: fieldType(field, 'single'),
388
+ })),
389
+ ]).join('\n')}`, '', `export type ${block.alias}AllLocales = ${objectType([
390
+ ...baseProperties,
391
+ ...block.fields.map((field) => ({
392
+ key: field.name,
393
+ optional: field.optional === true,
394
+ value: fieldType(field, 'all'),
395
+ })),
396
+ ]).join('\n')}`, '');
397
+ }
398
+ for (const collection of [...collectionContracts].sort((a, b) => compareStrings(a.alias, b.alias))) {
399
+ declarations.push(`export type ${collection.alias} = ${fieldSetType(collection.definition.fields, 'single').join('\n')}`, '', `export type ${collection.alias}AllLocales = ${fieldSetType(collection.definition.fields, 'all').join('\n')}`, '');
400
+ }
401
+ declarations.push('export type CollectionFieldsByPath = {');
402
+ for (const collection of collectionContracts) {
403
+ declarations.push(` ${propertyKey(collection.definition.path)}: ${collection.alias}`);
404
+ }
405
+ declarations.push('}', '', 'export type CollectionFieldsAllLocalesByPath = {');
406
+ for (const collection of collectionContracts) {
407
+ declarations.push(` ${propertyKey(collection.definition.path)}: ${collection.alias}AllLocales`);
408
+ }
409
+ declarations.push('}', '', 'export type CollectionPath = keyof CollectionFieldsByPath');
410
+ const usedImports = IMPORT_ORDER.filter((name) => imports.has(name));
411
+ const importLines = usedImports.length === 0
412
+ ? []
413
+ : ['import type {', ...usedImports.map((name) => ` ${name},`), "} from '@byline/core'", ''];
414
+ return `${[...importLines, ...declarations].join('\n').replace(/\n+$/, '')}\n`;
415
+ }
416
+ /**
417
+ * Emit deterministic application collection types from evaluated runtime definitions.
418
+ * The returned hash is SHA-256 over the versioned format domain and generated module body.
419
+ */
420
+ export function emitCollectionTypes(collections) {
421
+ const body = emitBody(analyze(collections));
422
+ const hash = sha256(`${HASH_DOMAIN}${body}`);
423
+ const header = [
424
+ '// Generated by @byline/core/codegen',
425
+ `// Format version: ${FORMAT_VERSION}`,
426
+ `// Hash: ${hash}`,
427
+ '',
428
+ '',
429
+ ].join('\n');
430
+ return { source: `${header}${body}`, hash };
431
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,230 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { describe, expect, expectTypeOf, it } from 'vitest';
6
+ import { AllFieldsCollection, AllFieldsCollections, } from './fixtures/all-fields.js';
7
+ import { emitCollectionTypes } from './index.js';
8
+ const fixtureDirectory = resolve(dirname(fileURLToPath(import.meta.url)), 'fixtures');
9
+ function collection(path, fields) {
10
+ return { path, labels: { singular: path, plural: path }, fields };
11
+ }
12
+ describe('emitCollectionTypes', () => {
13
+ it('matches the checked-in all-fields fixture and canonical inferred contracts exactly', () => {
14
+ const result = emitCollectionTypes(AllFieldsCollections);
15
+ const fixture = readFileSync(resolve(fixtureDirectory, 'all-fields.generated.txt'), 'utf8');
16
+ expect(result.source).toBe(fixture);
17
+ expectTypeOf().toEqualTypeOf();
18
+ expectTypeOf().toEqualTypeOf();
19
+ expectTypeOf().toEqualTypeOf();
20
+ expectTypeOf().toEqualTypeOf();
21
+ expectTypeOf().toEqualTypeOf();
22
+ expectTypeOf().toEqualTypeOf();
23
+ expectTypeOf().toEqualTypeOf();
24
+ expectTypeOf().toEqualTypeOf();
25
+ expectTypeOf().toEqualTypeOf();
26
+ expectTypeOf().toEqualTypeOf();
27
+ });
28
+ it('emits a versioned header, verifiable body hash, and canonical formatting', () => {
29
+ const { source, hash } = emitCollectionTypes([collection('plain', [])]);
30
+ const body = source.split('\n').slice(4).join('\n');
31
+ expect(source).toMatch(/^\/\/ Generated by @byline\/core\/codegen\n\/\/ Format version: 1\n\/\/ Hash: [a-f0-9]{64}\n\n/);
32
+ expect(hash).toBe(createHash('sha256').update(`@byline/core/codegen:collection-types:v1\n${body}`).digest('hex'));
33
+ expect(source).toContain(`// Hash: ${hash}`);
34
+ expect(source.endsWith('\n')).toBe(true);
35
+ expect(source).not.toContain('\r');
36
+ expect(source).not.toContain(';');
37
+ });
38
+ it('sorts collections and declarations without changing field, select, or block union order', () => {
39
+ const alpha = collection('alpha', [
40
+ {
41
+ name: 'choice',
42
+ type: 'select',
43
+ options: [
44
+ { label: 'Zulu', value: 'zulu' },
45
+ { label: 'Duplicate Zulu', value: 'zulu' },
46
+ { label: 'Alpha', value: 'alpha' },
47
+ ],
48
+ },
49
+ {
50
+ name: 'content',
51
+ type: 'blocks',
52
+ blocks: [
53
+ { blockType: 'zulu', fields: [] },
54
+ { blockType: 'alpha', fields: [] },
55
+ ],
56
+ },
57
+ ]);
58
+ const zulu = collection('zulu', [{ name: 'title', type: 'text' }]);
59
+ const forward = emitCollectionTypes([zulu, alpha]);
60
+ const reverse = emitCollectionTypes([alpha, zulu]);
61
+ expect(forward).toEqual(reverse);
62
+ expect(forward.source).toContain(`choice: 'zulu' | 'alpha'`);
63
+ expect(forward.source).toContain(`content: Array<ZuluBlockData | AlphaBlockData>`);
64
+ expect(forward.source.indexOf(`alpha: AlphaFields`)).toBeLessThan(forward.source.indexOf(`zulu: ZuluFields`));
65
+ });
66
+ it('normalizes identifiers and quotes only invalid property keys', () => {
67
+ const source = emitCollectionTypes([
68
+ collection('API_response', [
69
+ { name: 'validKey', type: 'text' },
70
+ { name: 'café', type: 'text' },
71
+ { name: 'not-valid', type: 'text' },
72
+ {
73
+ name: 'choice',
74
+ type: 'select',
75
+ options: [
76
+ { label: 'First', value: 'first' },
77
+ { label: 'First duplicate', value: 'first' },
78
+ { label: 'Second', value: 'second' },
79
+ ],
80
+ },
81
+ {
82
+ name: 'content',
83
+ type: 'blocks',
84
+ blocks: [{ blockType: 'photoBlock', fields: [] }],
85
+ },
86
+ ]),
87
+ collection('photoBlock', []),
88
+ collection('café—API_response', []),
89
+ ]).source;
90
+ expect(source).toContain('export type ApiResponseFields = {');
91
+ expect(source).toContain('export type PhotoBlockFields = {}');
92
+ expect(source).toContain('export type CafeApiResponseFields = {}');
93
+ expect(source).toContain('export type PhotoBlockData = {');
94
+ expect(source).toContain('validKey: string');
95
+ expect(source).toContain('café: string');
96
+ expect(source).toContain(`'not-valid': string`);
97
+ expect(source).toContain(`choice: 'first' | 'second'`);
98
+ expect(source).toContain('API_response: ApiResponseFields');
99
+ expect(source).toContain(`'café—API_response': CafeApiResponseFields`);
100
+ });
101
+ it('ignores non-type metadata but changes output for type-significant values', () => {
102
+ const base = collection('articles', [
103
+ { name: 'title', type: 'text' },
104
+ { name: 'kind', type: 'select', options: [{ label: 'News', value: 'news' }] },
105
+ ]);
106
+ const metadataOnly = {
107
+ ...base,
108
+ labels: { singular: 'Story', plural: 'Stories' },
109
+ version: 99,
110
+ fields: [
111
+ { name: 'title', type: 'text', label: 'Headline', helpText: 'Displayed prominently' },
112
+ {
113
+ name: 'kind',
114
+ type: 'select',
115
+ label: 'Kind',
116
+ options: [{ label: 'Article', value: 'news' }],
117
+ },
118
+ ],
119
+ };
120
+ const changed = collection('articles', [
121
+ { name: 'title', type: 'integer' },
122
+ { name: 'kind', type: 'select', options: [{ label: 'News', value: 'report' }] },
123
+ ]);
124
+ expect(emitCollectionTypes([metadataOnly])).toEqual(emitCollectionTypes([base]));
125
+ expect(emitCollectionTypes([changed]).hash).not.toBe(emitCollectionTypes([base]).hash);
126
+ });
127
+ it('imports only used canonical types in fixed order', () => {
128
+ const noImports = emitCollectionTypes([
129
+ collection('simple', [{ name: 'title', type: 'text' }]),
130
+ ]).source;
131
+ const selectedImports = emitCollectionTypes([
132
+ collection('typed', [
133
+ { name: 'data', type: 'json' },
134
+ { name: 'relation', type: 'relation', targetCollection: 'simple' },
135
+ { name: 'file', type: 'file' },
136
+ ]),
137
+ ]).source;
138
+ expect(noImports).not.toContain('import type');
139
+ expect(selectedImports).toContain("import type {\n JsonValue,\n RelatedDocumentValue,\n StoredFileValue,\n} from '@byline/core'");
140
+ expect(selectedImports).not.toContain('JsonObject');
141
+ });
142
+ it('reuses identical block contracts and suffixes incompatible normalized names', () => {
143
+ const sharedA = { blockType: 'hero', fields: [{ name: 'title', type: 'text' }] };
144
+ const sharedB = {
145
+ blockType: 'hero',
146
+ fields: [{ name: 'title', type: 'text', label: 'Ignored metadata' }],
147
+ };
148
+ const incompatible = {
149
+ blockType: 'hero',
150
+ fields: [{ name: 'title', type: 'integer' }],
151
+ };
152
+ const source = emitCollectionTypes([
153
+ collection('foo-bar', [{ name: 'content', type: 'blocks', blocks: [sharedA] }]),
154
+ collection('foo_bar', [{ name: 'content', type: 'blocks', blocks: [sharedB] }]),
155
+ collection('other', [{ name: 'content', type: 'blocks', blocks: [incompatible] }]),
156
+ ]).source;
157
+ expect(source.match(/export type HeroBlockData_[a-f0-9]+ =/g)).toHaveLength(2);
158
+ expect(source.match(/export type FooBarFields_[a-f0-9]+ =/g)).toHaveLength(2);
159
+ expect(source.match(/_type: 'hero'/g)).toHaveLength(4);
160
+ });
161
+ it('escapes property keys, block discriminants, and select literals', () => {
162
+ const source = emitCollectionTypes([AllFieldsCollection]).source;
163
+ expect(source).toContain(`'content\\'s':`);
164
+ expect(source).toContain(`_type: 'quote\\'block'`);
165
+ expect(source).toContain(`'tone\\'choice'?: 'calm' | 'author\\'s' | undefined`);
166
+ });
167
+ it('does not mutate definitions and accepts deeply frozen input', () => {
168
+ const field = Object.freeze({ name: 'title', type: 'text' });
169
+ const fields = Object.freeze([field]);
170
+ const definition = Object.freeze({
171
+ path: 'frozen',
172
+ labels: Object.freeze({ singular: 'Frozen', plural: 'Frozen' }),
173
+ fields,
174
+ });
175
+ const before = JSON.stringify(definition);
176
+ emitCollectionTypes(Object.freeze([definition]));
177
+ expect(JSON.stringify(definition)).toBe(before);
178
+ });
179
+ it('rejects duplicate collection paths', () => {
180
+ expect(() => emitCollectionTypes([collection('same', []), collection('same', [])])).toThrow("duplicate collection path 'same'");
181
+ });
182
+ it('rejects duplicate sibling fields at any nesting level', () => {
183
+ expect(() => emitCollectionTypes([
184
+ collection('duplicates', [
185
+ {
186
+ name: 'group',
187
+ type: 'group',
188
+ fields: [
189
+ { name: 'same', type: 'text' },
190
+ { name: 'same', type: 'integer' },
191
+ ],
192
+ },
193
+ ]),
194
+ ])).toThrow("duplicate sibling field 'same'");
195
+ });
196
+ it('rejects duplicate block types within one blocks field', () => {
197
+ expect(() => emitCollectionTypes([
198
+ collection('duplicates', [
199
+ {
200
+ name: 'content',
201
+ type: 'blocks',
202
+ blocks: [
203
+ { blockType: 'hero', fields: [] },
204
+ { blockType: 'hero', fields: [{ name: 'title', type: 'text' }] },
205
+ ],
206
+ },
207
+ ]),
208
+ ])).toThrow("duplicate local block type 'hero'");
209
+ });
210
+ it('rejects cyclic runtime definitions', () => {
211
+ const fields = [];
212
+ fields.push({ name: 'recursive', type: 'group', fields });
213
+ expect(() => emitCollectionTypes([collection('cycle', fields)])).toThrow('cycle detected');
214
+ });
215
+ it('rejects unsupported runtime field types', () => {
216
+ expect(() => emitCollectionTypes([
217
+ collection('unsupported', [{ name: 'future', type: 'future' }]),
218
+ ])).toThrow("unsupported runtime field type 'future'");
219
+ });
220
+ it('is exposed only through the published codegen subpath', () => {
221
+ const packageJson = JSON.parse(readFileSync(resolve(fixtureDirectory, '../../../package.json'), 'utf8'));
222
+ const rootSource = readFileSync(resolve(fixtureDirectory, '../../index.ts'), 'utf8');
223
+ expect(packageJson.exports['./codegen']).toEqual({
224
+ types: './dist/codegen/index.d.ts',
225
+ import: './dist/codegen/index.js',
226
+ require: './dist/codegen/index.js',
227
+ });
228
+ expect(rootSource).not.toMatch(/codegen/);
229
+ });
230
+ });