@beechcms/core 0.9.5 → 0.9.7
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.
- package/LICENSE +21 -0
- package/dist/auth/login-request.d.ts +16 -0
- package/dist/auth/login-request.d.ts.map +1 -0
- package/dist/auth/login-request.js +22 -0
- package/dist/automations-grammar-words.d.ts +2 -0
- package/dist/automations-grammar-words.d.ts.map +1 -0
- package/dist/automations-grammar-words.js +8 -0
- package/dist/automations.repository.interface.d.ts +19 -0
- package/dist/automations.repository.interface.d.ts.map +1 -0
- package/dist/automations.repository.interface.js +3 -0
- package/dist/automations.runner.interface.d.ts +10 -0
- package/dist/automations.runner.interface.d.ts.map +1 -0
- package/dist/automations.runner.interface.js +3 -0
- package/dist/automations.runner.stub.d.ts +5 -0
- package/dist/automations.runner.stub.d.ts.map +1 -0
- package/dist/automations.runner.stub.js +7 -0
- package/dist/automations.types.d.ts +75 -0
- package/dist/automations.types.d.ts.map +1 -0
- package/dist/automations.types.js +3 -0
- package/dist/clock.d.ts +25 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +11 -0
- package/dist/content/content.repository.d.ts +22 -22
- package/dist/content/content.repository.d.ts.map +1 -1
- package/dist/content/retention.d.ts +13 -0
- package/dist/content/retention.d.ts.map +1 -0
- package/dist/content/retention.js +20 -0
- package/dist/content/slug-utils.d.ts +7 -0
- package/dist/content/slug-utils.d.ts.map +1 -1
- package/dist/content/slug-utils.js +12 -0
- package/dist/content-scan.repository.d.ts +9 -0
- package/dist/content-scan.repository.d.ts.map +1 -0
- package/dist/content-scan.repository.js +3 -0
- package/dist/content.repository.d.ts +247 -0
- package/dist/content.repository.d.ts.map +1 -0
- package/dist/content.repository.js +59 -0
- package/dist/dashboard-layout/dashboard-layout.d.ts +2 -2
- package/dist/dashboard-layout/dashboard-layout.d.ts.map +1 -1
- package/dist/dashboard-layout/dashboard-layout.js +3 -3
- package/dist/dashboard-layout/relation-avatar.d.ts +12 -0
- package/dist/dashboard-layout/relation-avatar.d.ts.map +1 -0
- package/dist/dashboard-layout/relation-avatar.js +18 -0
- package/dist/dashboard-layout/seed-layout.d.ts.map +1 -1
- package/dist/dashboard-layout/seed-layout.js +17 -1
- package/dist/dashboard-layout.d.ts +154 -0
- package/dist/dashboard-layout.d.ts.map +1 -0
- package/dist/dashboard-layout.js +186 -0
- package/dist/dashboard-layout.repository.d.ts +18 -0
- package/dist/dashboard-layout.repository.d.ts.map +1 -0
- package/dist/dashboard-layout.repository.js +3 -0
- package/dist/dashboard-permissions.d.ts +3 -0
- package/dist/dashboard-permissions.d.ts.map +1 -0
- package/dist/dashboard-permissions.js +11 -0
- package/dist/dashboard-scopes.d.ts +12 -0
- package/dist/dashboard-scopes.d.ts.map +1 -0
- package/dist/dashboard-scopes.js +30 -0
- package/dist/define-seed.d.ts +3 -0
- package/dist/define-seed.d.ts.map +1 -0
- package/dist/define-seed.js +5 -0
- package/dist/demo-data.repository.d.ts +5 -0
- package/dist/demo-data.repository.d.ts.map +1 -0
- package/dist/demo-data.repository.js +3 -0
- package/dist/email.provider.d.ts +32 -0
- package/dist/email.provider.d.ts.map +1 -0
- package/dist/email.provider.js +4 -0
- package/dist/engine/ddl/ddl.d.ts +4 -6
- package/dist/engine/ddl/ddl.d.ts.map +1 -1
- package/dist/engine/ddl/ddl.js +13 -27
- package/dist/engine/ddl/seed-ddl.d.ts.map +1 -1
- package/dist/engine/ddl/seed-ddl.js +10 -2
- package/dist/engine/ddl.d.ts +0 -23
- package/dist/engine/ddl.d.ts.map +1 -1
- package/dist/engine/ddl.js +3 -59
- package/dist/engine/localization.d.ts +127 -0
- package/dist/engine/localization.d.ts.map +1 -0
- package/dist/engine/localization.js +212 -0
- package/dist/engine/privacy.service.d.ts.map +1 -1
- package/dist/engine/query/active-clause.d.ts +2 -3
- package/dist/engine/query/active-clause.d.ts.map +1 -1
- package/dist/engine/query/active-clause.js +3 -4
- package/dist/engine/query/query.js +8 -8
- package/dist/engine/query/search-support.d.ts +32 -0
- package/dist/engine/query/search-support.d.ts.map +1 -0
- package/dist/engine/query/search-support.js +34 -0
- package/dist/engine/query.d.ts.map +1 -1
- package/dist/engine/query.js +0 -12
- package/dist/engine/schema-fingerprint.d.ts +2 -0
- package/dist/engine/schema-fingerprint.d.ts.map +1 -1
- package/dist/engine/schema-fingerprint.js +2 -0
- package/dist/engine/seed-ddl.d.ts +1 -4
- package/dist/engine/seed-ddl.d.ts.map +1 -1
- package/dist/engine/seed-ddl.js +2 -17
- package/dist/engine/seed-registry.d.ts.map +1 -1
- package/dist/engine/seed-types-generator.d.ts +2 -17
- package/dist/engine/seed-types-generator.d.ts.map +1 -1
- package/dist/engine/seed-types-generator.js +3 -31
- package/dist/engine/seed-validation.d.ts.map +1 -1
- package/dist/engine/seed-validation.js +0 -10
- package/dist/engine/seeds/seed-definition-diff.d.ts +1 -1
- package/dist/engine/seeds/seed-definition-diff.d.ts.map +1 -1
- package/dist/engine/seeds/seed-definition-diff.js +2 -2
- package/dist/engine/seeds/seed-validation.d.ts.map +1 -1
- package/dist/engine/seeds/seed-validation.js +3 -12
- package/dist/engine/types.d.ts +7 -7
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/engine.d.ts +144 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +741 -0
- package/dist/file-types.d.ts +13 -0
- package/dist/file-types.d.ts.map +1 -0
- package/dist/file-types.js +85 -0
- package/dist/hooks.d.ts +29 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +3 -0
- package/dist/id-generator.d.ts +26 -0
- package/dist/id-generator.d.ts.map +1 -0
- package/dist/id-generator.js +12 -0
- package/dist/idempotency.repository.d.ts +13 -0
- package/dist/idempotency.repository.d.ts.map +1 -0
- package/dist/idempotency.repository.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/kanban-position.repository.d.ts +19 -0
- package/dist/kanban-position.repository.d.ts.map +1 -0
- package/dist/kanban-position.repository.js +3 -0
- package/dist/kanban.d.ts +75 -0
- package/dist/kanban.d.ts.map +1 -0
- package/dist/kanban.js +82 -0
- package/dist/layout-permissions.d.ts +3 -0
- package/dist/layout-permissions.d.ts.map +1 -0
- package/dist/layout-permissions.js +11 -0
- package/dist/media.repository.d.ts +61 -0
- package/dist/media.repository.d.ts.map +1 -0
- package/dist/media.repository.js +3 -0
- package/dist/notifications/notification.repository.d.ts +6 -3
- package/dist/notifications/notification.repository.d.ts.map +1 -1
- package/dist/policies.d.ts +10 -0
- package/dist/policies.d.ts.map +1 -0
- package/dist/policies.js +33 -0
- package/dist/queue.interface.d.ts +39 -0
- package/dist/queue.interface.d.ts.map +1 -0
- package/dist/queue.interface.js +3 -0
- package/dist/queue.stub.d.ts +6 -0
- package/dist/queue.stub.d.ts.map +1 -0
- package/dist/queue.stub.js +8 -0
- package/dist/relations.d.ts +28 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +25 -0
- package/dist/richtext-render.d.ts +11 -0
- package/dist/richtext-render.d.ts.map +1 -0
- package/dist/richtext-render.js +87 -0
- package/dist/richtext.d.ts +11 -0
- package/dist/richtext.d.ts.map +1 -0
- package/dist/richtext.js +13 -0
- package/dist/scheduler.interface.d.ts +4 -0
- package/dist/scheduler.interface.d.ts.map +1 -0
- package/dist/scheduler.interface.js +3 -0
- package/dist/scheduler.stub.d.ts +5 -0
- package/dist/scheduler.stub.d.ts.map +1 -0
- package/dist/scheduler.stub.js +7 -0
- package/dist/schema-mutator.d.ts +26 -0
- package/dist/schema-mutator.d.ts.map +1 -0
- package/dist/schema-mutator.js +3 -0
- package/dist/security/time-trap.d.ts +3 -2
- package/dist/security/time-trap.d.ts.map +1 -1
- package/dist/security/time-trap.js +5 -4
- package/dist/seed-ddl-destructive.d.ts +40 -0
- package/dist/seed-ddl-destructive.d.ts.map +1 -0
- package/dist/seed-ddl-destructive.js +149 -0
- package/dist/seed-ddl.d.ts +40 -0
- package/dist/seed-ddl.d.ts.map +1 -0
- package/dist/seed-ddl.js +100 -0
- package/dist/seed-layout.d.ts +145 -0
- package/dist/seed-layout.d.ts.map +1 -0
- package/dist/seed-layout.js +192 -0
- package/dist/seed-layout.repository.d.ts +22 -0
- package/dist/seed-layout.repository.d.ts.map +1 -0
- package/dist/seed-layout.repository.js +3 -0
- package/dist/seed-registry.d.ts +71 -0
- package/dist/seed-registry.d.ts.map +1 -0
- package/dist/seed-registry.js +143 -0
- package/dist/seed-types-generator.d.ts +1 -1
- package/dist/seed-types-generator.d.ts.map +1 -1
- package/dist/seed-validation.d.ts +23 -0
- package/dist/seed-validation.d.ts.map +1 -0
- package/dist/seed-validation.js +224 -0
- package/dist/seed.repository.d.ts +37 -0
- package/dist/seed.repository.d.ts.map +1 -0
- package/dist/seed.repository.js +3 -0
- package/dist/seeds.d.ts +20 -0
- package/dist/seeds.d.ts.map +1 -0
- package/dist/seeds.js +21 -0
- package/dist/settings/site-settings.repository.d.ts +2 -0
- package/dist/settings/site-settings.repository.d.ts.map +1 -1
- package/dist/site-settings.repository.d.ts +16 -0
- package/dist/site-settings.repository.d.ts.map +1 -0
- package/dist/site-settings.repository.js +3 -0
- package/dist/slug-utils.d.ts +18 -0
- package/dist/slug-utils.d.ts.map +1 -0
- package/dist/slug-utils.js +32 -0
- package/dist/storage.d.ts +46 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +3 -0
- package/dist/types.d.ts +243 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/validation.d.ts +50 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +660 -0
- package/dist/webhook-validation.d.ts +3 -0
- package/dist/webhook-validation.d.ts.map +1 -0
- package/dist/webhook-validation.js +6 -0
- package/package.json +1 -1
- package/dist/content/content-lock.repository.d.ts +0 -63
- package/dist/content/content-lock.repository.d.ts.map +0 -1
- package/dist/content/content-lock.repository.js +0 -15
package/dist/engine/ddl.js
CHANGED
|
@@ -25,7 +25,7 @@ const BRANCH_TYPE_SQL = {
|
|
|
25
25
|
/**
|
|
26
26
|
* System-defined columns that exist on all content tables.
|
|
27
27
|
*/
|
|
28
|
-
export const SYSTEM_COLUMNS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at'
|
|
28
|
+
export const SYSTEM_COLUMNS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at']);
|
|
29
29
|
/**
|
|
30
30
|
* Returns the SQL table name for a given Seed.
|
|
31
31
|
*
|
|
@@ -70,8 +70,6 @@ export function vectorTableName(seed) {
|
|
|
70
70
|
* @returns True if the column is valid, false otherwise.
|
|
71
71
|
*/
|
|
72
72
|
export function isValidColumn(seed, col) {
|
|
73
|
-
if (col === 'deleted_at')
|
|
74
|
-
return seed.softDelete === true;
|
|
75
73
|
if (SYSTEM_COLUMNS.has(col))
|
|
76
74
|
return true;
|
|
77
75
|
return seed.branches.some(b => b.alias === col);
|
|
@@ -127,12 +125,7 @@ export function generateCreateTable(seed) {
|
|
|
127
125
|
const lines = [
|
|
128
126
|
`CREATE TABLE IF NOT EXISTS ${table} (`,
|
|
129
127
|
` id TEXT NOT NULL PRIMARY KEY,`,
|
|
130
|
-
|
|
131
|
-
// row stops reserving its slug. An inline UNIQUE would become a sqlite_autoindex that
|
|
132
|
-
// no ALTER TABLE can drop later.
|
|
133
|
-
seed.softDelete
|
|
134
|
-
? ` slug TEXT NOT NULL,`
|
|
135
|
-
: ` slug TEXT NOT NULL UNIQUE,`,
|
|
128
|
+
` slug TEXT NOT NULL UNIQUE,`,
|
|
136
129
|
` status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'review', 'published', 'archived')),`,
|
|
137
130
|
];
|
|
138
131
|
for (const branch of seed.branches) {
|
|
@@ -151,9 +144,7 @@ export function generateCreateTable(seed) {
|
|
|
151
144
|
}
|
|
152
145
|
}
|
|
153
146
|
lines.push(` created_at INTEGER NOT NULL DEFAULT (unixepoch()),`);
|
|
154
|
-
lines.push(` updated_at INTEGER NOT NULL DEFAULT (unixepoch())`
|
|
155
|
-
if (seed.softDelete)
|
|
156
|
-
lines.push(` deleted_at INTEGER`);
|
|
147
|
+
lines.push(` updated_at INTEGER NOT NULL DEFAULT (unixepoch())`);
|
|
157
148
|
lines.push(`);`);
|
|
158
149
|
return lines.join('\n');
|
|
159
150
|
}
|
|
@@ -187,10 +178,6 @@ export function generateDraftTable(seed) {
|
|
|
187
178
|
}
|
|
188
179
|
}
|
|
189
180
|
lines.push(` _touched_fields TEXT,`);
|
|
190
|
-
// Live-row `updated_at` as it stood when this draft was created, written once and never re-based.
|
|
191
|
-
// Nullable on purpose: drafts created before optimistic-concurrency publish existed carry NULL and
|
|
192
|
-
// publish unchecked, so enabling this feature cannot block an in-flight draft.
|
|
193
|
-
lines.push(` live_snapshot_at INTEGER,`);
|
|
194
181
|
lines.push(` updated_at INTEGER NOT NULL DEFAULT (unixepoch())`);
|
|
195
182
|
lines.push(`);`);
|
|
196
183
|
return lines.join('\n');
|
|
@@ -206,21 +193,6 @@ export function generateAddColumn(seed, branch) {
|
|
|
206
193
|
const { sqlType } = BRANCH_TYPE_SQL[branch.type];
|
|
207
194
|
return `ALTER TABLE ${tableName(seed)} ADD COLUMN ${branch.alias} ${sqlType}${buildForeignKeyClause(branch)};`;
|
|
208
195
|
}
|
|
209
|
-
/**
|
|
210
|
-
* Generates the `ALTER TABLE content_{slug}_drafts ADD COLUMN live_snapshot_at INTEGER` statement
|
|
211
|
-
* for a draft table provisioned before the column existed.
|
|
212
|
-
*
|
|
213
|
-
* System column, not a branch — `generateAddColumn` iterates `seed.branches` and can never emit it.
|
|
214
|
-
* NOT idempotent: `ADD COLUMN` fails when the column is already present and `ISchemaMutator.execDdl`
|
|
215
|
-
* aborts the whole batch on the first failing statement, so the caller MUST check the physical
|
|
216
|
-
* columns first (see `planExtendSeed`).
|
|
217
|
-
*
|
|
218
|
-
* @param seed The seed definition. Caller must have verified `seed.allowDrafts`.
|
|
219
|
-
* @returns The ALTER TABLE statement.
|
|
220
|
-
*/
|
|
221
|
-
export function generateAddDraftSnapshotColumn(seed) {
|
|
222
|
-
return `ALTER TABLE content_${seed.slug}_drafts ADD COLUMN live_snapshot_at INTEGER;`;
|
|
223
|
-
}
|
|
224
196
|
/**
|
|
225
197
|
* Generates SQL index statements for system fields (status, created_at)
|
|
226
198
|
* and indexable branch columns.
|
|
@@ -248,11 +220,6 @@ export function generateIndexes(seed) {
|
|
|
248
220
|
indexes.push(`CREATE INDEX IF NOT EXISTS idx_${slug}_${branch.alias}_bidx ON ${table}(${branch.alias}_bidx);`);
|
|
249
221
|
}
|
|
250
222
|
}
|
|
251
|
-
if (seed.softDelete) {
|
|
252
|
-
indexes.push(`CREATE INDEX IF NOT EXISTS idx_${slug}_deleted_at ON ${table}(deleted_at);`);
|
|
253
|
-
// Active rows only: a trashed row releases its slug for reuse (feature brief §4).
|
|
254
|
-
indexes.push(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${slug}_slug_active ON ${table}(slug) WHERE deleted_at IS NULL;`);
|
|
255
|
-
}
|
|
256
223
|
return indexes;
|
|
257
224
|
}
|
|
258
225
|
/**
|
|
@@ -366,9 +333,6 @@ export function getExpectedColumns(seed) {
|
|
|
366
333
|
...branchCols,
|
|
367
334
|
{ name: 'created_at', sqlType: 'INTEGER', notNull: true, isPk: false },
|
|
368
335
|
{ name: 'updated_at', sqlType: 'INTEGER', notNull: true, isPk: false },
|
|
369
|
-
...(seed.softDelete
|
|
370
|
-
? [{ name: 'deleted_at', sqlType: 'INTEGER', notNull: false, isPk: false }]
|
|
371
|
-
: []),
|
|
372
336
|
];
|
|
373
337
|
}
|
|
374
338
|
/**
|
|
@@ -552,23 +516,3 @@ export function generateRetypeColumn(seed, branch) {
|
|
|
552
516
|
stmts.push(...rebuild(draftTableName(seed)));
|
|
553
517
|
return stmts;
|
|
554
518
|
}
|
|
555
|
-
/**
|
|
556
|
-
* Additive statements that turn soft delete on for a table that already exists.
|
|
557
|
-
*
|
|
558
|
-
* The partial unique index is emitted for correctness on tables that were CREATED with
|
|
559
|
-
* `softDelete: true`. On a pre-existing table the inline `slug … UNIQUE` survives as a
|
|
560
|
-
* sqlite_autoindex that SQLite cannot drop via ALTER; there the partial index is redundant
|
|
561
|
-
* and a trashed slug stays reserved until the table is rebuilt (see ROADMAP deferral).
|
|
562
|
-
* Returns [] when the seed does not opt in.
|
|
563
|
-
*/
|
|
564
|
-
export function generateEnableSoftDelete(seed) {
|
|
565
|
-
if (!seed.softDelete)
|
|
566
|
-
return [];
|
|
567
|
-
const table = tableName(seed);
|
|
568
|
-
const slug = seed.slug;
|
|
569
|
-
return [
|
|
570
|
-
`ALTER TABLE ${table} ADD COLUMN deleted_at INTEGER;`,
|
|
571
|
-
`CREATE INDEX IF NOT EXISTS idx_${slug}_deleted_at ON ${table}(deleted_at);`,
|
|
572
|
-
`CREATE UNIQUE INDEX IF NOT EXISTS idx_${slug}_slug_active ON ${table}(slug) WHERE deleted_at IS NULL;`,
|
|
573
|
-
];
|
|
574
|
-
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module engine/localization
|
|
3
|
+
* Field-level localization primitives for the Botanical Engine. A localized branch keeps its native
|
|
4
|
+
* TEXT column and stores a locale dictionary in it (`{"it":"Scarpa","en":"Shoe"}`), so ids, relations
|
|
5
|
+
* and metrics are untouched and toggling `Branch.localized` never emits DDL. Pure functions: no I/O and
|
|
6
|
+
* no settings access — callers pass the project's {@link LocaleConfig}.
|
|
7
|
+
*/
|
|
8
|
+
import type { Branch, BranchType, Seed } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Locale code grammar (v1): ISO 639 language (2–3 lowercase letters), optionally followed by an ISO 3166
|
|
11
|
+
* region (2 uppercase letters) or a UN M.49 area (3 digits) — `it`, `en`, `pt-BR`, `es-419`. Script
|
|
12
|
+
* subtags (`zh-Hant`) are outside v1. The grammar doubles as an injection guard: validated codes are
|
|
13
|
+
* later interpolated into SQLite JSON paths.
|
|
14
|
+
*/
|
|
15
|
+
export declare const LOCALE_CODE_RE: RegExp;
|
|
16
|
+
/** Branch types that may carry `localized: true`. */
|
|
17
|
+
export declare const LOCALIZABLE_BRANCH_TYPES: ReadonlySet<BranchType>;
|
|
18
|
+
/**
|
|
19
|
+
* Project-level language configuration. Invariant (guaranteed by its producer): `locales` is non-empty,
|
|
20
|
+
* every entry matches {@link LOCALE_CODE_RE}, and it contains `defaultLocale`.
|
|
21
|
+
*/
|
|
22
|
+
export interface LocaleConfig {
|
|
23
|
+
readonly locales: readonly string[];
|
|
24
|
+
readonly defaultLocale: string;
|
|
25
|
+
}
|
|
26
|
+
/** A locale dictionary as stored in the branch column: locale code → value. */
|
|
27
|
+
export type LocalizedDictionary = Record<string, unknown>;
|
|
28
|
+
/**
|
|
29
|
+
* A validated write for a localized branch: registered locale code → new value, or `null` meaning
|
|
30
|
+
* "clear this locale". Locales absent from the patch must be left untouched by the write path.
|
|
31
|
+
*/
|
|
32
|
+
export type LocalizedPatch = Record<string, unknown>;
|
|
33
|
+
/** True when `value` is a string matching {@link LOCALE_CODE_RE}. */
|
|
34
|
+
export declare function isLocaleCode(value: unknown): value is string;
|
|
35
|
+
/**
|
|
36
|
+
* True when the branch is localized AND its type supports it. A malformed definition that slipped past
|
|
37
|
+
* seed validation (e.g. `localized: true` on a `number`) is treated as not localized, never as a dictionary.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isLocalizedBranch(branch: Pick<Branch, 'type' | 'localized'>): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Structural check used on READ paths (no config available): a non-empty plain object whose every key
|
|
42
|
+
* matches the locale-code grammar. A richtext envelope (`schemaVersion`, `doc`) and a TipTap doc
|
|
43
|
+
* (`type`, `content`) never match.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isLocaleDictionary(value: unknown): value is LocalizedDictionary;
|
|
46
|
+
/**
|
|
47
|
+
* Stricter check used on WRITE paths: a locale dictionary with at least one REGISTERED locale key.
|
|
48
|
+
* Without the registration requirement a legitimate json value such as `{"url": "…", "alt": "…"}` —
|
|
49
|
+
* whose keys happen to match the grammar — would be read as a dictionary of unregistered locales and
|
|
50
|
+
* silently emptied.
|
|
51
|
+
*/
|
|
52
|
+
export declare function isLocalizedWriteDictionary(value: unknown, config: LocaleConfig): value is LocalizedDictionary;
|
|
53
|
+
/**
|
|
54
|
+
* Normalises any accepted write value of a localized branch into a {@link LocalizedPatch}:
|
|
55
|
+
* - a write dictionary keeps its registered locales, in `config.locales` order; unregistered locale
|
|
56
|
+
* keys are dropped;
|
|
57
|
+
* - any other value (string, richtext doc, non-dictionary json) is the default-locale value;
|
|
58
|
+
* - `null`, `undefined` and blank strings become `null` ("clear this locale").
|
|
59
|
+
* Idempotent: a patch passed back in yields the same patch.
|
|
60
|
+
*/
|
|
61
|
+
export declare function toLocalizedPatch(value: unknown, config: LocaleConfig): LocalizedPatch;
|
|
62
|
+
/**
|
|
63
|
+
* Removes `null` / `undefined` / blank-string entries. Returns `null` when nothing remains, so an
|
|
64
|
+
* all-cleared dictionary is stored as SQL NULL rather than `{}`. Keys are otherwise preserved as-is —
|
|
65
|
+
* including locales no longer registered, which must never be dropped by a write (brief §2, no data loss).
|
|
66
|
+
*/
|
|
67
|
+
export declare function compactLocalizedDictionary(value: LocalizedDictionary): LocalizedDictionary | null;
|
|
68
|
+
/**
|
|
69
|
+
* The settings subset {@link resolveLocaleConfig} reads. Structural, so this module stays free of
|
|
70
|
+
* settings imports; `SiteSettings` satisfies it.
|
|
71
|
+
*/
|
|
72
|
+
export interface LocaleSettings {
|
|
73
|
+
readonly locales?: readonly string[] | null;
|
|
74
|
+
readonly defaultLocale?: string | null;
|
|
75
|
+
readonly defaultLanguage: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Builds the project's {@link LocaleConfig}, repairing whatever a hand-edited settings row could break so
|
|
79
|
+
* the LocaleConfig invariant always holds:
|
|
80
|
+
* - `locales`: the stored list minus invalid codes and duplicates; when nothing remains (never configured,
|
|
81
|
+
* `[]`, corrupt), the implicit single-language config `[defaultLanguage]` (brief §2), which keeps the
|
|
82
|
+
* feature invisible to a mono-lingual project;
|
|
83
|
+
* - `defaultLocale`: the stored value when it belongs to `locales`, else `locales[0]`.
|
|
84
|
+
*/
|
|
85
|
+
export declare function resolveLocaleConfig(settings: LocaleSettings): LocaleConfig;
|
|
86
|
+
/**
|
|
87
|
+
* Merges a write into the value stored for a localized branch. The write path's only way to persist a
|
|
88
|
+
* localized value, so a translation the write does not mention is never dropped (brief §2):
|
|
89
|
+
* - `write === null` clears the whole field (an explicit null keeps its pre-localization meaning);
|
|
90
|
+
* - otherwise the write is normalised with {@link toLocalizedPatch}: every registered locale it names
|
|
91
|
+
* overwrites that locale, a `null` entry clears that locale only, and every other stored locale —
|
|
92
|
+
* including ones no longer registered — is kept as-is;
|
|
93
|
+
* - a stored legacy value (written before the branch became localized) is the default-locale value.
|
|
94
|
+
* @returns The compacted dictionary, or `null` when no locale keeps a value.
|
|
95
|
+
*/
|
|
96
|
+
export declare function applyLocalizedPatch(branch: Pick<Branch, 'type' | 'localized'>, stored: unknown, write: unknown, config: LocaleConfig): LocalizedDictionary | null;
|
|
97
|
+
/** Aliases of the localized branches a write carries — the fields it must merge rather than replace. */
|
|
98
|
+
export declare function localizedAliasesIn(seed: Pick<Seed, 'branches'>, data: Record<string, unknown>): string[];
|
|
99
|
+
/**
|
|
100
|
+
* Returns `data` with every localized branch it carries merged into `stored` via
|
|
101
|
+
* {@link applyLocalizedPatch}; other keys pass through untouched. `stored` is the entry's current values
|
|
102
|
+
* (`null` on create, which only compacts). Returns `data` itself when `config` is undefined or no localized
|
|
103
|
+
* branch is written.
|
|
104
|
+
*/
|
|
105
|
+
export declare function mergeLocalizedFields(seed: Pick<Seed, 'branches'>, stored: Record<string, unknown> | null, data: Record<string, unknown>, config: LocaleConfig | undefined): Record<string, unknown>;
|
|
106
|
+
/**
|
|
107
|
+
* Resolves a stored localized value to one language: the requested locale, then the default locale, then the
|
|
108
|
+
* first stored translation (stored key order), then `null`. A legacy value that is not a dictionary is returned
|
|
109
|
+
* as-is. Non-localized branches return `value` unchanged. `buildSelectQuery` applies the same chain in SQL, so
|
|
110
|
+
* filters and ORDER BY compare exactly what a reader sees.
|
|
111
|
+
*/
|
|
112
|
+
export declare function resolveLocalizedValue(branch: Pick<Branch, 'type' | 'localized'>, value: unknown, locale: string, config: LocaleConfig): unknown;
|
|
113
|
+
/**
|
|
114
|
+
* Returns a copy of `data` with every localized branch resolved to `locale` (default: the default locale)
|
|
115
|
+
* via {@link resolveLocalizedValue}. Returns `data` itself when `config` is undefined.
|
|
116
|
+
*/
|
|
117
|
+
export declare function resolveLocalizedFields(seed: Pick<Seed, 'branches'>, data: Record<string, unknown>, config: LocaleConfig | undefined, locale?: string): Record<string, unknown>;
|
|
118
|
+
/**
|
|
119
|
+
* The whole dictionary of a stored localized value, for readers asking every language (`?lang=all`):
|
|
120
|
+
* a stored dictionary as-is — unregistered locales included, nothing is hidden (brief §2) — a legacy
|
|
121
|
+
* value as `{ [defaultLocale]: value }` (the write path's reading of it), a blank value as `null`.
|
|
122
|
+
* Non-localized branches return `value` unchanged.
|
|
123
|
+
*/
|
|
124
|
+
export declare function asLocaleDictionary(branch: Pick<Branch, 'type' | 'localized'>, value: unknown, config: LocaleConfig): unknown;
|
|
125
|
+
/** Returns a copy of `data` with every localized branch it carries passed through {@link asLocaleDictionary}. */
|
|
126
|
+
export declare function asLocaleDictionaries(seed: Pick<Seed, 'branches'>, data: Record<string, unknown>, config: LocaleConfig): Record<string, unknown>;
|
|
127
|
+
//# sourceMappingURL=localization.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"localization.d.ts","sourceRoot":"","sources":["../../src/engine/localization.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
import { cleanString, isPlainObject } from './validation/primitives.js';
|
|
4
|
+
/**
|
|
5
|
+
* Locale code grammar (v1): ISO 639 language (2–3 lowercase letters), optionally followed by an ISO 3166
|
|
6
|
+
* region (2 uppercase letters) or a UN M.49 area (3 digits) — `it`, `en`, `pt-BR`, `es-419`. Script
|
|
7
|
+
* subtags (`zh-Hant`) are outside v1. The grammar doubles as an injection guard: validated codes are
|
|
8
|
+
* later interpolated into SQLite JSON paths.
|
|
9
|
+
*/
|
|
10
|
+
export const LOCALE_CODE_RE = /^[a-z]{2,3}(?:-(?:[A-Z]{2}|[0-9]{3}))?$/;
|
|
11
|
+
/** Branch types that may carry `localized: true`. */
|
|
12
|
+
export const LOCALIZABLE_BRANCH_TYPES = new Set(['text', 'richtext', 'json']);
|
|
13
|
+
/** True when `value` is a string matching {@link LOCALE_CODE_RE}. */
|
|
14
|
+
export function isLocaleCode(value) {
|
|
15
|
+
return typeof value === 'string' && LOCALE_CODE_RE.test(value);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* True when the branch is localized AND its type supports it. A malformed definition that slipped past
|
|
19
|
+
* seed validation (e.g. `localized: true` on a `number`) is treated as not localized, never as a dictionary.
|
|
20
|
+
*/
|
|
21
|
+
export function isLocalizedBranch(branch) {
|
|
22
|
+
return branch.localized === true && LOCALIZABLE_BRANCH_TYPES.has(branch.type);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Structural check used on READ paths (no config available): a non-empty plain object whose every key
|
|
26
|
+
* matches the locale-code grammar. A richtext envelope (`schemaVersion`, `doc`) and a TipTap doc
|
|
27
|
+
* (`type`, `content`) never match.
|
|
28
|
+
*/
|
|
29
|
+
export function isLocaleDictionary(value) {
|
|
30
|
+
if (!isPlainObject(value))
|
|
31
|
+
return false;
|
|
32
|
+
const keys = Object.keys(value);
|
|
33
|
+
return keys.length > 0 && keys.every(isLocaleCode);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Stricter check used on WRITE paths: a locale dictionary with at least one REGISTERED locale key.
|
|
37
|
+
* Without the registration requirement a legitimate json value such as `{"url": "…", "alt": "…"}` —
|
|
38
|
+
* whose keys happen to match the grammar — would be read as a dictionary of unregistered locales and
|
|
39
|
+
* silently emptied.
|
|
40
|
+
*/
|
|
41
|
+
export function isLocalizedWriteDictionary(value, config) {
|
|
42
|
+
return isLocaleDictionary(value) && Object.keys(value).some((key) => config.locales.includes(key));
|
|
43
|
+
}
|
|
44
|
+
function isBlankLocaleValue(value) {
|
|
45
|
+
return value === null || value === undefined || (typeof value === 'string' && cleanString(value) === '');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Normalises any accepted write value of a localized branch into a {@link LocalizedPatch}:
|
|
49
|
+
* - a write dictionary keeps its registered locales, in `config.locales` order; unregistered locale
|
|
50
|
+
* keys are dropped;
|
|
51
|
+
* - any other value (string, richtext doc, non-dictionary json) is the default-locale value;
|
|
52
|
+
* - `null`, `undefined` and blank strings become `null` ("clear this locale").
|
|
53
|
+
* Idempotent: a patch passed back in yields the same patch.
|
|
54
|
+
*/
|
|
55
|
+
export function toLocalizedPatch(value, config) {
|
|
56
|
+
const source = isLocalizedWriteDictionary(value, config)
|
|
57
|
+
? value
|
|
58
|
+
: { [config.defaultLocale]: value };
|
|
59
|
+
const patch = {};
|
|
60
|
+
for (const locale of config.locales) {
|
|
61
|
+
if (!Object.hasOwn(source, locale))
|
|
62
|
+
continue;
|
|
63
|
+
patch[locale] = isBlankLocaleValue(source[locale]) ? null : source[locale];
|
|
64
|
+
}
|
|
65
|
+
return patch;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Removes `null` / `undefined` / blank-string entries. Returns `null` when nothing remains, so an
|
|
69
|
+
* all-cleared dictionary is stored as SQL NULL rather than `{}`. Keys are otherwise preserved as-is —
|
|
70
|
+
* including locales no longer registered, which must never be dropped by a write (brief §2, no data loss).
|
|
71
|
+
*/
|
|
72
|
+
export function compactLocalizedDictionary(value) {
|
|
73
|
+
const compact = {};
|
|
74
|
+
for (const [locale, localeValue] of Object.entries(value)) {
|
|
75
|
+
if (isBlankLocaleValue(localeValue))
|
|
76
|
+
continue;
|
|
77
|
+
compact[locale] = localeValue;
|
|
78
|
+
}
|
|
79
|
+
return Object.keys(compact).length > 0 ? compact : null;
|
|
80
|
+
}
|
|
81
|
+
/** Content locale used when neither stored `locales` nor a valid `defaultLanguage` is available. */
|
|
82
|
+
const FALLBACK_LOCALE = 'en';
|
|
83
|
+
/**
|
|
84
|
+
* Builds the project's {@link LocaleConfig}, repairing whatever a hand-edited settings row could break so
|
|
85
|
+
* the LocaleConfig invariant always holds:
|
|
86
|
+
* - `locales`: the stored list minus invalid codes and duplicates; when nothing remains (never configured,
|
|
87
|
+
* `[]`, corrupt), the implicit single-language config `[defaultLanguage]` (brief §2), which keeps the
|
|
88
|
+
* feature invisible to a mono-lingual project;
|
|
89
|
+
* - `defaultLocale`: the stored value when it belongs to `locales`, else `locales[0]`.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveLocaleConfig(settings) {
|
|
92
|
+
const stored = [...new Set((settings.locales ?? []).filter(isLocaleCode))];
|
|
93
|
+
const implicit = isLocaleCode(settings.defaultLanguage) ? settings.defaultLanguage : FALLBACK_LOCALE;
|
|
94
|
+
const locales = stored.length > 0 ? stored : [implicit];
|
|
95
|
+
const defaultLocale = settings.defaultLocale && locales.includes(settings.defaultLocale)
|
|
96
|
+
? settings.defaultLocale
|
|
97
|
+
: locales[0];
|
|
98
|
+
return { locales, defaultLocale };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* True when a STORED value of a localized branch is a locale dictionary. Text and richtext cannot hold an
|
|
102
|
+
* object keyed only by locale codes for any other reason; json can (`{"url": "…", "alt": "…"}`), so a json
|
|
103
|
+
* object counts only when at least one key is a registered locale — otherwise it is a legacy value written
|
|
104
|
+
* before the branch became localized.
|
|
105
|
+
*/
|
|
106
|
+
function isStoredLocaleDictionary(branch, value, config) {
|
|
107
|
+
if (!isLocalizedBranch(branch) || !isLocaleDictionary(value))
|
|
108
|
+
return false;
|
|
109
|
+
return branch.type !== 'json' || Object.keys(value).some((key) => config.locales.includes(key));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Merges a write into the value stored for a localized branch. The write path's only way to persist a
|
|
113
|
+
* localized value, so a translation the write does not mention is never dropped (brief §2):
|
|
114
|
+
* - `write === null` clears the whole field (an explicit null keeps its pre-localization meaning);
|
|
115
|
+
* - otherwise the write is normalised with {@link toLocalizedPatch}: every registered locale it names
|
|
116
|
+
* overwrites that locale, a `null` entry clears that locale only, and every other stored locale —
|
|
117
|
+
* including ones no longer registered — is kept as-is;
|
|
118
|
+
* - a stored legacy value (written before the branch became localized) is the default-locale value.
|
|
119
|
+
* @returns The compacted dictionary, or `null` when no locale keeps a value.
|
|
120
|
+
*/
|
|
121
|
+
export function applyLocalizedPatch(branch, stored, write, config) {
|
|
122
|
+
if (write === null)
|
|
123
|
+
return null;
|
|
124
|
+
const merged = isStoredLocaleDictionary(branch, stored, config)
|
|
125
|
+
? { ...stored }
|
|
126
|
+
: isBlankLocaleValue(stored) ? {} : { [config.defaultLocale]: stored };
|
|
127
|
+
Object.assign(merged, toLocalizedPatch(write, config));
|
|
128
|
+
return compactLocalizedDictionary(merged);
|
|
129
|
+
}
|
|
130
|
+
/** Aliases of the localized branches a write carries — the fields it must merge rather than replace. */
|
|
131
|
+
export function localizedAliasesIn(seed, data) {
|
|
132
|
+
return seed.branches
|
|
133
|
+
.filter((branch) => isLocalizedBranch(branch) && Object.hasOwn(data, branch.alias) && data[branch.alias] !== undefined)
|
|
134
|
+
.map((branch) => branch.alias);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Returns `data` with every localized branch it carries merged into `stored` via
|
|
138
|
+
* {@link applyLocalizedPatch}; other keys pass through untouched. `stored` is the entry's current values
|
|
139
|
+
* (`null` on create, which only compacts). Returns `data` itself when `config` is undefined or no localized
|
|
140
|
+
* branch is written.
|
|
141
|
+
*/
|
|
142
|
+
export function mergeLocalizedFields(seed, stored, data, config) {
|
|
143
|
+
if (!config)
|
|
144
|
+
return data;
|
|
145
|
+
const aliases = localizedAliasesIn(seed, data);
|
|
146
|
+
if (aliases.length === 0)
|
|
147
|
+
return data;
|
|
148
|
+
const merged = { ...data };
|
|
149
|
+
for (const branch of seed.branches) {
|
|
150
|
+
if (!aliases.includes(branch.alias))
|
|
151
|
+
continue;
|
|
152
|
+
merged[branch.alias] = applyLocalizedPatch(branch, stored?.[branch.alias], data[branch.alias], config);
|
|
153
|
+
}
|
|
154
|
+
return merged;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Resolves a stored localized value to one language: the requested locale, then the default locale, then the
|
|
158
|
+
* first stored translation (stored key order), then `null`. A legacy value that is not a dictionary is returned
|
|
159
|
+
* as-is. Non-localized branches return `value` unchanged. `buildSelectQuery` applies the same chain in SQL, so
|
|
160
|
+
* filters and ORDER BY compare exactly what a reader sees.
|
|
161
|
+
*/
|
|
162
|
+
export function resolveLocalizedValue(branch, value, locale, config) {
|
|
163
|
+
if (!isStoredLocaleDictionary(branch, value, config))
|
|
164
|
+
return value;
|
|
165
|
+
if (!isBlankLocaleValue(value[locale]))
|
|
166
|
+
return value[locale];
|
|
167
|
+
if (!isBlankLocaleValue(value[config.defaultLocale]))
|
|
168
|
+
return value[config.defaultLocale];
|
|
169
|
+
// An implicit config follows `defaultLanguage`; changing it before languages are configured leaves every
|
|
170
|
+
// dictionary without its default key. Returning null there would blank the public site (brief §2).
|
|
171
|
+
const firstStored = Object.values(value).find((translation) => !isBlankLocaleValue(translation));
|
|
172
|
+
return firstStored ?? null;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Returns a copy of `data` with every localized branch resolved to `locale` (default: the default locale)
|
|
176
|
+
* via {@link resolveLocalizedValue}. Returns `data` itself when `config` is undefined.
|
|
177
|
+
*/
|
|
178
|
+
export function resolveLocalizedFields(seed, data, config, locale) {
|
|
179
|
+
if (!config)
|
|
180
|
+
return data;
|
|
181
|
+
const target = locale ?? config.defaultLocale;
|
|
182
|
+
const resolved = { ...data };
|
|
183
|
+
for (const branch of seed.branches) {
|
|
184
|
+
if (isLocalizedBranch(branch) && Object.hasOwn(resolved, branch.alias)) {
|
|
185
|
+
resolved[branch.alias] = resolveLocalizedValue(branch, resolved[branch.alias], target, config);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return resolved;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The whole dictionary of a stored localized value, for readers asking every language (`?lang=all`):
|
|
192
|
+
* a stored dictionary as-is — unregistered locales included, nothing is hidden (brief §2) — a legacy
|
|
193
|
+
* value as `{ [defaultLocale]: value }` (the write path's reading of it), a blank value as `null`.
|
|
194
|
+
* Non-localized branches return `value` unchanged.
|
|
195
|
+
*/
|
|
196
|
+
export function asLocaleDictionary(branch, value, config) {
|
|
197
|
+
if (!isLocalizedBranch(branch))
|
|
198
|
+
return value;
|
|
199
|
+
if (isStoredLocaleDictionary(branch, value, config))
|
|
200
|
+
return value;
|
|
201
|
+
return isBlankLocaleValue(value) ? null : { [config.defaultLocale]: value };
|
|
202
|
+
}
|
|
203
|
+
/** Returns a copy of `data` with every localized branch it carries passed through {@link asLocaleDictionary}. */
|
|
204
|
+
export function asLocaleDictionaries(seed, data, config) {
|
|
205
|
+
const expanded = { ...data };
|
|
206
|
+
for (const branch of seed.branches) {
|
|
207
|
+
if (isLocalizedBranch(branch) && Object.hasOwn(expanded, branch.alias)) {
|
|
208
|
+
expanded[branch.alias] = asLocaleDictionary(branch, expanded[branch.alias], config);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return expanded;
|
|
212
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"privacy.service.d.ts","sourceRoot":"","sources":["../../src/engine/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;
|
|
1
|
+
{"version":3,"file":"privacy.service.d.ts","sourceRoot":"","sources":["../../src/engine/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;IACH,YAAY,SAAS,EAAE,MAAM,EAK5B;IAED;;;OAGG;YACW,SAAS;IAmBvB;;OAEG;YACW,UAAU;IAexB;;;OAGG;IACG,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAYhD;IAED;;;OAGG;IACG,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAkBjD;IAED;;OAEG;IACG,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAY7C;CACF"}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Seed, TrashedMode } from '../types.js';
|
|
2
2
|
/**
|
|
3
|
-
* Returns the SQL condition (e.g. `ce.deleted_at IS NULL`), or `null` if
|
|
4
|
-
* seed does not use soft delete or mode is 'any'.
|
|
3
|
+
* Returns the SQL condition (e.g. `ce.deleted_at IS NULL`), or `null` if mode is 'any'.
|
|
5
4
|
*
|
|
6
5
|
* @param seed - The seed schema definition.
|
|
7
6
|
* @param mode - 'active' (default), 'trashed', or 'any'.
|
|
@@ -9,7 +8,7 @@ import type { Seed, TrashedMode } from '../types.js';
|
|
|
9
8
|
*/
|
|
10
9
|
export declare function activeCondition(seed: Seed, mode?: TrashedMode, tableAlias?: string): string | null;
|
|
11
10
|
/**
|
|
12
|
-
* Returns ` AND <activeCondition
|
|
11
|
+
* Returns ` AND <activeCondition>`, or `''` when mode is 'any'.
|
|
13
12
|
*
|
|
14
13
|
* @param seed - The seed schema definition.
|
|
15
14
|
* @param mode - 'active' (default), 'trashed', or 'any'.
|
|
@@ -1 +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
|
|
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;;;;;;GAMG;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"}
|
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
3
|
/**
|
|
4
|
-
* Returns the SQL condition (e.g. `ce.deleted_at IS NULL`), or `null` if
|
|
5
|
-
* seed does not use soft delete or mode is 'any'.
|
|
4
|
+
* Returns the SQL condition (e.g. `ce.deleted_at IS NULL`), or `null` if mode is 'any'.
|
|
6
5
|
*
|
|
7
6
|
* @param seed - The seed schema definition.
|
|
8
7
|
* @param mode - 'active' (default), 'trashed', or 'any'.
|
|
9
8
|
* @param tableAlias - Optional table or alias qualifier prefix.
|
|
10
9
|
*/
|
|
11
10
|
export function activeCondition(seed, mode = 'active', tableAlias) {
|
|
12
|
-
if (
|
|
11
|
+
if (mode === 'any')
|
|
13
12
|
return null;
|
|
14
13
|
const col = tableAlias ? `${tableAlias}.deleted_at` : 'deleted_at';
|
|
15
14
|
return mode === 'trashed' ? `${col} IS NOT NULL` : `${col} IS NULL`;
|
|
16
15
|
}
|
|
17
16
|
/**
|
|
18
|
-
* Returns ` AND <activeCondition
|
|
17
|
+
* Returns ` AND <activeCondition>`, or `''` when mode is 'any'.
|
|
19
18
|
*
|
|
20
19
|
* @param seed - The seed schema definition.
|
|
21
20
|
* @param mode - 'active' (default), 'trashed', or 'any'.
|
|
@@ -112,7 +112,7 @@ export function buildSelectQuery(seed, options = {}) {
|
|
|
112
112
|
joinClause = `LEFT JOIN kanban_positions kp` +
|
|
113
113
|
` ON kp.seed_slug = ? AND kp.entry_id = ${table}.id AND kp.axis_branch_id = ?`;
|
|
114
114
|
bindings.push(options.kanbanOrder.seedSlug, options.kanbanOrder.axisBranchId);
|
|
115
|
-
kanbanOrderClause = ` ORDER BY (kp.position IS NULL) ASC, kp.position ASC`;
|
|
115
|
+
kanbanOrderClause = ` ORDER BY (kp.position IS NULL) ASC, kp.position ASC, ${table}.id ASC`;
|
|
116
116
|
}
|
|
117
117
|
const rtBranches = indexableSearchBranches(seed);
|
|
118
118
|
if (search && rtBranches.length > 0) {
|
|
@@ -128,12 +128,10 @@ export function buildSelectQuery(seed, options = {}) {
|
|
|
128
128
|
// Soft delete: 'active' is the default, so a caller that forgets the option can never
|
|
129
129
|
// observe a trashed row — including the Public API, relation expansion and subqueries,
|
|
130
130
|
// which all reach SQL only through repository.findMany.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
whereClauses.push(cond);
|
|
136
|
-
}
|
|
131
|
+
const trashed = options.trashed ?? 'active';
|
|
132
|
+
const cond = activeCondition(seed, trashed, table);
|
|
133
|
+
if (cond) {
|
|
134
|
+
whereClauses.push(cond);
|
|
137
135
|
}
|
|
138
136
|
const groupClauses = [];
|
|
139
137
|
for (const group of filters) {
|
|
@@ -202,9 +200,11 @@ export function buildSelectQuery(seed, options = {}) {
|
|
|
202
200
|
sql += orderedBranch && isSingleChoiceTags(orderedBranch)
|
|
203
201
|
? tagsOrderClause(col, orderedBranch, dir, bindings)
|
|
204
202
|
: ` ORDER BY ${col} ${dir}`;
|
|
203
|
+
// Unique final key: tied sort values would otherwise page in an undefined order.
|
|
204
|
+
sql += `, ${table}.id ${dir}`;
|
|
205
205
|
}
|
|
206
206
|
else {
|
|
207
|
-
sql += ` ORDER BY ${table}.created_at DESC`;
|
|
207
|
+
sql += ` ORDER BY ${table}.created_at DESC, ${table}.id DESC`;
|
|
208
208
|
}
|
|
209
209
|
if (pagination) {
|
|
210
210
|
sql += ` LIMIT ? OFFSET ?`;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Branch, Seed } from '../types.js';
|
|
2
|
+
/** Resolves a seed slug to its definition; `undefined` when unknown. */
|
|
3
|
+
export type SeedLookup = (slug: string) => Seed | null | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* How a list search is answered for a seed:
|
|
6
|
+
* - `fts`: the seed has indexable text/richtext branches, matched through its FTS table.
|
|
7
|
+
* - `relation`: the seed shows a single relation as its display name, matched against the
|
|
8
|
+
* target's display name (plain text only).
|
|
9
|
+
* - `none`: nothing searchable; the search term is ignored.
|
|
10
|
+
*/
|
|
11
|
+
export type SearchSupport = {
|
|
12
|
+
mode: 'fts';
|
|
13
|
+
} | {
|
|
14
|
+
mode: 'relation';
|
|
15
|
+
branch: Branch;
|
|
16
|
+
target: Seed;
|
|
17
|
+
targetBranch: Branch;
|
|
18
|
+
} | {
|
|
19
|
+
mode: 'none';
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Decides how `seed` can be searched. FTS wins when available; otherwise a single relation
|
|
23
|
+
* display name is searched through the target's display name, provided that column is plain,
|
|
24
|
+
* unlocalized, unencrypted text.
|
|
25
|
+
*
|
|
26
|
+
* @param seed The seed being listed.
|
|
27
|
+
* @param lookup Resolves relation targets; without it the relation mode is never chosen.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveSearchSupport(seed: Seed, lookup?: SeedLookup): SearchSupport;
|
|
30
|
+
/** True when a list search on `seed` can return filtered results. */
|
|
31
|
+
export declare function isSearchSupported(seed: Seed, lookup?: SeedLookup): boolean;
|
|
32
|
+
//# sourceMappingURL=search-support.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search-support.d.ts","sourceRoot":"","sources":["../../../src/engine/query/search-support.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAK/C,wEAAwE;AACxE,MAAM,MAAM,UAAU,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,GAAG,SAAS,CAAA;AAElE;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,GACf;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,IAAI,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACxE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAEpB;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,UAAU,GAAG,aAAa,CAoBnF;AAED,qEAAqE;AACrE,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,UAAU,GAAG,OAAO,CAE1E"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
import { indexableSearchBranches } from '../ddl/ddl.js';
|
|
4
|
+
import { resolveClassification } from '../privacy/policies.js';
|
|
5
|
+
import { isLocalizedBranch } from '../localization/localization.js';
|
|
6
|
+
/**
|
|
7
|
+
* Decides how `seed` can be searched. FTS wins when available; otherwise a single relation
|
|
8
|
+
* display name is searched through the target's display name, provided that column is plain,
|
|
9
|
+
* unlocalized, unencrypted text.
|
|
10
|
+
*
|
|
11
|
+
* @param seed The seed being listed.
|
|
12
|
+
* @param lookup Resolves relation targets; without it the relation mode is never chosen.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveSearchSupport(seed, lookup) {
|
|
15
|
+
if (indexableSearchBranches(seed).length > 0)
|
|
16
|
+
return { mode: 'fts' };
|
|
17
|
+
const branch = seed.branches.find((b) => b.alias === seed.displayNameAlias);
|
|
18
|
+
if (!lookup || branch?.type !== 'relation' || branch.multiple === true || !branch.targetSeed) {
|
|
19
|
+
return { mode: 'none' };
|
|
20
|
+
}
|
|
21
|
+
const target = lookup(branch.targetSeed);
|
|
22
|
+
const targetBranch = target?.branches.find((b) => b.alias === target.displayNameAlias);
|
|
23
|
+
if (!target
|
|
24
|
+
|| targetBranch?.type !== 'text'
|
|
25
|
+
|| isLocalizedBranch(targetBranch)
|
|
26
|
+
|| resolveClassification(targetBranch).storage === 'encrypt') {
|
|
27
|
+
return { mode: 'none' };
|
|
28
|
+
}
|
|
29
|
+
return { mode: 'relation', branch, target, targetBranch };
|
|
30
|
+
}
|
|
31
|
+
/** True when a list search on `seed` can return filtered results. */
|
|
32
|
+
export function isSearchSupported(seed, lookup) {
|
|
33
|
+
return resolveSearchSupport(seed, lookup).mode !== 'none';
|
|
34
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../../src/engine/query.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,IAAI,EAMJ,aAAa,EACb,kBAAkB,EACnB,MAAM,YAAY,CAAC;AAKpB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,aAAkB,GAAG,kBAAkB,
|
|
1
|
+
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../../src/engine/query.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,IAAI,EAMJ,aAAa,EACb,kBAAkB,EACnB,MAAM,YAAY,CAAC;AAKpB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,aAAkB,GAAG,kBAAkB,CAoG5F"}
|