@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 @@
1
+ {"version":3,"file":"relation-audit.d.ts","sourceRoot":"","sources":["../../../src/engine/introspection/relation-audit.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAU,IAAI,EAAE,MAAM,aAAa,CAAA;AAE/C,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,oBAAoB,CAAA;AAE3B,6DAA6D;AAC7D,MAAM,MAAM,oBAAoB;AAC9B,kFAAkF;AAChF,eAAe;AACjB,2DAA2D;GACzD,iBAAiB;AACnB,4DAA4D;GAC1D,oBAAoB;AACtB,qEAAqE;GACnE,uBAAuB,CAAA;AAE3B,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,oBAAoB,CAAA;IAC3B,6EAA6E;IAC7E,QAAQ,EAAE,MAAM,CAAA;IAChB,+BAA+B;IAC/B,MAAM,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAA;CACrB;AAkGD;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,IAAI,EAAE,GACZ,OAAO,CAAC,sBAAsB,EAAE,CAAC,CAiBnC"}
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024–2026 Flavio De Musso
3
+ import { junctionTableName } from '../ddl/ddl.js';
4
+ import { assertSafeIdentifier, introspectTable, } from './introspection.js';
5
+ /** Default `ON DELETE` rule, per storage shape (mirrors `engine/ddl`). */
6
+ function effectiveOnDelete(branch) {
7
+ return (branch.onDelete ?? (branch.multiple === true ? 'CASCADE' : 'SET NULL')).toUpperCase();
8
+ }
9
+ async function countRows(executor, sql) {
10
+ const rows = await executor.all(sql);
11
+ return Number(rows[0]?.n ?? 0);
12
+ }
13
+ /** Checks the FK that carries the reference: the `alias` column, or the junction's `target_id`. */
14
+ function fkFindings(seed, branch, fk, location) {
15
+ const expectedTarget = `content_${branch.targetSeed}`;
16
+ const expectedRule = effectiveOnDelete(branch);
17
+ const base = { seed: seed.slug, alias: branch.alias, strandedRows: 0 };
18
+ const expected = `${location} -> ${expectedTarget}(id) ON DELETE ${expectedRule}`;
19
+ if (!fk) {
20
+ return [{ ...base, issue: 'fk_target_mismatch', expected, actual: `${location} has no foreign key` }];
21
+ }
22
+ const actual = `${location} -> ${fk.targetTable}(${fk.targetColumn}) ON DELETE ${fk.onDelete}`;
23
+ const findings = [];
24
+ if (fk.targetTable !== expectedTarget)
25
+ findings.push({ ...base, issue: 'fk_target_mismatch', expected, actual });
26
+ if (fk.onDelete !== expectedRule)
27
+ findings.push({ ...base, issue: 'fk_on_delete_mismatch', expected, actual });
28
+ return findings;
29
+ }
30
+ async function auditBranch(executor, seed, branch, contentColumns, contentForeignKeys) {
31
+ const isMultiple = branch.multiple === true;
32
+ const junction = junctionTableName(seed.slug, branch.alias);
33
+ const draftJunction = `${junction}_drafts`;
34
+ const alias = assertSafeIdentifier(branch.alias);
35
+ const base = { seed: seed.slug, alias: branch.alias };
36
+ const liveJunction = await introspectTable(executor, junction);
37
+ const liveDraftJunction = await introspectTable(executor, draftJunction);
38
+ const hasColumn = contentColumns.has(alias);
39
+ if (isMultiple) {
40
+ if (hasColumn) {
41
+ const strandedRows = await countRows(executor, `SELECT COUNT(*) AS n FROM content_${seed.slug} WHERE "${alias}" IS NOT NULL`);
42
+ return [{
43
+ ...base,
44
+ issue: 'wrong_storage',
45
+ expected: `junction ${junction}`,
46
+ actual: `${liveJunction.exists ? `junction ${junction} and ` : ''}column content_${seed.slug}.${alias}`,
47
+ strandedRows,
48
+ }];
49
+ }
50
+ if (!liveJunction.exists) {
51
+ return [{ ...base, issue: 'missing_storage', expected: `junction ${junction}`, actual: 'no junction table', strandedRows: 0 }];
52
+ }
53
+ const findings = fkFindings(seed, branch, liveJunction.foreignKeys.find(fk => fk.column === 'target_id'), `${junction}.target_id`);
54
+ if (seed.allowDrafts && !liveDraftJunction.exists) {
55
+ findings.push({ ...base, issue: 'missing_storage', expected: `drafts junction ${draftJunction}`, actual: 'no drafts junction table', strandedRows: 0 });
56
+ }
57
+ return findings;
58
+ }
59
+ // Single relation: the value lives in a column; any junction left behind holds stranded links.
60
+ if (liveJunction.exists || liveDraftJunction.exists) {
61
+ let strandedRows = 0;
62
+ if (liveJunction.exists)
63
+ strandedRows += await countRows(executor, `SELECT COUNT(*) AS n FROM ${junction}`);
64
+ if (liveDraftJunction.exists)
65
+ strandedRows += await countRows(executor, `SELECT COUNT(*) AS n FROM ${draftJunction}`);
66
+ return [{
67
+ ...base,
68
+ issue: 'wrong_storage',
69
+ expected: `column content_${seed.slug}.${alias}`,
70
+ actual: `${hasColumn ? `column content_${seed.slug}.${alias} and ` : ''}junction ${junction}`,
71
+ strandedRows,
72
+ }];
73
+ }
74
+ if (!hasColumn) {
75
+ return [{ ...base, issue: 'missing_storage', expected: `column content_${seed.slug}.${alias}`, actual: 'no column', strandedRows: 0 }];
76
+ }
77
+ return fkFindings(seed, branch, contentForeignKeys.get(alias), `content_${seed.slug}.${alias}`);
78
+ }
79
+ /**
80
+ * Compares every relation branch of the given (active) seeds against the physical tables.
81
+ *
82
+ * Seeds whose `content_<slug>` table is missing are skipped: that is plain schema drift and is
83
+ * reported by the schema diff, not a relation-storage mismatch. Findings are ordered by seed, then
84
+ * branch order, so the report is deterministic.
85
+ */
86
+ export async function auditRelationStorage(executor, seeds) {
87
+ const findings = [];
88
+ const ordered = [...seeds].sort((a, b) => a.slug.localeCompare(b.slug));
89
+ // Sequential on purpose: see `introspectSchema`.
90
+ for (const seed of ordered) {
91
+ const content = await introspectTable(executor, `content_${assertSafeIdentifier(seed.slug)}`);
92
+ if (!content.exists)
93
+ continue;
94
+ const columns = new Set(content.columns.map(column => column.name));
95
+ const foreignKeys = new Map(content.foreignKeys.map(fk => [fk.column, fk]));
96
+ for (const branch of seed.branches) {
97
+ if (branch.type !== 'relation' || !branch.targetSeed)
98
+ continue;
99
+ findings.push(...await auditBranch(executor, seed, branch, columns, foreignKeys));
100
+ }
101
+ }
102
+ return findings;
103
+ }
@@ -4,7 +4,7 @@ import type { Branch, Seed } from '../types.js';
4
4
  * fingerprints computed by two builds are no longer comparable. It is carried in the fingerprint
5
5
  * string itself so a version skew reads as a mismatch instead of as an accidental match.
6
6
  */
7
- export declare const SCHEMA_FINGERPRINT_VERSION = 1;
7
+ export declare const SCHEMA_FINGERPRINT_VERSION = 2;
8
8
  /** The response-shape-bearing subset of a `Branch`. */
9
9
  export interface ContractBranch {
10
10
  alias: string;
@@ -52,6 +52,7 @@ export interface SchemaContract {
52
52
  * `policies.classification|privacy` — behaviour and storage concerns that leave the response
53
53
  * shape identical. (`privacy` DOES change a stored value's encoding, but the API type stays
54
54
  * `string`; drift there is a data concern, not a type concern.)
55
+ * - option `color` — presentation only; a color edit cannot change a response byte.
55
56
  * - anything physical (column order, index names) — see the VETO Audit.
56
57
  *
57
58
  * Seeds are sorted by slug; BRANCH ORDER IS PRESERVED, because it is the declared field order the
@@ -1 +1 @@
1
- {"version":3,"file":"schema-fingerprint.d.ts","sourceRoot":"","sources":["../../../src/engine/introspection/schema-fingerprint.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAE/C;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,IAAI,CAAA;AAE3C,uDAAuD;AACvD,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAA;IACzB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,gBAAgB,EAAE,OAAO,CAAA;IACzB,gBAAgB,EAAE,OAAO,CAAA;IACzB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,2FAA2F;IAC3F,UAAU,EAAE,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAA;IACxC,UAAU,EAAE,OAAO,CAAA;IACnB,UAAU,EAAE,OAAO,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yGAAyG;IACzG,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,yDAAyD;IACzD,MAAM,CAAC,EAAE,cAAc,EAAE,CAAA;CAC1B;AAED,qDAAqD;AACrD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,gBAAgB,EAAE,MAAM,CAAA;IACxB,WAAW,EAAE,OAAO,CAAA;IACpB,eAAe,EAAE,OAAO,CAAA;IACxB,eAAe,EAAE,OAAO,CAAA;IACxB,eAAe,EAAE,OAAO,CAAA;IACxB,QAAQ,EAAE,cAAc,EAAE,CAAA;CAC3B;AAED,0DAA0D;AAC1D,MAAM,WAAW,cAAc;IAC7B,kBAAkB,EAAE,MAAM,CAAA;IAC1B,KAAK,EAAE,YAAY,EAAE,CAAA;CACtB;AAyBD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,cAAc,CAenE;AAMD;;;;;;;;;;GAUG;AACH,wBAAsB,wBAAwB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAI7E"}
1
+ {"version":3,"file":"schema-fingerprint.d.ts","sourceRoot":"","sources":["../../../src/engine/introspection/schema-fingerprint.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAG/C;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,IAAI,CAAA;AAE3C,uDAAuD;AACvD,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAA;IACzB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,gBAAgB,EAAE,OAAO,CAAA;IACzB,gBAAgB,EAAE,OAAO,CAAA;IACzB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,2FAA2F;IAC3F,UAAU,EAAE,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAA;IACxC,UAAU,EAAE,OAAO,CAAA;IACnB,UAAU,EAAE,OAAO,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yGAAyG;IACzG,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,yDAAyD;IACzD,MAAM,CAAC,EAAE,cAAc,EAAE,CAAA;CAC1B;AAED,qDAAqD;AACrD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,gBAAgB,EAAE,MAAM,CAAA;IACxB,WAAW,EAAE,OAAO,CAAA;IACpB,eAAe,EAAE,OAAO,CAAA;IACxB,eAAe,EAAE,OAAO,CAAA;IACxB,eAAe,EAAE,OAAO,CAAA;IACxB,QAAQ,EAAE,cAAc,EAAE,CAAA;CAC3B;AAED,0DAA0D;AAC1D,MAAM,WAAW,cAAc;IAC7B,kBAAkB,EAAE,MAAM,CAAA;IAC1B,KAAK,EAAE,YAAY,EAAE,CAAA;CACtB;AA4BD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,cAAc,CAenE;AAMD;;;;;;;;;;GAUG;AACH,wBAAsB,wBAAwB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAI7E"}
@@ -16,21 +16,25 @@
16
16
  */
17
17
  import { canonicalStringify } from '../../common/canonical-json.js';
18
18
  import { resolvePolicies } from '../privacy/policies.js';
19
+ import { isSingleChoiceTags, tagOptionValues } from '../seeds/tag-options.js';
19
20
  /**
20
21
  * Bumped ONLY when the projection below or the canonical byte format changes — i.e. when
21
22
  * fingerprints computed by two builds are no longer comparable. It is carried in the fingerprint
22
23
  * string itself so a version skew reads as a mismatch instead of as an accidental match.
23
24
  */
24
- export const SCHEMA_FINGERPRINT_VERSION = 1;
25
+ export const SCHEMA_FINGERPRINT_VERSION = 2;
25
26
  function projectBranch(branch) {
26
27
  const policies = resolvePolicies(branch);
27
28
  return {
28
29
  alias: branch.alias,
29
30
  type: branch.type,
30
31
  ...(branch.format !== undefined ? { format: branch.format } : {}),
31
- ...(branch.multiple !== undefined ? { multiple: branch.multiple } : {}),
32
+ // `tags` always carry the resolved mode, so an omitted `multiple` hashes like `true`.
33
+ ...(branch.type === 'tags'
34
+ ? { multiple: !isSingleChoiceTags(branch) }
35
+ : branch.multiple !== undefined ? { multiple: branch.multiple } : {}),
32
36
  // Option order is the contract: it is the order a select renders and a union type lists.
33
- ...(branch.options !== undefined ? { options: [...branch.options] } : {}),
37
+ ...(branch.options !== undefined ? { options: tagOptionValues(branch) } : {}),
34
38
  requiredOnCreate: branch.requiredOnCreate ?? false,
35
39
  requiredOnUpdate: branch.requiredOnUpdate ?? false,
36
40
  ...(branch.targetSeed !== undefined ? { targetSeed: branch.targetSeed } : {}),
@@ -55,6 +59,7 @@ function projectBranch(branch) {
55
59
  * `policies.classification|privacy` — behaviour and storage concerns that leave the response
56
60
  * shape identical. (`privacy` DOES change a stored value's encoding, but the API type stays
57
61
  * `string`; drift there is a data concern, not a type concern.)
62
+ * - option `color` — presentation only; a color edit cannot change a response byte.
58
63
  * - anything physical (column order, index names) — see the VETO Audit.
59
64
  *
60
65
  * Seeds are sorted by slug; BRANCH ORDER IS PRESERVED, because it is the declared field order the
@@ -7,7 +7,7 @@ import type { ActorContext, Branch, DataClassification, Seed } from '../types.js
7
7
  */
8
8
  export declare function sha256hex(value: string): Promise<string>;
9
9
  /**
10
- * Verifies if a candidate string matches a stored SHA-256 hash digest.
10
+ * Verifies if a candidate string matches a stored SHA-256 hash digest, comparing in constant time.
11
11
  * @param stored - The expected 64-character SHA-256 hex digest.
12
12
  * @param candidate - The raw candidate string to verify.
13
13
  * @returns A Promise resolving to true if candidate matches stored digest, false otherwise.
@@ -1 +1 @@
1
- {"version":3,"file":"policies.d.ts","sourceRoot":"","sources":["../../../src/engine/privacy/policies.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAGjF;;;;;GAKG;AACH,wBAAsB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAK9D;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAEzF;AAED;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC,0CAA0C;IAC1C,cAAc,EAAE,kBAAkB,CAAA;IAClC,6FAA6F;IAC7F,OAAO,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,CAAA;IACrC,6FAA6F;IAC7F,gBAAgB,EAAE,MAAM,GAAG,QAAQ,CAAA;IACnC,oFAAoF;IACpF,cAAc,EAAE,MAAM,GAAG,QAAQ,CAAA;CAClC;AAED,0EAA0E;AAC1E,wBAAgB,uBAAuB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,kBAAkB,CAQxE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,sBAAsB,CAmC5E;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,CAAC,WAAW,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CA2BzF;AAuBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,IAAI,EAAE,IAAI,EACV,KAAK,GAAE,YAAwC,GAC9C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAgDzB"}
1
+ {"version":3,"file":"policies.d.ts","sourceRoot":"","sources":["../../../src/engine/privacy/policies.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAIjF;;;;;GAKG;AACH,wBAAsB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAK9D;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAEzF;AAED;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC,0CAA0C;IAC1C,cAAc,EAAE,kBAAkB,CAAA;IAClC,6FAA6F;IAC7F,OAAO,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,CAAA;IACrC,6FAA6F;IAC7F,gBAAgB,EAAE,MAAM,GAAG,QAAQ,CAAA;IACnC,oFAAoF;IACpF,cAAc,EAAE,MAAM,GAAG,QAAQ,CAAA;CAClC;AAED,0EAA0E;AAC1E,wBAAgB,uBAAuB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,kBAAkB,CAQxE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,sBAAsB,CAmC5E;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,CAAC,WAAW,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CA+BzF;AAuBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,IAAI,EAAE,IAAI,EACV,KAAK,GAAE,YAAwC,GAC9C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAiDzB"}
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (c) 2024–2026 Flavio De Musso
3
3
  import { isLocaleDictionary, isLocalizedBranch } from '../localization/localization.js';
4
+ import { timingSafeEqual } from '../../webhooks/webhook-crypto.js';
4
5
  /**
5
6
  * Computes an un-salted SHA-256 hex digest of a string value.
6
7
  * Used for basic digest operations.
@@ -14,13 +15,13 @@ export async function sha256hex(value) {
14
15
  .join('');
15
16
  }
16
17
  /**
17
- * Verifies if a candidate string matches a stored SHA-256 hash digest.
18
+ * Verifies if a candidate string matches a stored SHA-256 hash digest, comparing in constant time.
18
19
  * @param stored - The expected 64-character SHA-256 hex digest.
19
20
  * @param candidate - The raw candidate string to verify.
20
21
  * @returns A Promise resolving to true if candidate matches stored digest, false otherwise.
21
22
  */
22
23
  export async function verifyHashField(stored, candidate) {
23
- return stored === (await sha256hex(candidate));
24
+ return timingSafeEqual(stored, await sha256hex(candidate));
24
25
  }
25
26
  /** Normalizes raw policy strings to canonical DataClassification enum. */
26
27
  export function normalizeClassification(raw) {
@@ -89,9 +90,12 @@ export function resolvePolicies(branch) {
89
90
  const isEncryptedOrHashed = resolved.storage !== 'plain';
90
91
  const defaultVisibility = resolved.authVisibility;
91
92
  const isPublicAllowed = branch.policies?.public ?? (resolved.publicVisibility === 'full');
92
- const defaultFilter = isRepeater || resolved.storage === 'hash' ? false : branch.policies?.filter ?? true;
93
- const defaultSort = isRepeater || isEncryptedOrHashed ? false : branch.policies?.sort ?? true;
94
- const defaultSearch = isRepeater || isEncryptedOrHashed ? false : branch.policies?.search ?? true;
93
+ const visibility = branch.policies?.visibility ?? defaultVisibility;
94
+ // Concealed values must not be queryable: filter/sort/search leak them
95
+ const isConcealed = visibility === 'hidden' || visibility === 'masked';
96
+ const defaultFilter = branch.policies?.filter ?? (isRepeater || resolved.storage === 'hash' ? false : !isConcealed);
97
+ const defaultSort = branch.policies?.sort ?? (isRepeater || isEncryptedOrHashed ? false : !isConcealed);
98
+ const defaultSearch = branch.policies?.search ?? (isRepeater || isEncryptedOrHashed ? false : !isConcealed);
95
99
  // publicEdit default: true for public fields (unless public: false), false for confidential/internal/restricted
96
100
  const defaultPublicEdit = resolved.classification === 'public'
97
101
  ? (branch.policies?.publicEdit ?? isPublicAllowed)
@@ -99,7 +103,7 @@ export function resolvePolicies(branch) {
99
103
  return {
100
104
  classification: resolved.classification,
101
105
  privacy: resolved.storage,
102
- visibility: branch.policies?.visibility ?? defaultVisibility,
106
+ visibility,
103
107
  search: defaultSearch,
104
108
  filter: defaultFilter,
105
109
  sort: defaultSort,
@@ -164,6 +168,8 @@ export function filterEntryForActor(data, seed, actor = { type: 'authenticated'
164
168
  if (isPublicActor) {
165
169
  if (resolved.publicVisibility === 'hidden')
166
170
  continue;
171
+ if (branch.policies?.public === false)
172
+ continue;
167
173
  }
168
174
  else {
169
175
  // Authenticated actor check
@@ -62,4 +62,17 @@ export declare class PrivacyService implements IPrivacyService {
62
62
  */
63
63
  hash(plaintext: string): Promise<string>;
64
64
  }
65
+ /** Thrown when a protected field is processed but no master key is configured. */
66
+ export declare class PrivacyNotConfiguredError extends Error {
67
+ constructor();
68
+ }
69
+ /**
70
+ * Fail-closed {@link IPrivacyService} for installs without a master key.
71
+ * Never returns plaintext as protected output; legacy non-`v1:` values still read through.
72
+ */
73
+ export declare class UnconfiguredPrivacyService implements IPrivacyService {
74
+ encrypt(_plaintext: string): Promise<string>;
75
+ decrypt(ciphertext: string): Promise<string>;
76
+ hash(_plaintext: string): Promise<string>;
77
+ }
65
78
  //# sourceMappingURL=privacy.service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"privacy.service.d.ts","sourceRoot":"","sources":["../../../src/engine/privacy/privacy.service.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;OAIG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAE3C;;;;;OAKG;IACH,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAE5C;;;;;OAKG;IACH,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;CACzC;AA4BD;;;GAGG;AACH,qBAAa,cAAe,YAAW,eAAe;IACpD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,aAAa,CAAC,CAAoB;IAC1C,OAAO,CAAC,cAAc,CAAC,CAAoB;IAE3C;;;;OAIG;gBACS,SAAS,EAAE,MAAM;IAO7B;;;OAGG;YACW,SAAS;IAmBvB;;OAEG;YACW,UAAU;IAexB;;;OAGG;IACG,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAcjD;;;OAGG;IACG,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAoBlD;;OAEG;IACG,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAa/C"}
1
+ {"version":3,"file":"privacy.service.d.ts","sourceRoot":"","sources":["../../../src/engine/privacy/privacy.service.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;OAIG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAE3C;;;;;OAKG;IACH,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAE5C;;;;;OAKG;IACH,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;CACzC;AA4BD;;;GAGG;AACH,qBAAa,cAAe,YAAW,eAAe;IACpD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,aAAa,CAAC,CAAoB;IAC1C,OAAO,CAAC,cAAc,CAAC,CAAoB;IAE3C;;;;OAIG;gBACS,SAAS,EAAE,MAAM;IAO7B;;;OAGG;YACW,SAAS;IAmBvB;;OAEG;YACW,UAAU;IAexB;;;OAGG;IACG,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAcjD;;;OAGG;IACG,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAoBlD;;OAEG;IACG,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAa/C;AAED,kFAAkF;AAClF,qBAAa,yBAA0B,SAAQ,KAAK;;CAKnD;AAED;;;GAGG;AACH,qBAAa,0BAA2B,YAAW,eAAe;IAC1D,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAI5C,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAK5C,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAGhD"}
@@ -112,3 +112,27 @@ export class PrivacyService {
112
112
  .join('');
113
113
  }
114
114
  }
115
+ /** Thrown when a protected field is processed but no master key is configured. */
116
+ export class PrivacyNotConfiguredError extends Error {
117
+ constructor() {
118
+ super('PRIVACY_MASTER_KEY is not configured: cannot encrypt or hash protected fields');
119
+ this.name = 'PrivacyNotConfiguredError';
120
+ }
121
+ }
122
+ /**
123
+ * Fail-closed {@link IPrivacyService} for installs without a master key.
124
+ * Never returns plaintext as protected output; legacy non-`v1:` values still read through.
125
+ */
126
+ export class UnconfiguredPrivacyService {
127
+ async encrypt(_plaintext) {
128
+ throw new PrivacyNotConfiguredError();
129
+ }
130
+ async decrypt(ciphertext) {
131
+ if (ciphertext?.startsWith('v1:'))
132
+ throw new PrivacyNotConfiguredError();
133
+ return ciphertext;
134
+ }
135
+ async hash(_plaintext) {
136
+ throw new PrivacyNotConfiguredError();
137
+ }
138
+ }
@@ -0,0 +1,19 @@
1
+ import type { Seed, TrashedMode } from '../types.js';
2
+ /**
3
+ * Returns the SQL condition (e.g. `ce.deleted_at IS NULL`), or `null` if the
4
+ * seed does not use soft delete or mode is 'any'.
5
+ *
6
+ * @param seed - The seed schema definition.
7
+ * @param mode - 'active' (default), 'trashed', or 'any'.
8
+ * @param tableAlias - Optional table or alias qualifier prefix.
9
+ */
10
+ export declare function activeCondition(seed: Seed, mode?: TrashedMode, tableAlias?: string): string | null;
11
+ /**
12
+ * Returns ` AND <activeCondition>` for a soft-delete seed, or `''` otherwise.
13
+ *
14
+ * @param seed - The seed schema definition.
15
+ * @param mode - 'active' (default), 'trashed', or 'any'.
16
+ * @param tableAlias - Optional table or alias qualifier prefix.
17
+ */
18
+ export declare function activeClause(seed: Seed, mode?: TrashedMode, tableAlias?: string): string;
19
+ //# sourceMappingURL=active-clause.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"active-clause.d.ts","sourceRoot":"","sources":["../../../src/engine/query/active-clause.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAEpD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,IAAI,EACV,IAAI,GAAE,WAAsB,EAC5B,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,GAAG,IAAI,CAIf;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,IAAI,EACV,IAAI,GAAE,WAAsB,EAC5B,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAGR"}
@@ -0,0 +1,27 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024–2026 Flavio De Musso
3
+ /**
4
+ * Returns the SQL condition (e.g. `ce.deleted_at IS NULL`), or `null` if the
5
+ * seed does not use soft delete or mode is 'any'.
6
+ *
7
+ * @param seed - The seed schema definition.
8
+ * @param mode - 'active' (default), 'trashed', or 'any'.
9
+ * @param tableAlias - Optional table or alias qualifier prefix.
10
+ */
11
+ export function activeCondition(seed, mode = 'active', tableAlias) {
12
+ if (!seed.softDelete || mode === 'any')
13
+ return null;
14
+ const col = tableAlias ? `${tableAlias}.deleted_at` : 'deleted_at';
15
+ return mode === 'trashed' ? `${col} IS NOT NULL` : `${col} IS NULL`;
16
+ }
17
+ /**
18
+ * Returns ` AND <activeCondition>` for a soft-delete seed, or `''` otherwise.
19
+ *
20
+ * @param seed - The seed schema definition.
21
+ * @param mode - 'active' (default), 'trashed', or 'any'.
22
+ * @param tableAlias - Optional table or alias qualifier prefix.
23
+ */
24
+ export function activeClause(seed, mode = 'active', tableAlias) {
25
+ const cond = activeCondition(seed, mode, tableAlias);
26
+ return cond ? ` AND ${cond}` : '';
27
+ }
@@ -1,4 +1,4 @@
1
- import type { Seed, SelectOptions, ParameterizedQuery } from '../types.js';
1
+ import type { Seed, FilterGroup, SelectOptions, ParameterizedQuery } from '../types.js';
2
2
  /**
3
3
  * Builds a parameterized SQL SELECT query and bindings for a given Seed based on search, filtering, status, and ordering options.
4
4
  *
@@ -20,4 +20,17 @@ import type { Seed, SelectOptions, ParameterizedQuery } from '../types.js';
20
20
  * ```
21
21
  */
22
22
  export declare function buildSelectQuery(seed: Seed, options?: SelectOptions): ParameterizedQuery;
23
+ /**
24
+ * Lists the filter groups `buildSelectQuery` would silently leave out of the WHERE clause: an unknown
25
+ * column, or a condition whose value cannot be bound for the field type (empty or unparsable
26
+ * number/date, `in`/`not_in` value that is not an array). An empty `in` is not listed:
27
+ * it compiles to constant false and still constrains the read. A caller that needs the filter to constrain the read
28
+ * (e.g. automations resolving placeholders at run time) uses this to refuse the query instead of
29
+ * running it unconstrained.
30
+ *
31
+ * @param seed The seed the filters apply to.
32
+ * @param filters The filter groups to check.
33
+ * @returns The groups that would be dropped, in input order.
34
+ */
35
+ export declare function findUnappliedFilters(seed: Seed, filters: FilterGroup[]): FilterGroup[];
23
36
  //# sourceMappingURL=query.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../../../src/engine/query/query.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,IAAI,EAMJ,aAAa,EAEb,kBAAkB,EACnB,MAAM,aAAa,CAAC;AAgErB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,aAAkB,GAAG,kBAAkB,CA4G5F"}
1
+ {"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../../../src/engine/query/query.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,IAAI,EAGJ,WAAW,EAGX,aAAa,EAEb,kBAAkB,EACnB,MAAM,aAAa,CAAC;AA8FrB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,aAAkB,GAAG,kBAAkB,CAgH5F;AAGD;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,GAAG,WAAW,EAAE,CAKtF"}
@@ -1,6 +1,9 @@
1
1
  import { tableName, ftsTableName, isValidColumn, indexableSearchBranches, SYSTEM_COLUMNS } from '../ddl/ddl.js';
2
2
  import { resolveClassification } from '../privacy/policies.js';
3
3
  import { isLocaleCode, isLocalizedBranch } from '../localization/localization.js';
4
+ import { activeCondition } from './active-clause.js';
5
+ import { isSingleChoiceTags, tagOptionValues } from '../seeds/tag-options.js';
6
+ import { normalizeTagValue, TAG_OPTIONS_MAX_COUNT } from '../seeds/tag-value.js';
4
7
  /** SQLite GLOB twins of `LOCALE_CODE_RE` (`it`, `ast`, `pt-BR`, `es-419`, …). Keep both in sync. */
5
8
  const LOCALE_KEY_GLOBS = [
6
9
  '[a-z][a-z]',
@@ -57,6 +60,27 @@ function columnSql(seed, table, alias, locale) {
57
60
  }
58
61
  return alias;
59
62
  }
63
+ /** A multiple-choice `tags` column has no meaningful order, so `orderBy` on it is ignored. */
64
+ function isMultipleChoiceTagsColumn(seed, alias) {
65
+ const branch = seed.branches.find((b) => b.alias === alias);
66
+ return branch?.type === 'tags' && !isSingleChoiceTags(branch);
67
+ }
68
+ /**
69
+ * ORDER BY clause for a single-choice tags column: empty values (`NULL`, `[]`) always last, then the option
70
+ * position (a value outside the options ranks after every option) or, without options, the value alphabetically.
71
+ * Option values are pushed to `bindings`.
72
+ */
73
+ function tagsOrderClause(col, branch, dir, bindings) {
74
+ const head = `(CASE WHEN json_valid(${col}) THEN json_extract(${col}, '$[0]') END)`;
75
+ const options = tagOptionValues(branch);
76
+ let rank = head;
77
+ if (options.length > 0) {
78
+ const branches = options.map((_, position) => `WHEN ? THEN ${position}`).join(' ');
79
+ rank = `CASE ${head} ${branches} ELSE ${options.length} END`;
80
+ bindings.push(...options);
81
+ }
82
+ return ` ORDER BY (${head} IS NULL) ASC, ${rank} ${dir}`;
83
+ }
60
84
  /**
61
85
  * Builds a parameterized SQL SELECT query and bindings for a given Seed based on search, filtering, status, and ordering options.
62
86
  *
@@ -106,11 +130,9 @@ export function buildSelectQuery(seed, options = {}) {
106
130
  // which all reach SQL only through repository.findMany.
107
131
  if (seed.softDelete) {
108
132
  const trashed = options.trashed ?? 'active';
109
- if (trashed === 'active') {
110
- whereClauses.push(`${table}.deleted_at IS NULL`);
111
- }
112
- else if (trashed === 'trashed') {
113
- whereClauses.push(`${table}.deleted_at IS NOT NULL`);
133
+ const cond = activeCondition(seed, trashed, table);
134
+ if (cond) {
135
+ whereClauses.push(cond);
114
136
  }
115
137
  }
116
138
  const groupClauses = [];
@@ -135,6 +157,9 @@ export function buildSelectQuery(seed, options = {}) {
135
157
  if (clause)
136
158
  condClauses.push(clause);
137
159
  }
160
+ else {
161
+ throw new TypeError(`Invalid filter: operator '${op}' is not supported for encrypted field '${group.column}'`);
162
+ }
138
163
  }
139
164
  else {
140
165
  const clause = buildFilterCondition(col, group.type, cond, bindings);
@@ -170,10 +195,13 @@ export function buildSelectQuery(seed, options = {}) {
170
195
  if (kanbanOrderClause) {
171
196
  sql += kanbanOrderClause;
172
197
  }
173
- else if (orderBy && isValidColumn(seed, orderBy.column)) {
198
+ else if (orderBy && isValidColumn(seed, orderBy.column) && !isMultipleChoiceTagsColumn(seed, orderBy.column)) {
174
199
  const dir = orderBy.dir === 'DESC' ? 'DESC' : 'ASC';
175
200
  const col = columnSql(seed, table, orderBy.column, options.locale);
176
- sql += ` ORDER BY ${col} ${dir}`;
201
+ const orderedBranch = seed.branches.find((b) => b.alias === orderBy.column);
202
+ sql += orderedBranch && isSingleChoiceTags(orderedBranch)
203
+ ? tagsOrderClause(col, orderedBranch, dir, bindings)
204
+ : ` ORDER BY ${col} ${dir}`;
177
205
  }
178
206
  else {
179
207
  sql += ` ORDER BY ${table}.created_at DESC`;
@@ -185,6 +213,22 @@ export function buildSelectQuery(seed, options = {}) {
185
213
  }
186
214
  return { sql, bindings };
187
215
  }
216
+ /**
217
+ * Lists the filter groups `buildSelectQuery` would silently leave out of the WHERE clause: an unknown
218
+ * column, or a condition whose value cannot be bound for the field type (empty or unparsable
219
+ * number/date, `in`/`not_in` value that is not an array). An empty `in` is not listed:
220
+ * it compiles to constant false and still constrains the read. A caller that needs the filter to constrain the read
221
+ * (e.g. automations resolving placeholders at run time) uses this to refuse the query instead of
222
+ * running it unconstrained.
223
+ *
224
+ * @param seed The seed the filters apply to.
225
+ * @param filters The filter groups to check.
226
+ * @returns The groups that would be dropped, in input order.
227
+ */
228
+ export function findUnappliedFilters(seed, filters) {
229
+ return filters.filter((group) => !isValidColumn(seed, group.column)
230
+ || group.conditions.some((cond) => buildFilterCondition('col', group.type, cond, []) === null));
231
+ }
188
232
  /**
189
233
  * Normalizes user-provided filter values to a format appropriate for SQL execution.
190
234
  *
@@ -218,6 +262,35 @@ function normalizeFilterValue(type, value) {
218
262
  }
219
263
  return value;
220
264
  }
265
+ /** On a `tags` column, equality-like operators test membership; `negated` ones are the complement. */
266
+ const TAGS_MEMBERSHIP_OPS = {
267
+ eq: { op: 'has_tag', negated: false },
268
+ contains: { op: 'has_tag', negated: false },
269
+ in: { op: 'has_any_tag', negated: false },
270
+ neq: { op: 'has_tag', negated: true },
271
+ not_contains: { op: 'has_tag', negated: true },
272
+ not_in: { op: 'has_any_tag', negated: true },
273
+ };
274
+ function buildTagsMembershipCondition(col, op, value, bindings) {
275
+ const { op: membershipOp, negated } = TAGS_MEMBERSHIP_OPS[op];
276
+ const isSetOp = membershipOp === 'has_any_tag';
277
+ let tags;
278
+ if (isSetOp) {
279
+ if (!Array.isArray(value))
280
+ return null;
281
+ tags = value.filter((v) => typeof v === 'string' || typeof v === 'number').map(String);
282
+ // Same rule as in/not_in: an empty set matches nothing, its negation adds no clause.
283
+ if (tags.length === 0)
284
+ return negated ? null : '0';
285
+ }
286
+ else {
287
+ if (typeof value !== 'string' && typeof value !== 'number')
288
+ return null;
289
+ tags = [String(value)];
290
+ }
291
+ const condition = buildFilterCondition(col, 'tags', { op: membershipOp, value: isSetOp ? tags : tags[0] }, bindings);
292
+ return negated && condition ? `NOT (${condition})` : condition;
293
+ }
221
294
  /**
222
295
  * Translates a filter condition into its SQL clause and adds parameters to the bindings list.
223
296
  *
@@ -234,21 +307,26 @@ function buildFilterCondition(col, type, cond, bindings) {
234
307
  if (type === 'text') {
235
308
  return isEmpty ? `(${col} IS NULL OR ${col} = '')` : `(${col} IS NOT NULL AND ${col} != '')`;
236
309
  }
237
- if (type === 'tags' || type === 'json') {
310
+ if (type === 'tags' || type === 'json' || type === 'repeater') {
238
311
  return isEmpty
239
312
  ? `(${col} IS NULL OR ${col} = '[]' OR ${col} = '{}')`
240
313
  : `(${col} IS NOT NULL AND ${col} != '[]' AND ${col} != '{}')`;
241
314
  }
242
315
  return isEmpty ? `${col} IS NULL` : `${col} IS NOT NULL`;
243
316
  }
317
+ if (type === 'tags' && Object.hasOwn(TAGS_MEMBERSHIP_OPS, op)) {
318
+ return buildTagsMembershipCondition(col, op, value, bindings);
319
+ }
244
320
  if (op === 'in' || op === 'not_in') {
245
- if (!Array.isArray(value) || value.length === 0)
321
+ if (!Array.isArray(value))
246
322
  return null;
247
323
  const normalizedValues = value
248
324
  .map((v) => normalizeFilterValue(type, v))
249
325
  .filter((v) => v !== null);
326
+ // Membership in an empty set is false, not "no constraint"; dropping it would widen the result set.
327
+ // `not_in` over an empty set is vacuously true, so it adds no clause.
250
328
  if (normalizedValues.length === 0)
251
- return null;
329
+ return op === 'in' ? '0' : null;
252
330
  const placeholders = normalizedValues.map(() => '?').join(', ');
253
331
  bindings.push(...normalizedValues);
254
332
  return `${col} ${op === 'in' ? 'IN' : 'NOT IN'} (${placeholders})`;
@@ -256,10 +334,24 @@ function buildFilterCondition(col, type, cond, bindings) {
256
334
  if (op === 'has_tag' || op === 'has_any_tag' || op === 'has_all_tags') {
257
335
  if (type !== 'tags' && type !== 'json')
258
336
  return null;
259
- const tags = (op === 'has_tag' ? [value] : Array.isArray(value) ? value : [value])
260
- .map(t => String(t));
261
- if (tags.length === 0)
337
+ const requested = [...new Set((op === 'has_tag' ? [value] : Array.isArray(value) ? value : [value]).map(t => String(t)))];
338
+ if (requested.length === 0)
262
339
  return null;
340
+ // One bound parameter per tag and D1 allows 100 per statement: an oversized set is a malformed filter.
341
+ if (requested.length > TAG_OPTIONS_MAX_COUNT)
342
+ return null;
343
+ // A stored `tags` value is always normalized, so the needle is too. One that cannot be normalized
344
+ // (blank, too long, ill-formed) matches nothing, and `has_all_tags` over it can never be satisfied.
345
+ let tags = requested;
346
+ if (type === 'tags') {
347
+ const normalized = requested.flatMap((t) => {
348
+ const result = normalizeTagValue(t);
349
+ return result.ok && result.value.length > 0 ? [result.value] : [];
350
+ });
351
+ if (normalized.length === 0 || (op === 'has_all_tags' && normalized.length < requested.length))
352
+ return '0';
353
+ tags = [...new Set(normalized)];
354
+ }
263
355
  // json_each on array → value is the element; on object → key is the property name.
264
356
  // Tags stored as ["photo"] (array) or {"photo":"#3b82f6"} (object) must both match.
265
357
  const tagRef = `CASE json_type(${col}) WHEN 'array' THEN value ELSE key END`;
@@ -0,0 +1,14 @@
1
+ import type { Seed } from '../types.js';
2
+ /**
3
+ * Fills the id of every branch (and repeater sub-field) that lacks one.
4
+ *
5
+ * The stored id wins by alias match; only an alias the stored definition does not know gets a fresh
6
+ * `nextBranchId`, minted past every stored id and every explicit id anywhere in the candidate, so it
7
+ * never collides with a later explicit id nor recycles the id of a branch the candidate omits. An id
8
+ * already present on the candidate is kept as-is: callers that author without ids (hand-written
9
+ * manifests) must send none rather than invented ones.
10
+ */
11
+ export declare function resolveCandidateIds<T extends {
12
+ branches?: unknown;
13
+ }>(candidate: T, stored: Seed | null): T;
14
+ //# sourceMappingURL=candidate-ids.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"candidate-ids.d.ts","sourceRoot":"","sources":["../../../src/engine/seeds/candidate-ids.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAU,IAAI,EAAE,MAAM,aAAa,CAAA;AAE/C;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,SAAS;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,EAAE,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,GAAG,IAAI,GAAG,CAAC,CAM1G"}
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024–2026 Flavio De Musso
3
+ /**
4
+ * @module engine/seeds/candidate-ids
5
+ * Resolves branch ids on a candidate seed definition against the stored one.
6
+ */
7
+ import { nextBranchId } from './seed-registry.js';
8
+ /**
9
+ * Fills the id of every branch (and repeater sub-field) that lacks one.
10
+ *
11
+ * The stored id wins by alias match; only an alias the stored definition does not know gets a fresh
12
+ * `nextBranchId`, minted past every stored id and every explicit id anywhere in the candidate, so it
13
+ * never collides with a later explicit id nor recycles the id of a branch the candidate omits. An id
14
+ * already present on the candidate is kept as-is: callers that author without ids (hand-written
15
+ * manifests) must send none rather than invented ones.
16
+ */
17
+ export function resolveCandidateIds(candidate, stored) {
18
+ const storedBranches = stored?.branches ?? [];
19
+ return {
20
+ ...candidate,
21
+ branches: assignIds(Array.isArray(candidate.branches) ? candidate.branches : [], storedBranches),
22
+ };
23
+ }
24
+ function assignIds(branches, stored) {
25
+ const storedByAlias = new Map(stored.map(b => [b.alias, b]));
26
+ const taken = [...stored, ...branches.filter(b => b.id)];
27
+ const resolved = [];
28
+ for (const source of branches) {
29
+ const match = source.alias === undefined ? undefined : storedByAlias.get(source.alias);
30
+ const branch = { ...source };
31
+ if (!branch.id) {
32
+ branch.id = match?.id || nextBranchId({ branches: taken });
33
+ taken.push(branch);
34
+ }
35
+ if (Array.isArray(source.fields))
36
+ branch.fields = assignIds(source.fields, match?.fields ?? []);
37
+ resolved.push(branch);
38
+ }
39
+ return resolved;
40
+ }