@beechcms/core 0.8.1 → 0.9.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 (163) hide show
  1. package/dist/antivirus/noop-antivirus.provider.d.ts.map +1 -1
  2. package/dist/antivirus/virustotal-antivirus.provider.d.ts.map +1 -1
  3. package/dist/auth/account-email.d.ts +3 -0
  4. package/dist/auth/account-email.d.ts.map +1 -0
  5. package/dist/auth/account-email.js +9 -0
  6. package/dist/auth/password-reset-token.repository.d.ts +13 -2
  7. package/dist/auth/password-reset-token.repository.d.ts.map +1 -1
  8. package/dist/auth/user.repository.d.ts +69 -4
  9. package/dist/auth/user.repository.d.ts.map +1 -1
  10. package/dist/common/canonical-json.d.ts +2 -1
  11. package/dist/common/canonical-json.d.ts.map +1 -1
  12. package/dist/common/canonical-json.js +5 -3
  13. package/dist/content/content-scan.repository.d.ts +1 -1
  14. package/dist/content/content.repository.d.ts +156 -5
  15. package/dist/content/content.repository.d.ts.map +1 -1
  16. package/dist/content/content.repository.js +62 -0
  17. package/dist/content/seed-media-purge.repository.d.ts +34 -0
  18. package/dist/content/seed-media-purge.repository.d.ts.map +1 -0
  19. package/dist/content/seed-media-purge.repository.js +3 -0
  20. package/dist/content/seed.repository.d.ts +14 -0
  21. package/dist/content/seed.repository.d.ts.map +1 -1
  22. package/dist/dashboard-layout/content-view.d.ts +757 -0
  23. package/dist/dashboard-layout/content-view.d.ts.map +1 -0
  24. package/dist/dashboard-layout/content-view.js +285 -0
  25. package/dist/dashboard-layout/content-view.repository.d.ts +39 -0
  26. package/dist/dashboard-layout/content-view.repository.d.ts.map +1 -0
  27. package/dist/dashboard-layout/content-view.repository.js +3 -0
  28. package/dist/dashboard-layout/dashboard-layout.d.ts +5 -0
  29. package/dist/dashboard-layout/dashboard-layout.d.ts.map +1 -1
  30. package/dist/dashboard-layout/dashboard-layout.js +17 -9
  31. package/dist/dashboard-layout/kanban/kanban.d.ts +32 -18
  32. package/dist/dashboard-layout/kanban/kanban.d.ts.map +1 -1
  33. package/dist/dashboard-layout/kanban/kanban.js +45 -21
  34. package/dist/dashboard-layout/seed-layout.d.ts +22 -31
  35. package/dist/dashboard-layout/seed-layout.d.ts.map +1 -1
  36. package/dist/dashboard-layout/seed-layout.js +93 -18
  37. package/dist/dashboard-layout/seed-layout.repository.d.ts +1 -5
  38. package/dist/dashboard-layout/seed-layout.repository.d.ts.map +1 -1
  39. package/dist/engine/ddl/ddl.d.ts +31 -0
  40. package/dist/engine/ddl/ddl.d.ts.map +1 -1
  41. package/dist/engine/ddl/ddl.js +92 -13
  42. package/dist/engine/ddl/schema-mutator.d.ts +3 -0
  43. package/dist/engine/ddl/schema-mutator.d.ts.map +1 -1
  44. package/dist/engine/ddl/seed-purge-guards.d.ts +7 -0
  45. package/dist/engine/ddl/seed-purge-guards.d.ts.map +1 -0
  46. package/dist/engine/ddl/seed-purge-guards.js +19 -0
  47. package/dist/engine/engine.d.ts +4 -3
  48. package/dist/engine/engine.d.ts.map +1 -1
  49. package/dist/engine/engine.js +4 -3
  50. package/dist/engine/introspection/relation-audit.d.ts +46 -0
  51. package/dist/engine/introspection/relation-audit.d.ts.map +1 -0
  52. package/dist/engine/introspection/relation-audit.js +103 -0
  53. package/dist/engine/introspection/schema-fingerprint.d.ts +2 -1
  54. package/dist/engine/introspection/schema-fingerprint.d.ts.map +1 -1
  55. package/dist/engine/introspection/schema-fingerprint.js +8 -3
  56. package/dist/engine/privacy/policies.d.ts +1 -1
  57. package/dist/engine/privacy/policies.d.ts.map +1 -1
  58. package/dist/engine/privacy/policies.js +12 -6
  59. package/dist/engine/privacy/privacy.service.d.ts +13 -0
  60. package/dist/engine/privacy/privacy.service.d.ts.map +1 -1
  61. package/dist/engine/privacy/privacy.service.js +24 -0
  62. package/dist/engine/query/active-clause.d.ts +19 -0
  63. package/dist/engine/query/active-clause.d.ts.map +1 -0
  64. package/dist/engine/query/active-clause.js +27 -0
  65. package/dist/engine/query/query.d.ts +14 -1
  66. package/dist/engine/query/query.d.ts.map +1 -1
  67. package/dist/engine/query/query.js +105 -13
  68. package/dist/engine/seeds/candidate-ids.d.ts +14 -0
  69. package/dist/engine/seeds/candidate-ids.d.ts.map +1 -0
  70. package/dist/engine/seeds/candidate-ids.js +40 -0
  71. package/dist/engine/seeds/seed-definition-diff.d.ts +38 -0
  72. package/dist/engine/seeds/seed-definition-diff.d.ts.map +1 -0
  73. package/dist/engine/seeds/seed-definition-diff.js +51 -0
  74. package/dist/engine/seeds/seed-registry.d.ts +17 -0
  75. package/dist/engine/seeds/seed-registry.d.ts.map +1 -1
  76. package/dist/engine/seeds/seed-registry.js +27 -0
  77. package/dist/engine/seeds/seed-types-generator.d.ts.map +1 -1
  78. package/dist/engine/seeds/seed-types-generator.js +4 -5
  79. package/dist/engine/seeds/seed-validation.d.ts +5 -0
  80. package/dist/engine/seeds/seed-validation.d.ts.map +1 -1
  81. package/dist/engine/seeds/seed-validation.js +39 -19
  82. package/dist/engine/seeds/tag-migration.d.ts +73 -0
  83. package/dist/engine/seeds/tag-migration.d.ts.map +1 -0
  84. package/dist/engine/seeds/tag-migration.js +212 -0
  85. package/dist/engine/seeds/tag-options.d.ts +15 -0
  86. package/dist/engine/seeds/tag-options.d.ts.map +1 -0
  87. package/dist/engine/seeds/tag-options.js +111 -0
  88. package/dist/engine/seeds/tag-value.d.ts +31 -0
  89. package/dist/engine/seeds/tag-value.d.ts.map +1 -0
  90. package/dist/engine/seeds/tag-value.js +51 -0
  91. package/dist/engine/types.d.ts +59 -7
  92. package/dist/engine/types.d.ts.map +1 -1
  93. package/dist/engine/validation/cache.d.ts.map +1 -1
  94. package/dist/engine/validation/cache.js +20 -4
  95. package/dist/engine/validation/index.d.ts +12 -1
  96. package/dist/engine/validation/index.d.ts.map +1 -1
  97. package/dist/engine/validation/index.js +29 -33
  98. package/dist/engine/validation/primitives.d.ts +12 -0
  99. package/dist/engine/validation/primitives.d.ts.map +1 -1
  100. package/dist/engine/validation/primitives.js +22 -0
  101. package/dist/engine/validation/richtext-sanitizer.d.ts +9 -1
  102. package/dist/engine/validation/richtext-sanitizer.d.ts.map +1 -1
  103. package/dist/engine/validation/richtext-sanitizer.js +36 -7
  104. package/dist/engine/validation/schema-builders.d.ts +16 -0
  105. package/dist/engine/validation/schema-builders.d.ts.map +1 -1
  106. package/dist/engine/validation/schema-builders.js +125 -18
  107. package/dist/index.d.ts +34 -11
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +34 -11
  110. package/dist/media/file-types.d.ts +12 -0
  111. package/dist/media/file-types.d.ts.map +1 -1
  112. package/dist/media/file-types.js +24 -4
  113. package/dist/media/magic-bytes.d.ts.map +1 -1
  114. package/dist/media/magic-bytes.js +19 -9
  115. package/dist/media/media-transform.d.ts +8 -4
  116. package/dist/media/media-transform.d.ts.map +1 -1
  117. package/dist/media/media-transform.js +10 -6
  118. package/dist/media/media.repository.d.ts +5 -0
  119. package/dist/media/media.repository.d.ts.map +1 -1
  120. package/dist/notifications/notification-service.d.ts +1 -1
  121. package/dist/notifications/notification.repository.d.ts +11 -2
  122. package/dist/notifications/notification.repository.d.ts.map +1 -1
  123. package/dist/oauth/authorization-code.repository.d.ts +10 -5
  124. package/dist/oauth/authorization-code.repository.d.ts.map +1 -1
  125. package/dist/oauth/pkce.d.ts +1 -1
  126. package/dist/oauth/role-guard.d.ts +30 -11
  127. package/dist/oauth/role-guard.d.ts.map +1 -1
  128. package/dist/oauth/role-guard.js +27 -5
  129. package/dist/oauth/scopes.d.ts +1 -1
  130. package/dist/oauth/scopes.d.ts.map +1 -1
  131. package/dist/oauth/token.repository.d.ts +2 -0
  132. package/dist/oauth/token.repository.d.ts.map +1 -1
  133. package/dist/queue/queue.interface.d.ts +9 -1
  134. package/dist/queue/queue.interface.d.ts.map +1 -1
  135. package/dist/queue/queue.stub.d.ts +1 -1
  136. package/dist/queue/queue.stub.d.ts.map +1 -1
  137. package/dist/queue/queue.stub.js +2 -3
  138. package/dist/queue/scheduler.stub.d.ts.map +1 -1
  139. package/dist/rate-limit/token-bucket-rate-limiter.d.ts +7 -0
  140. package/dist/rate-limit/token-bucket-rate-limiter.d.ts.map +1 -1
  141. package/dist/rate-limit/token-bucket-rate-limiter.js +66 -0
  142. package/dist/rbac/invitation.repository.d.ts +28 -10
  143. package/dist/rbac/invitation.repository.d.ts.map +1 -1
  144. package/dist/rbac/types.d.ts +40 -28
  145. package/dist/rbac/types.d.ts.map +1 -1
  146. package/dist/rbac/types.js +2 -1
  147. package/dist/schema/manifest-seeds.js +3 -3
  148. package/dist/search/search.repository.d.ts +5 -4
  149. package/dist/search/search.repository.d.ts.map +1 -1
  150. package/dist/search/vector-extractor.d.ts.map +1 -1
  151. package/dist/search/vector-extractor.js +23 -2
  152. package/dist/search/vector.repository.d.ts +2 -1
  153. package/dist/search/vector.repository.d.ts.map +1 -1
  154. package/dist/security/time-trap-token.repository.d.ts +5 -3
  155. package/dist/security/time-trap-token.repository.d.ts.map +1 -1
  156. package/dist/settings/site-settings.repository.d.ts +8 -0
  157. package/dist/settings/site-settings.repository.d.ts.map +1 -1
  158. package/dist/transfer/flat-seed.d.ts.map +1 -1
  159. package/dist/transfer/flat-seed.js +6 -1
  160. package/dist/transfer/row-mapping.d.ts.map +1 -1
  161. package/dist/transfer/row-mapping.js +9 -2
  162. package/package.json +8 -3
  163. package/dist/.tsbuildinfo +0 -1
@@ -0,0 +1,38 @@
1
+ import type { Branch, Seed } from '../types.js';
2
+ /** A branch as either side of a comparison carries it: stored (`Branch`, with id) or manifest-authored (id optional). */
3
+ export type BranchDefinitionLike = Omit<Branch, 'id' | 'fields'> & {
4
+ id?: string;
5
+ fields?: BranchDefinitionLike[];
6
+ };
7
+ /** A seed as either side of a comparison carries it: stored (`Seed`) or manifest-authored (no layout). */
8
+ export type SeedDefinitionLike = Omit<Seed, 'branches' | 'layout'> & {
9
+ branches: BranchDefinitionLike[];
10
+ layout?: unknown;
11
+ };
12
+ /** Seed-level flags the engine treats as `false` when omitted. */
13
+ export declare const SEED_DEFINITION_FLAGS: readonly ["allowDrafts", "allowPublicRead", "allowPublicPost", "allowPublicEdit", "softDelete"];
14
+ /** Branch-level flags the engine treats as `false` when omitted. `multiple` is resolved separately (see normalizeBranchDefinition). */
15
+ export declare const BRANCH_DEFINITION_FLAGS: readonly ["requiredOnCreate", "requiredOnUpdate"];
16
+ /**
17
+ * Comparable form of one branch definition. Two branches are the same definition iff their canonicalStringify bytes match.
18
+ *
19
+ * - `id` is dropped, recursively through `fields`.
20
+ * - `requiredOnCreate` / `requiredOnUpdate` omitted ≡ `false`.
21
+ * - `multiple` omitted ≡ `true` on `tags` (single source of truth: `isSingleChoiceTags`), ≡ `false` on every other type.
22
+ * - Every other key, unknown ones included, is copied unchanged: `options` is compared as declared (values, colors, order).
23
+ */
24
+ export declare function normalizeBranchDefinition(branch: BranchDefinitionLike): Record<string, unknown>;
25
+ /** Comparable form of one seed definition (branches normalized, order kept). `layout` is server-populated and dropped. */
26
+ export declare function normalizeSeedDefinition(seed: SeedDefinitionLike): Record<string, unknown>;
27
+ export interface SeedDefinitionDiff {
28
+ /** True when the candidate's comparable definition differs from the stored one. Always true when nothing is stored. */
29
+ changed: boolean;
30
+ /**
31
+ * Aliases, in candidate order, of candidate branches that are new (no stored branch with the same id) or whose
32
+ * comparable definition differs from the stored branch with the same id. `[]` when nothing is stored.
33
+ */
34
+ changedBranches: string[];
35
+ }
36
+ /** Compares a stored seed definition with a candidate whose ids were already resolved ({@link resolveCandidateIds}). Pure. */
37
+ export declare function diffSeedDefinition(stored: Seed | null, candidate: Seed): SeedDefinitionDiff;
38
+ //# sourceMappingURL=seed-definition-diff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seed-definition-diff.d.ts","sourceRoot":"","sources":["../../../src/engine/seeds/seed-definition-diff.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAI/C,yHAAyH;AACzH,MAAM,MAAM,oBAAoB,GAAG,IAAI,CAAC,MAAM,EAAE,IAAI,GAAG,QAAQ,CAAC,GAAG;IAAE,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,oBAAoB,EAAE,CAAA;CAAE,CAAA;AAEnH,0GAA0G;AAC1G,MAAM,MAAM,kBAAkB,GAAG,IAAI,CAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,CAAC,GAAG;IAAE,QAAQ,EAAE,oBAAoB,EAAE,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,CAAA;AAE3H,kEAAkE;AAClE,eAAO,MAAM,qBAAqB,iGAAkG,CAAA;AAEpI,uIAAuI;AACvI,eAAO,MAAM,uBAAuB,mDAAoD,CAAA;AAExF;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,oBAAoB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAO/F;AAED,0HAA0H;AAC1H,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAMzF;AAED,MAAM,WAAW,kBAAkB;IACjC,uHAAuH;IACvH,OAAO,EAAE,OAAO,CAAA;IAChB;;;OAGG;IACH,eAAe,EAAE,MAAM,EAAE,CAAA;CAC1B;AAED,8HAA8H;AAC9H,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,IAAI,GAAG,IAAI,EAAE,SAAS,EAAE,IAAI,GAAG,kBAAkB,CAe3F"}
@@ -0,0 +1,51 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024–2026 Flavio De Musso
3
+ import { canonicalStringify } from '../../common/canonical-json.js';
4
+ import { isSingleChoiceTags } from './tag-options.js';
5
+ /** Seed-level flags the engine treats as `false` when omitted. */
6
+ export const SEED_DEFINITION_FLAGS = ['allowDrafts', 'allowPublicRead', 'allowPublicPost', 'allowPublicEdit', 'softDelete'];
7
+ /** Branch-level flags the engine treats as `false` when omitted. `multiple` is resolved separately (see normalizeBranchDefinition). */
8
+ export const BRANCH_DEFINITION_FLAGS = ['requiredOnCreate', 'requiredOnUpdate'];
9
+ /**
10
+ * Comparable form of one branch definition. Two branches are the same definition iff their canonicalStringify bytes match.
11
+ *
12
+ * - `id` is dropped, recursively through `fields`.
13
+ * - `requiredOnCreate` / `requiredOnUpdate` omitted ≡ `false`.
14
+ * - `multiple` omitted ≡ `true` on `tags` (single source of truth: `isSingleChoiceTags`), ≡ `false` on every other type.
15
+ * - Every other key, unknown ones included, is copied unchanged: `options` is compared as declared (values, colors, order).
16
+ */
17
+ export function normalizeBranchDefinition(branch) {
18
+ const { id: _id, fields, ...rest } = branch;
19
+ const normalized = { ...rest };
20
+ for (const flag of BRANCH_DEFINITION_FLAGS)
21
+ normalized[flag] = branch[flag] === true;
22
+ normalized.multiple = branch.type === 'tags' ? !isSingleChoiceTags(branch) : branch.multiple === true;
23
+ if (fields !== undefined)
24
+ normalized.fields = fields.map(normalizeBranchDefinition);
25
+ return normalized;
26
+ }
27
+ /** Comparable form of one seed definition (branches normalized, order kept). `layout` is server-populated and dropped. */
28
+ export function normalizeSeedDefinition(seed) {
29
+ const { layout: _layout, branches, ...rest } = seed;
30
+ const normalized = { ...rest };
31
+ for (const flag of SEED_DEFINITION_FLAGS)
32
+ normalized[flag] = seed[flag] === true;
33
+ normalized.branches = (branches ?? []).map(normalizeBranchDefinition);
34
+ return normalized;
35
+ }
36
+ /** Compares a stored seed definition with a candidate whose ids were already resolved ({@link resolveCandidateIds}). Pure. */
37
+ export function diffSeedDefinition(stored, candidate) {
38
+ if (stored === null)
39
+ return { changed: true, changedBranches: [] };
40
+ const storedById = new Map(stored.branches.map(branch => [branch.id, branch]));
41
+ const changedBranches = candidate.branches
42
+ .filter(branch => {
43
+ const previous = storedById.get(branch.id);
44
+ return previous === undefined
45
+ || canonicalStringify(normalizeBranchDefinition(branch)) !== canonicalStringify(normalizeBranchDefinition(previous));
46
+ })
47
+ .map(branch => branch.alias);
48
+ const changed = changedBranches.length > 0
49
+ || canonicalStringify(normalizeSeedDefinition(candidate)) !== canonicalStringify(normalizeSeedDefinition(stored));
50
+ return { changed, changedBranches };
51
+ }
@@ -40,6 +40,23 @@ export declare class SeedRegistry implements ISeedRegistry {
40
40
  publicReadable(): Seed[];
41
41
  draftEnabled(): Seed[];
42
42
  }
43
+ /** A seed the registry refused to serve, with the validation failure that excluded it. */
44
+ export interface QuarantinedSeed {
45
+ slug: string;
46
+ reason: string;
47
+ }
48
+ /**
49
+ * Builds a registry from every seed that validates and sets aside the ones that do not.
50
+ *
51
+ * `new SeedRegistry(seeds)` is all-or-nothing: one seed holding a legacy shape (say pre-0.9 tag
52
+ * options) throws and takes the whole API down. Validation is per seed and independent, so this
53
+ * validates each seed alone and leaves the failing ones out. Callers log `quarantined`; the rest of
54
+ * the system keeps serving.
55
+ */
56
+ export declare function createTolerantSeedRegistry(seeds: ReadonlyArray<Seed>): {
57
+ registry: SeedRegistry;
58
+ quarantined: QuarantinedSeed[];
59
+ };
43
60
  /**
44
61
  * Named subclass of SeedRegistry for use in test suites.
45
62
  * Gives tests a semantic name without requiring any dependency on factory.ts.
@@ -1 +1 @@
1
- {"version":3,"file":"seed-registry.d.ts","sourceRoot":"","sources":["../../../src/engine/seeds/seed-registry.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAI/C,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,GAAG,IAAI,IAAI,EAAE,CAAA;IAEb;;;;OAIG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAA;IAE9B;;;;;OAKG;IACH,kBAAkB,IAAI,IAAI,EAAE,CAAA;IAE5B;;;OAGG;IACH,cAAc,IAAI,IAAI,EAAE,CAAA;IAExB;;;OAGG;IACH,YAAY,IAAI,IAAI,EAAE,CAAA;CACvB;AAID,qBAAa,YAAa,YAAW,aAAa;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmB;IAC3C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;gBAEzB,KAAK,EAAE,IAAI,EAAE;IAkCzB,GAAG,IAAI,IAAI,EAAE;IAIb,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAI9B,kBAAkB,IAAI,IAAI,EAAE;IAI5B,cAAc,IAAI,IAAI,EAAE;IAIxB,YAAY,IAAI,IAAI,EAAE;CAGvB;AAED;;;GAGG;AACH,qBAAa,oBAAqB,SAAQ,YAAY;CAAG;AAEzD,gFAAgF;AAChF,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAEpE;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,GAAG,MAAM,CAQjE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,aAAa,CAAC,IAAI,CAAC,GAAG,IAAI,EAAE,CA0F1E"}
1
+ {"version":3,"file":"seed-registry.d.ts","sourceRoot":"","sources":["../../../src/engine/seeds/seed-registry.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAK/C,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,GAAG,IAAI,IAAI,EAAE,CAAA;IAEb;;;;OAIG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAA;IAE9B;;;;;OAKG;IACH,kBAAkB,IAAI,IAAI,EAAE,CAAA;IAE5B;;;OAGG;IACH,cAAc,IAAI,IAAI,EAAE,CAAA;IAExB;;;OAGG;IACH,YAAY,IAAI,IAAI,EAAE,CAAA;CACvB;AAID,qBAAa,YAAa,YAAW,aAAa;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmB;IAC3C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;gBAEzB,KAAK,EAAE,IAAI,EAAE;IAsCzB,GAAG,IAAI,IAAI,EAAE;IAIb,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAI9B,kBAAkB,IAAI,IAAI,EAAE;IAI5B,cAAc,IAAI,IAAI,EAAE;IAIxB,YAAY,IAAI,IAAI,EAAE;CAGvB;AAED,0FAA0F;AAC1F,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,aAAa,CAAC,IAAI,CAAC,GAAG;IACtE,QAAQ,EAAE,YAAY,CAAA;IACtB,WAAW,EAAE,eAAe,EAAE,CAAA;CAC/B,CAYA;AAED;;;GAGG;AACH,qBAAa,oBAAqB,SAAQ,YAAY;CAAG;AAEzD,gFAAgF;AAChF,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAEpE;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,GAAG,MAAM,CAQjE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,aAAa,CAAC,IAAI,CAAC,GAAG,IAAI,EAAE,CA0F1E"}
@@ -2,6 +2,7 @@
2
2
  // Copyright (c) 2024–2026 Flavio De Musso
3
3
  import { AUTOMATION_RESERVED_WORDS } from '../../automations/automations-grammar-words.js';
4
4
  import { SQL_RESERVED_WORDS } from '../sql-reserved-words.js';
5
+ import { tagOptionsIssues } from './tag-options.js';
5
6
  const BRANCH_ID_RE = /^br_[A-Za-z0-9]+$/;
6
7
  export class SeedRegistry {
7
8
  seedMap;
@@ -26,6 +27,10 @@ export class SeedRegistry {
26
27
  throw new Error(`Seed "${seed.slug}" has duplicate branch id "${branch.id}".`);
27
28
  }
28
29
  idsInSeed.add(branch.id);
30
+ const optionIssues = tagOptionsIssues(branch);
31
+ if (optionIssues.length > 0) {
32
+ throw new Error(`Seed "${seed.slug}": ${optionIssues[0]}`);
33
+ }
29
34
  }
30
35
  }
31
36
  this.orderedSeeds = seeds;
@@ -47,6 +52,28 @@ export class SeedRegistry {
47
52
  return this.orderedSeeds.filter(seed => seed.allowDrafts === true);
48
53
  }
49
54
  }
55
+ /**
56
+ * Builds a registry from every seed that validates and sets aside the ones that do not.
57
+ *
58
+ * `new SeedRegistry(seeds)` is all-or-nothing: one seed holding a legacy shape (say pre-0.9 tag
59
+ * options) throws and takes the whole API down. Validation is per seed and independent, so this
60
+ * validates each seed alone and leaves the failing ones out. Callers log `quarantined`; the rest of
61
+ * the system keeps serving.
62
+ */
63
+ export function createTolerantSeedRegistry(seeds) {
64
+ const valid = [];
65
+ const quarantined = [];
66
+ for (const seed of seeds) {
67
+ try {
68
+ new SeedRegistry([seed]);
69
+ valid.push(seed);
70
+ }
71
+ catch (error) {
72
+ quarantined.push({ slug: seed.slug, reason: error instanceof Error ? error.message : String(error) });
73
+ }
74
+ }
75
+ return { registry: new SeedRegistry(valid), quarantined };
76
+ }
50
77
  /**
51
78
  * Named subclass of SeedRegistry for use in test suites.
52
79
  * Gives tests a semantic name without requiring any dependency on factory.ts.
@@ -1 +1 @@
1
- {"version":3,"file":"seed-types-generator.d.ts","sourceRoot":"","sources":["../../../src/engine/seeds/seed-types-generator.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAe/C,sEAAsE;AACtE,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAMhD;AAMD,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAoCtD;AAWD,sDAAsD;AACtD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAYnD;AAKD,oFAAoF;AACpF,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,OAAO,GAAE,gBAAqB,GAAG,MAAM,CAcvF"}
1
+ {"version":3,"file":"seed-types-generator.d.ts","sourceRoot":"","sources":["../../../src/engine/seeds/seed-types-generator.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAgB/C,sEAAsE;AACtE,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAMhD;AAMD,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAiCtD;AAWD,sDAAsD;AACtD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAYnD;AAKD,oFAAoF;AACpF,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,OAAO,GAAE,gBAAqB,GAAG,MAAM,CAcvF"}
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (c) 2024–2026 Flavio De Musso
3
+ import { tagOptionValues } from './tag-options.js';
3
4
  const HEADER = '// Questo file è generato automaticamente da BeechCMS CLI. Non modificarlo direttamente.\n';
4
5
  /** System columns emitted by generateCreateTable — always present, never optional. */
5
6
  const SYSTEM_FIELDS = ` id: string\n` +
@@ -31,13 +32,11 @@ export function tsTypeForBranch(branch) {
31
32
  case 'text':
32
33
  case 'richtext':
33
34
  case 'file': {
34
- const base = branch.type === 'text' && branch.options?.length
35
- ? literalUnion(branch.options)
36
- : 'string';
37
- return branch.type === 'file' && branch.multiple ? `${base}[]` : base;
35
+ return branch.type === 'file' && branch.multiple ? 'string[]' : 'string';
38
36
  }
39
37
  case 'tags': {
40
- const base = branch.options?.length ? `(${literalUnion(branch.options)})` : 'string';
38
+ const values = tagOptionValues(branch);
39
+ const base = values.length > 0 ? `(${literalUnion(values)})` : 'string';
41
40
  return `${base}[]`;
42
41
  }
43
42
  case 'relation':
@@ -7,6 +7,11 @@ import type { Seed } from '../types.js';
7
7
  * exact same guard — do not inline a divergent copy.
8
8
  */
9
9
  export declare const BRANCH_ALIAS_RE: RegExp;
10
+ /**
11
+ * Reason a branch alias is reserved, or `null` when free to use.
12
+ * Single source for seed validation and the branch rename route.
13
+ */
14
+ export declare function reservedBranchAliasReason(alias: string): string | null;
10
15
  /**
11
16
  * Allowed charset for a seed slug identifier.
12
17
  * Must consist solely of lowercase ASCII letters, digits, and underscores.
@@ -1 +1 @@
1
- {"version":3,"file":"seed-validation.d.ts","sourceRoot":"","sources":["../../../src/engine/seeds/seed-validation.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAUvC;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,QAAyB,CAAA;AAErD;;;;GAIG;AACH,eAAO,MAAM,YAAY,QAAiB,CAAA;AAC1C,eAAO,MAAM,OAAO,QAAe,CAAA;AAGnC,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,KAAK,EAAE,OAAO,CAAA;CACf;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,mBAAmB,EAAE,CAkW5E;AAED,6CAA6C;AAC7C,wBAAgB,cAAc,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,OAAO,CAErD"}
1
+ {"version":3,"file":"seed-validation.d.ts","sourceRoot":"","sources":["../../../src/engine/seeds/seed-validation.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAWvC;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,QAAyB,CAAA;AAErD;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CActE;AAED;;;;GAIG;AACH,eAAO,MAAM,YAAY,QAAiB,CAAA;AAC1C,eAAO,MAAM,OAAO,QAAe,CAAA;AAGnC,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,KAAK,EAAE,OAAO,CAAA;CACf;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,mBAAmB,EAAE,CA2V5E;AAED,6CAA6C;AAC7C,wBAAgB,cAAc,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,OAAO,CAErD"}
@@ -5,6 +5,7 @@ import { SYSTEM_COLUMNS } from '../ddl/ddl.js';
5
5
  import { LOCALIZABLE_BRANCH_TYPES } from '../localization/localization.js';
6
6
  import { resolveClassification } from '../privacy/policies.js';
7
7
  import { sortSeedsByDependencies } from './seed-registry.js';
8
+ import { tagOptionsIssues } from './tag-options.js';
8
9
  import { SQL_RESERVED_WORDS } from '../sql-reserved-words.js';
9
10
  const BRANCH_ID_RE = /^br_[A-Za-z0-9]+$/;
10
11
  /**
@@ -15,6 +16,25 @@ const BRANCH_ID_RE = /^br_[A-Za-z0-9]+$/;
15
16
  * exact same guard — do not inline a divergent copy.
16
17
  */
17
18
  export const BRANCH_ALIAS_RE = /^[a-z][a-zA-Z0-9_]*$/;
19
+ /**
20
+ * Reason a branch alias is reserved, or `null` when free to use.
21
+ * Single source for seed validation and the branch rename route.
22
+ */
23
+ export function reservedBranchAliasReason(alias) {
24
+ if (SYSTEM_COLUMNS.has(alias)) {
25
+ return `branch '${alias}' collides with reserved system column. ` +
26
+ `System columns (id, slug, status, created_at, updated_at) cannot be used as branch aliases.`;
27
+ }
28
+ if (AUTOMATION_RESERVED_WORDS.has(alias)) {
29
+ return `branch '${alias}' uses reserved alias. ` +
30
+ `This word is used by the automation template grammar.`;
31
+ }
32
+ if (SQL_RESERVED_WORDS.has(alias.toLowerCase())) {
33
+ return `branch '${alias}' uses SQL reserved keyword. ` +
34
+ `Please pick a different alias to prevent database errors.`;
35
+ }
36
+ return null;
37
+ }
18
38
  /**
19
39
  * Allowed charset for a seed slug identifier.
20
40
  * Must consist solely of lowercase ASCII letters, digits, and underscores.
@@ -29,15 +49,18 @@ export const SLUG_RE = SEED_SLUG_RE;
29
49
  */
30
50
  export function validateSeedDefinitions(seeds) {
31
51
  const result = [];
32
- // ── Fatal 1: unknown relation targets ────────────────────────────────────
52
+ // ── Fatal 1: missing or unknown relation targets ─────────────────────────
33
53
  const slugSet = new Set(seeds.map(s => s.slug));
34
54
  for (const seed of seeds) {
35
55
  const messages = [];
36
56
  for (const branch of seed.branches) {
37
- if (branch.type === 'relation' && branch.targetSeed) {
38
- if (!slugSet.has(branch.targetSeed)) {
39
- messages.push(`branch '${branch.alias}' targets unknown seed '${branch.targetSeed}'`);
40
- }
57
+ if (branch.type !== 'relation')
58
+ continue;
59
+ if (!branch.targetSeed) {
60
+ messages.push(`branch '${branch.alias}' is a relation and requires a targetSeed`);
61
+ }
62
+ else if (!slugSet.has(branch.targetSeed)) {
63
+ messages.push(`branch '${branch.alias}' targets unknown seed '${branch.targetSeed}'`);
41
64
  }
42
65
  }
43
66
  if (messages.length > 0)
@@ -123,18 +146,9 @@ export function validateSeedDefinitions(seeds) {
123
146
  for (const seed of seeds) {
124
147
  const messages = [];
125
148
  for (const branch of seed.branches) {
126
- if (SYSTEM_COLUMNS.has(branch.alias)) {
127
- messages.push(`branch '${branch.alias}' collides with reserved system column. ` +
128
- `System columns (id, slug, status, created_at, updated_at) cannot be used as branch aliases.`);
129
- }
130
- else if (AUTOMATION_RESERVED_WORDS.has(branch.alias)) {
131
- messages.push(`branch '${branch.alias}' uses reserved alias. ` +
132
- `This word is used by the automation template grammar.`);
133
- }
134
- else if (SQL_RESERVED_WORDS.has(branch.alias.toLowerCase())) {
135
- messages.push(`branch '${branch.alias}' uses SQL reserved keyword. ` +
136
- `Please pick a different alias to prevent database errors.`);
137
- }
149
+ const reason = reservedBranchAliasReason(branch.alias);
150
+ if (reason)
151
+ messages.push(reason);
138
152
  }
139
153
  if (messages.length > 0)
140
154
  result.push({ slug: seed.slug, messages, fatal: true });
@@ -203,14 +217,14 @@ export function validateSeedDefinitions(seeds) {
203
217
  if (messages.length > 0)
204
218
  result.push({ slug: seed.slug, messages, fatal: false });
205
219
  }
206
- // ── Warning 9: displayNameAlias not in branches ───────────────────────────
220
+ // ── Fatal 9: displayNameAlias not in branches ───────────────────────────
207
221
  for (const seed of seeds) {
208
222
  const allAliases = new Set(seed.branches.map(b => b.alias));
209
223
  if (!allAliases.has(seed.displayNameAlias)) {
210
224
  result.push({
211
225
  slug: seed.slug,
212
226
  messages: [`displayNameAlias "${seed.displayNameAlias}" not found in branches`],
213
- fatal: false,
227
+ fatal: true,
214
228
  });
215
229
  }
216
230
  }
@@ -356,6 +370,12 @@ export function validateSeedDefinitions(seeds) {
356
370
  if (messages.length > 0)
357
371
  result.push({ slug: seed.slug, messages, fatal: true });
358
372
  }
373
+ // ── Fatal 18: tags options / cardinality ───────────────────────────────────
374
+ for (const seed of seeds) {
375
+ const messages = seed.branches.flatMap(branch => tagOptionsIssues(branch));
376
+ if (messages.length > 0)
377
+ result.push({ slug: seed.slug, messages, fatal: true });
378
+ }
359
379
  return result;
360
380
  }
361
381
  /** Convenience: true iff no fatal issues. */
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @module engine/tag-migration
3
+ * Data migration from the pre-0.9 tag/choice shapes to the closed-vocabulary `tags` contract.
4
+ *
5
+ * Before 0.9 a choice field was either `text` + `options` (a single value stored as a plain string)
6
+ * or `tags` + `options: string[]`. Since 0.9 `options` exists only on `tags` as `{ value, color }[]`,
7
+ * and every stored value is a JSON array. Databases holding the old shapes were rejected at boot with
8
+ * no way forward; this module computes the way forward.
9
+ *
10
+ * Everything here is pure: it plans from seed definitions and emits SQL text. Nothing is executed,
11
+ * so the same plan drives the CLI dry run, `--apply`, and the audit.
12
+ */
13
+ import type { Seed } from '../types.js';
14
+ import type { SchemaQueryExecutor } from '../introspection/introspection.js';
15
+ /**
16
+ * Colors assigned, in declaration order, to legacy options that never carried one.
17
+ * Deterministic on purpose: re-running the plan yields the same definition.
18
+ */
19
+ export declare const TAG_MIGRATION_PALETTE: readonly string[];
20
+ /** What the plan does with one branch. */
21
+ export type TagMigrationAction =
22
+ /** `text` + `options` becomes single-choice `tags`; stored strings are wrapped into arrays. */
23
+ 'text-to-tags'
24
+ /** `tags` whose `options` are plain strings or carry invalid colors; stored values are untouched. */
25
+ | 'tags-options'
26
+ /** Cannot be converted safely; a human must edit the seed. */
27
+ | 'manual';
28
+ export interface TagMigrationStep {
29
+ seed: string;
30
+ /** Dotted path when the branch is nested in a repeater, otherwise the alias. */
31
+ alias: string;
32
+ action: TagMigrationAction;
33
+ /** One line, operator-facing. */
34
+ summary: string;
35
+ /** Non-fatal remarks (e.g. a full-text index that still covers the column). */
36
+ warnings: string[];
37
+ }
38
+ export interface TagMigrationPlan {
39
+ steps: TagMigrationStep[];
40
+ /** Rewritten definitions, only for seeds that changed. Seeds with a `manual` step are not rewritten. */
41
+ seeds: Seed[];
42
+ }
43
+ /** True when the plan has anything to report or do. */
44
+ export declare function hasTagMigrationWork(plan: TagMigrationPlan): boolean;
45
+ /** Steps a human must resolve. */
46
+ export declare function manualTagSteps(plan: TagMigrationPlan): TagMigrationStep[];
47
+ /**
48
+ * Computes what moving `seeds` to the 0.9 tags contract takes. Seeds that already comply produce no
49
+ * step and no rewritten definition, so a clean database yields an empty plan (the operation is idempotent).
50
+ */
51
+ export declare function planTagMigration(seeds: readonly Seed[]): TagMigrationPlan;
52
+ /**
53
+ * The statements that carry the plan out, in execution order: stored values first, then the seed
54
+ * definitions, then the registry-version bump that tells every isolate to rebuild its cached registry.
55
+ * Value wrapping touches the live table and, when drafts are on, the drafts table.
56
+ *
57
+ * `originals` are the definitions the plan was computed from (the physical tables are named after them).
58
+ */
59
+ export declare function tagMigrationStatements(plan: TagMigrationPlan, originals: readonly Seed[]): string[];
60
+ /** A stored value that is not in the (new) vocabulary of its branch. */
61
+ export interface TagOrphan {
62
+ seed: string;
63
+ alias: string;
64
+ value: string;
65
+ rows: number;
66
+ }
67
+ /**
68
+ * Reads, before anything is changed, the stored values the new vocabulary would not contain. They are
69
+ * reported, never deleted or rewritten: the operator decides whether to add an option or fix the rows.
70
+ * Read-only (`SELECT` through the injected executor).
71
+ */
72
+ export declare function findTagOrphans(executor: SchemaQueryExecutor, plan: TagMigrationPlan, originals: readonly Seed[]): Promise<TagOrphan[]>;
73
+ //# sourceMappingURL=tag-migration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tag-migration.d.ts","sourceRoot":"","sources":["../../../src/engine/seeds/tag-migration.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAU,IAAI,EAAa,MAAM,aAAa,CAAA;AAE1D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAA;AAK5E;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,SAAS,MAAM,EAElD,CAAA;AAED,0CAA0C;AAC1C,MAAM,MAAM,kBAAkB;AAC5B,+FAA+F;AAC7F,cAAc;AAChB,qGAAqG;GACnG,cAAc;AAChB,8DAA8D;GAC5D,QAAQ,CAAA;AAEZ,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAA;IACZ,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,kBAAkB,CAAA;IAC1B,iCAAiC;IACjC,OAAO,EAAE,MAAM,CAAA;IACf,+EAA+E;IAC/E,QAAQ,EAAE,MAAM,EAAE,CAAA;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,gBAAgB,EAAE,CAAA;IACzB,wGAAwG;IACxG,KAAK,EAAE,IAAI,EAAE,CAAA;CACd;AAED,uDAAuD;AACvD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAEnE;AAED,kCAAkC;AAClC,wBAAgB,cAAc,CAAC,IAAI,EAAE,gBAAgB,GAAG,gBAAgB,EAAE,CAEzE;AA2GD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,IAAI,EAAE,GAAG,gBAAgB,CAuBzE;AAeD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,gBAAgB,EAAE,SAAS,EAAE,SAAS,IAAI,EAAE,GAAG,MAAM,EAAE,CA2BnG;AAED,wEAAwE;AACxE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;CACb;AAOD;;;;GAIG;AACH,wBAAsB,cAAc,CAClC,QAAQ,EAAE,mBAAmB,EAC7B,IAAI,EAAE,gBAAgB,EACtB,SAAS,EAAE,SAAS,IAAI,EAAE,GACzB,OAAO,CAAC,SAAS,EAAE,CAAC,CA2BtB"}
@@ -0,0 +1,212 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024–2026 Flavio De Musso
3
+ import { draftTableName, indexableSearchBranches, tableName } from '../ddl/ddl.js';
4
+ import { cleanString, isPlainObject } from '../validation/primitives.js';
5
+ import { TAG_OPTION_COLOR_RE, tagOptionsIssues } from './tag-options.js';
6
+ import { normalizeTagValue, TAG_OPTIONS_MAX_COUNT } from './tag-value.js';
7
+ /**
8
+ * Colors assigned, in declaration order, to legacy options that never carried one.
9
+ * Deterministic on purpose: re-running the plan yields the same definition.
10
+ */
11
+ export const TAG_MIGRATION_PALETTE = [
12
+ '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#ec4899', '#64748b',
13
+ ];
14
+ /** True when the plan has anything to report or do. */
15
+ export function hasTagMigrationWork(plan) {
16
+ return plan.steps.length > 0;
17
+ }
18
+ /** Steps a human must resolve. */
19
+ export function manualTagSteps(plan) {
20
+ return plan.steps.filter(step => step.action === 'manual');
21
+ }
22
+ function paletteColor(index) {
23
+ return TAG_MIGRATION_PALETTE[index % TAG_MIGRATION_PALETTE.length];
24
+ }
25
+ /**
26
+ * Converts whatever a legacy `options` array held into the `{ value, color }` contract.
27
+ * Returns `null` when nothing salvageable remains (the caller then asks for a manual fix).
28
+ */
29
+ function rewriteOptions(raw) {
30
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > TAG_OPTIONS_MAX_COUNT)
31
+ return null;
32
+ const seen = new Set();
33
+ const options = [];
34
+ raw.forEach((entry, index) => {
35
+ let candidate;
36
+ let color;
37
+ if (typeof entry === 'string') {
38
+ candidate = entry;
39
+ }
40
+ else if (isPlainObject(entry) && typeof entry.value === 'string') {
41
+ candidate = entry.value;
42
+ color = typeof entry.color === 'string' && TAG_OPTION_COLOR_RE.test(entry.color) ? entry.color : undefined;
43
+ }
44
+ if (candidate === undefined)
45
+ return;
46
+ const normalized = normalizeTagValue(cleanString(candidate));
47
+ if (!normalized.ok || normalized.value === '' || seen.has(normalized.value))
48
+ return;
49
+ seen.add(normalized.value);
50
+ options.push({ value: normalized.value, color: color ?? paletteColor(options.length) });
51
+ });
52
+ return options.length > 0 ? options : null;
53
+ }
54
+ function manual(branch, summary) {
55
+ return { branch, step: { alias: branch.alias, action: 'manual', summary, warnings: [] } };
56
+ }
57
+ function isSafeIdentifier(name) {
58
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
59
+ }
60
+ /** Plans the rewrite of one top-level branch; `null` when it already honours the contract. */
61
+ function planBranch(seed, branch) {
62
+ const raw = branch;
63
+ const ownIssues = tagOptionsIssues({ ...branch, fields: [] });
64
+ const nestedIssues = tagOptionsIssues(branch).length - ownIssues.length;
65
+ if (ownIssues.length === 0) {
66
+ return nestedIssues > 0
67
+ ? manual(branch, `a repeater sub-field of '${branch.alias}' uses the legacy options shape; edit the seed by hand`)
68
+ : null;
69
+ }
70
+ if (!isSafeIdentifier(branch.alias)) {
71
+ return manual(branch, `alias '${branch.alias}' is not a plain SQL identifier`);
72
+ }
73
+ if (branch.type !== 'text' && branch.type !== 'tags') {
74
+ return manual(branch, `options are not supported on type '${branch.type}'; remove them from the seed`);
75
+ }
76
+ if (branch.localized === true) {
77
+ return manual(branch, `'${branch.alias}' is localized; its stored locale dictionary cannot be wrapped automatically`);
78
+ }
79
+ if (nestedIssues > 0) {
80
+ return manual(branch, `'${branch.alias}' also has legacy options in a repeater sub-field; edit the seed by hand`);
81
+ }
82
+ const options = rewriteOptions(raw.options);
83
+ if (!options) {
84
+ return manual(branch, `'${branch.alias}' has no usable options (empty, over ${TAG_OPTIONS_MAX_COUNT}, or all invalid)`);
85
+ }
86
+ const rewritten = branch.type === 'text'
87
+ ? { ...branch, type: 'tags', multiple: false, options }
88
+ : { ...branch, options };
89
+ if (tagOptionsIssues(rewritten).length > 0) {
90
+ return manual(branch, `'${branch.alias}' still violates the tags contract after conversion`);
91
+ }
92
+ const warnings = [];
93
+ if (branch.type === 'text' && indexableSearchBranches({ ...seed, branches: [branch] }).length > 0) {
94
+ warnings.push(`'${branch.alias}' was full-text indexed as text; run \`beech schema diff\` to check the search index`);
95
+ }
96
+ return branch.type === 'text'
97
+ ? {
98
+ branch: rewritten,
99
+ step: {
100
+ alias: branch.alias,
101
+ action: 'text-to-tags',
102
+ summary: `'${branch.alias}': text + options → single-choice tags (${options.length} options, values wrapped into arrays)`,
103
+ warnings,
104
+ },
105
+ }
106
+ : {
107
+ branch: rewritten,
108
+ step: {
109
+ alias: branch.alias,
110
+ action: 'tags-options',
111
+ summary: `'${branch.alias}': options rewritten as { value, color } (${options.length} options, values untouched)`,
112
+ warnings,
113
+ },
114
+ };
115
+ }
116
+ /**
117
+ * Computes what moving `seeds` to the 0.9 tags contract takes. Seeds that already comply produce no
118
+ * step and no rewritten definition, so a clean database yields an empty plan (the operation is idempotent).
119
+ */
120
+ export function planTagMigration(seeds) {
121
+ const steps = [];
122
+ const rewrittenSeeds = [];
123
+ for (const seed of seeds) {
124
+ const seedSteps = [];
125
+ let changed = false;
126
+ const branches = seed.branches.map(branch => {
127
+ const rewrite = planBranch(seed, branch);
128
+ if (!rewrite)
129
+ return branch;
130
+ seedSteps.push({ seed: seed.slug, ...rewrite.step });
131
+ if (rewrite.step.action !== 'manual')
132
+ changed = true;
133
+ return rewrite.branch;
134
+ });
135
+ steps.push(...seedSteps);
136
+ // A seed with an unresolved branch is left exactly as stored: half a migration is worse than none.
137
+ if (changed && !seedSteps.some(step => step.action === 'manual')) {
138
+ rewrittenSeeds.push({ ...seed, branches });
139
+ }
140
+ }
141
+ return { steps, seeds: rewrittenSeeds };
142
+ }
143
+ function quote(value) {
144
+ return `'${value.replace(/'/g, "''")}'`;
145
+ }
146
+ /** `text-to-tags` steps of `seed` that the plan will rewrite. */
147
+ function wrapAliases(plan, seed) {
148
+ const rewritten = plan.seeds.some(candidate => candidate.slug === seed.slug);
149
+ if (!rewritten)
150
+ return [];
151
+ return plan.steps
152
+ .filter(step => step.seed === seed.slug && step.action === 'text-to-tags')
153
+ .map(step => step.alias);
154
+ }
155
+ /**
156
+ * The statements that carry the plan out, in execution order: stored values first, then the seed
157
+ * definitions, then the registry-version bump that tells every isolate to rebuild its cached registry.
158
+ * Value wrapping touches the live table and, when drafts are on, the drafts table.
159
+ *
160
+ * `originals` are the definitions the plan was computed from (the physical tables are named after them).
161
+ */
162
+ export function tagMigrationStatements(plan, originals) {
163
+ const statements = [];
164
+ for (const original of originals) {
165
+ for (const alias of wrapAliases(plan, original)) {
166
+ const tables = [tableName(original), ...(original.allowDrafts ? [draftTableName(original)] : [])];
167
+ for (const table of tables) {
168
+ statements.push(`UPDATE ${table} SET ${alias} = NULL WHERE ${alias} = '';`);
169
+ statements.push(`UPDATE ${table} SET ${alias} = json_array(${alias}) `
170
+ + `WHERE ${alias} IS NOT NULL AND NOT (json_valid(${alias}) AND json_type(${alias}) = 'array');`);
171
+ }
172
+ }
173
+ }
174
+ for (const seed of plan.seeds) {
175
+ statements.push(`UPDATE seeds SET definition = ${quote(JSON.stringify(seed))}, updated_at = unixepoch() WHERE slug = ${quote(seed.slug)};`);
176
+ }
177
+ if (plan.seeds.length > 0) {
178
+ statements.push(`UPDATE seed_meta SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) WHERE id = 'registry_version';`);
179
+ }
180
+ return statements;
181
+ }
182
+ /**
183
+ * Reads, before anything is changed, the stored values the new vocabulary would not contain. They are
184
+ * reported, never deleted or rewritten: the operator decides whether to add an option or fix the rows.
185
+ * Read-only (`SELECT` through the injected executor).
186
+ */
187
+ export async function findTagOrphans(executor, plan, originals) {
188
+ const orphans = [];
189
+ for (const rewritten of plan.seeds) {
190
+ const original = originals.find(seed => seed.slug === rewritten.slug);
191
+ if (!original)
192
+ continue;
193
+ const table = tableName(original);
194
+ for (const step of plan.steps.filter(candidate => candidate.seed === rewritten.slug && candidate.action !== 'manual')) {
195
+ const branch = rewritten.branches.find(candidate => candidate.alias === step.alias);
196
+ if (!branch?.options)
197
+ continue;
198
+ const allowed = branch.options.map(option => quote(option.value)).join(', ');
199
+ const sql = step.action === 'text-to-tags'
200
+ ? `SELECT ${step.alias} AS value, COUNT(*) AS n FROM ${table} `
201
+ + `WHERE ${step.alias} IS NOT NULL AND ${step.alias} != '' AND ${step.alias} NOT IN (${allowed}) GROUP BY ${step.alias}`
202
+ : `SELECT j.value AS value, COUNT(*) AS n FROM ${table}, `
203
+ + `json_each(CASE WHEN json_valid(${table}.${step.alias}) THEN ${table}.${step.alias} ELSE '[]' END) AS j `
204
+ + `WHERE j.value NOT IN (${allowed}) GROUP BY j.value`;
205
+ const rows = await executor.all(sql);
206
+ for (const row of rows) {
207
+ orphans.push({ seed: rewritten.slug, alias: step.alias, value: String(row.value), rows: Number(row.n) });
208
+ }
209
+ }
210
+ }
211
+ return orphans;
212
+ }
@@ -0,0 +1,15 @@
1
+ import type { Branch, TagOption } from '../types.js';
2
+ /** Accepted option color format: 6-digit hex, the value `<input type="color">` emits. */
3
+ export declare const TAG_OPTION_COLOR_RE: RegExp;
4
+ /** True only for a `tags` branch with `multiple === false`. Omitted `multiple` on `tags` means multiple choice. */
5
+ export declare function isSingleChoiceTags(branch: Pick<Branch, 'type' | 'multiple'>): boolean;
6
+ /** Option values in declared order; `[]` when the branch has no options. */
7
+ export declare function tagOptionValues(branch: Pick<Branch, 'options'>): string[];
8
+ /** The option whose `value` equals `value` exactly, or `undefined`. */
9
+ export declare function findTagOption(branch: Pick<Branch, 'options'>, value: string): TagOption | undefined;
10
+ /**
11
+ * Seed-definition issues for `options` / `multiple` on this branch AND, recursively, its repeater sub-fields.
12
+ * Pure, never throws, works on untyped input (stored JSON may carry any shape). `[]` = valid.
13
+ */
14
+ export declare function tagOptionsIssues(branch: Branch): string[];
15
+ //# sourceMappingURL=tag-options.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tag-options.d.ts","sourceRoot":"","sources":["../../../src/engine/seeds/tag-options.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAIpD,yFAAyF;AACzF,eAAO,MAAM,mBAAmB,QAAsB,CAAA;AAEtD,mHAAmH;AACnH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC,GAAG,OAAO,CAErF;AAED,4EAA4E;AAC5E,wBAAgB,eAAe,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,MAAM,EAAE,CAEzE;AAED,uEAAuE;AACvE,wBAAgB,aAAa,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,SAAS,CAAC,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAEnG;AA8ED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAOzD"}