vintasend-managed-templates 1.0.0-alpha2

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 (38) hide show
  1. package/README.md +529 -0
  2. package/dist/base-template-manager-backend.d.ts +202 -0
  3. package/dist/base-template-manager-backend.d.ts.map +1 -0
  4. package/dist/base-template-manager-backend.js +1 -0
  5. package/dist/composition.d.ts +238 -0
  6. package/dist/composition.d.ts.map +1 -0
  7. package/dist/composition.js +0 -0
  8. package/dist/constants.d.ts +32 -0
  9. package/dist/constants.d.ts.map +1 -0
  10. package/dist/constants.js +31 -0
  11. package/dist/errors.d.ts +80 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +86 -0
  14. package/dist/filter-evaluation.d.ts +69 -0
  15. package/dist/filter-evaluation.d.ts.map +1 -0
  16. package/dist/filter-evaluation.js +252 -0
  17. package/dist/filters.d.ts +192 -0
  18. package/dist/filters.d.ts.map +1 -0
  19. package/dist/filters.js +252 -0
  20. package/dist/in-memory-template-manager-backend.d.ts +85 -0
  21. package/dist/in-memory-template-manager-backend.d.ts.map +1 -0
  22. package/dist/in-memory-template-manager-backend.js +323 -0
  23. package/dist/index.d.ts +19 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +18 -0
  26. package/dist/managed-template-renderer.d.ts +153 -0
  27. package/dist/managed-template-renderer.d.ts.map +1 -0
  28. package/dist/managed-template-renderer.js +152 -0
  29. package/dist/managed-template-service.d.ts +415 -0
  30. package/dist/managed-template-service.d.ts.map +1 -0
  31. package/dist/managed-template-service.js +712 -0
  32. package/dist/tags.d.ts +54 -0
  33. package/dist/tags.d.ts.map +1 -0
  34. package/dist/tags.js +108 -0
  35. package/dist/types.d.ts +100 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +1 -0
  38. package/package.json +41 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The filter vocabulary a template-manager backend evaluates.
3
+ *
4
+ * Spelled exactly the way `vintasend` spells its notification filters — camelCase fields,
5
+ * camelCase string lookups (`startsWith`, not `starts_with`), a `DateRange` with `from`/`to`,
6
+ * and the same `and`/`or`/`not` grouping — so a caller that already builds one kind of filter
7
+ * needs no second set of rules for this one.
8
+ */
9
+ /**
10
+ * The fields `ManagedTemplateFilterFields` accepts, as data.
11
+ *
12
+ * Kept in step with the type by {@link ManagedTemplateFilterFieldName}, which fails to compile
13
+ * if the two drift apart — a field added to the type without a name here is a type error rather
14
+ * than a filter the service silently rejects.
15
+ */
16
+ export const KNOWN_FILTER_FIELDS = [
17
+ 'name',
18
+ 'description',
19
+ 'key',
20
+ 'version',
21
+ 'templateManagedBackend',
22
+ 'status',
23
+ 'createdAtRange',
24
+ 'updatedAtRange',
25
+ 'includesAllTags',
26
+ 'includesAnyOfTags',
27
+ 'isAbstract',
28
+ 'mostRecentActiveVersion',
29
+ ];
30
+ const _allFilterFieldsListed = true;
31
+ void _allFilterFieldsListed;
32
+ /** The filter fields whose value is a list of tag slugs rather than a lookup object. */
33
+ export const TAG_FILTER_FIELDS = ['includesAllTags', 'includesAnyOfTags'];
34
+ /** The filter fields whose value is a bare boolean. */
35
+ export const FLAG_FILTER_FIELDS = ['mostRecentActiveVersion', 'isAbstract'];
36
+ const LOGICAL_KEYS = ['and', 'or', 'not'];
37
+ const STRING_LOOKUPS = ['exact', 'startsWith', 'endsWith', 'includes'];
38
+ const NUMERIC_LOOKUPS = ['gt', 'gte', 'lt', 'lte'];
39
+ function isRecord(value) {
40
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
41
+ }
42
+ /** True when `filter` is a field filter rather than an `and`/`or`/`not` group. */
43
+ export function isFieldFilter(filter) {
44
+ return !LOGICAL_KEYS.some((key) => key in filter);
45
+ }
46
+ /** True when a string-field filter is a `StringFilterLookup` and not a bare string. */
47
+ export function isStringFilterLookup(value) {
48
+ return (isRecord(value) &&
49
+ typeof value.value === 'string' &&
50
+ typeof value.lookup === 'string' &&
51
+ STRING_LOOKUPS.includes(value.lookup) &&
52
+ (value.caseSensitive === undefined || typeof value.caseSensitive === 'boolean'));
53
+ }
54
+ export function isNumericFilterLookup(value) {
55
+ return (isRecord(value) &&
56
+ typeof value.value === 'number' &&
57
+ typeof value.lookup === 'string' &&
58
+ NUMERIC_LOOKUPS.includes(value.lookup));
59
+ }
60
+ export function isDateRange(value) {
61
+ return isRecord(value) && (value.from instanceof Date || value.to instanceof Date);
62
+ }
63
+ export function isStatusInLookup(value) {
64
+ return (isRecord(value) &&
65
+ value.lookup === 'in' &&
66
+ Array.isArray(value.value) &&
67
+ value.value.every((entry) => typeof entry === 'string'));
68
+ }
69
+ export function isStatusExactLookup(value) {
70
+ return isRecord(value) && value.lookup === 'exact' && typeof value.value === 'string';
71
+ }
72
+ /**
73
+ * True when a value is a list of tag slugs.
74
+ *
75
+ * A bare string is deliberately rejected: `{ includesAllTags: 'welcome' }` would otherwise be
76
+ * spread character by character by a backend that iterates it, and silently ask for the tags
77
+ * `w`, `e`, `l`. Pass a one-element array instead.
78
+ */
79
+ export function isTagsFilter(value) {
80
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
81
+ }
82
+ /** Every orderable field, for validation and for building a capability report. */
83
+ export const MANAGED_TEMPLATE_ORDER_BY_FIELDS = [
84
+ 'key',
85
+ 'name',
86
+ 'version',
87
+ 'status',
88
+ 'createdAt',
89
+ 'updatedAt',
90
+ ];
91
+ /** The capability key reporting whether a backend can order by `field`. */
92
+ export function orderByCapabilityKey(field) {
93
+ return `orderBy.${field}`;
94
+ }
95
+ export const DEFAULT_TEMPLATE_BACKEND_FILTER_CAPABILITIES = {
96
+ // Composition. A backend that can evaluate field filters but not assemble them into
97
+ // and/or/not groups declines these. `notNested` is the narrower question of whether `not` may
98
+ // wrap a *group* rather than a single field filter.
99
+ 'logical.and': true,
100
+ 'logical.or': true,
101
+ 'logical.not': true,
102
+ 'logical.notNested': true,
103
+ // One key per field of `ManagedTemplateFilterFields`.
104
+ 'fields.name': true,
105
+ 'fields.description': true,
106
+ 'fields.key': true,
107
+ 'fields.version': true,
108
+ 'fields.templateManagedBackend': true,
109
+ 'fields.status': true,
110
+ 'fields.createdAtRange': true,
111
+ 'fields.updatedAtRange': true,
112
+ // Tag membership. A backend that stores no tags — or stores them but cannot query across
113
+ // them — declines these, and a caller drops the filter rather than failing the request. They
114
+ // are separate keys because "every tag" and "at least one tag" are different queries: the
115
+ // first needs a per-template count over the tags asked for, the second only membership.
116
+ 'fields.includesAllTags': true,
117
+ 'fields.includesAnyOfTags': true,
118
+ // Its own key because it is a different question from the rest: a backend answers it by
119
+ // comparing a row against the other versions of its key, which a store that keeps no version
120
+ // history cannot do.
121
+ 'fields.mostRecentActiveVersion': true,
122
+ // Bases versus templates to send, answered from the stored `isAbstract` flag the backend
123
+ // derives on every write. A backend that keeps no such flag declines this.
124
+ 'fields.isAbstract': true,
125
+ 'stringLookups.exact': true,
126
+ 'stringLookups.startsWith': true,
127
+ 'stringLookups.endsWith': true,
128
+ 'stringLookups.includes': true,
129
+ // These two are independent capabilities, not a flag and its negation:
130
+ //
131
+ // * `caseSensitive: false` — everything is forced case-insensitive, which is what a store on
132
+ // a case-insensitive collation (MySQL's `*_ci`) does. It cannot honour
133
+ // `caseSensitive: true`, nor a bare string filter, which means the same thing.
134
+ // * `caseInsensitive: false` — only exact-case matching is available, e.g. a store with
135
+ // `LIKE` but no `ILIKE`.
136
+ //
137
+ // Deriving either from the other inverts the answer for exactly the backends that had a
138
+ // constraint worth reporting. Read the key you actually mean.
139
+ 'stringLookups.caseSensitive': true,
140
+ 'stringLookups.caseInsensitive': true,
141
+ // Ordering is new vocabulary rather than behaviour backends already have, so every key
142
+ // defaults to false. A backend that predates the ordering argument ignores it, and a `true`
143
+ // default would have it claim an order it never applies.
144
+ 'orderBy.key': false,
145
+ 'orderBy.name': false,
146
+ 'orderBy.version': false,
147
+ 'orderBy.status': false,
148
+ 'orderBy.createdAt': false,
149
+ 'orderBy.updatedAt': false,
150
+ };
151
+ /**
152
+ * Drop the parts of a filter the backend has declared it cannot answer.
153
+ *
154
+ * This is the other half of the capability report: reporting a limitation is only useful if
155
+ * something acts on it. Every caller that builds a filter would otherwise have to walk the
156
+ * capability map itself and reach its own conclusions, and they would disagree.
157
+ *
158
+ * **Dropping widens.** A pruned filter matches everything the original did and possibly more, so
159
+ * a caller sees extra rows rather than missing ones — a listing that could not collapse to one
160
+ * row per key shows every version, which is visible in the result. That is the whole reason
161
+ * filters are negotiated by dropping while *ordering* is negotiated by refusing: an ignored order
162
+ * leaves no trace in the rows at all. Check the report before trusting a filter to have narrowed.
163
+ *
164
+ * Returns an empty filter — which constrains nothing — when everything has been dropped.
165
+ */
166
+ export function pruneUnsupportedFilters(filter, capabilities) {
167
+ const can = (key) => supportsCapability(capabilities, key);
168
+ if (isFieldFilter(filter)) {
169
+ return pruneFields(filter, can);
170
+ }
171
+ if ('and' in filter) {
172
+ if (!can('logical.and')) {
173
+ return {};
174
+ }
175
+ const kept = filter.and
176
+ .map((inner) => pruneUnsupportedFilters(inner, capabilities))
177
+ .filter((inner) => !isEmptyFilter(inner));
178
+ if (kept.length === 0)
179
+ return {};
180
+ // A one-element `and` is the element: fewer groups for a backend to translate.
181
+ return kept.length === 1 ? kept[0] : { and: kept };
182
+ }
183
+ if ('or' in filter) {
184
+ // An `or` cannot be partially dropped. Removing one branch of a disjunction *narrows* the
185
+ // result — the opposite of what dropping is allowed to do — so the whole group goes, and
186
+ // with it the constraint.
187
+ if (!can('logical.or')) {
188
+ return {};
189
+ }
190
+ const kept = filter.or.map((inner) => pruneUnsupportedFilters(inner, capabilities));
191
+ // If any branch pruned down to "everything", the disjunction is satisfied by every row.
192
+ if (kept.some(isEmptyFilter))
193
+ return {};
194
+ return { or: kept };
195
+ }
196
+ if (!can('logical.not')) {
197
+ return {};
198
+ }
199
+ const inner = pruneUnsupportedFilters(filter.not, capabilities);
200
+ // Negating "everything" is "nothing", which is not a widening — drop it instead.
201
+ if (isEmptyFilter(inner))
202
+ return {};
203
+ if (!isFieldFilter(inner) && !can('logical.notNested'))
204
+ return {};
205
+ return { not: inner };
206
+ }
207
+ /** True for a filter that constrains nothing, whatever shape it arrived in. */
208
+ export function isEmptyFilter(filter) {
209
+ if (isFieldFilter(filter)) {
210
+ return Object.values(filter).every((value) => value === undefined);
211
+ }
212
+ if ('and' in filter)
213
+ return filter.and.every(isEmptyFilter);
214
+ if ('or' in filter)
215
+ return filter.or.every(isEmptyFilter);
216
+ return isEmptyFilter(filter.not);
217
+ }
218
+ function pruneFields(fields, can) {
219
+ const kept = {};
220
+ for (const field of KNOWN_FILTER_FIELDS) {
221
+ const value = fields[field];
222
+ if (value === undefined)
223
+ continue;
224
+ if (!can(`fields.${field}`))
225
+ continue;
226
+ if (isStringFilterLookup(value) && !supportedStringLookup(value, can))
227
+ continue;
228
+ // A bare string means exact and case-sensitive.
229
+ if (typeof value === 'string' &&
230
+ !(can('stringLookups.exact') && can('stringLookups.caseSensitive'))) {
231
+ continue;
232
+ }
233
+ kept[field] = value;
234
+ }
235
+ return kept;
236
+ }
237
+ function supportedStringLookup(filter, can) {
238
+ if (!can(`stringLookups.${filter.lookup}`))
239
+ return false;
240
+ return filter.caseSensitive === false
241
+ ? can('stringLookups.caseInsensitive')
242
+ : can('stringLookups.caseSensitive');
243
+ }
244
+ /**
245
+ * Read one capability, defaulting to supported.
246
+ *
247
+ * A missing key means "supported": backends declare only what they *cannot* do, so a capability
248
+ * added in a later release does not force every backend to re-declare it.
249
+ */
250
+ export function supportsCapability(capabilities, key) {
251
+ return capabilities[key] ?? true;
252
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A complete `BaseTemplateManagerBackend` that keeps everything in process memory.
3
+ *
4
+ * Two jobs. It is what a test suite — this package's, a renderer's, an API's — runs against
5
+ * without standing up a database. And it is the executable statement of what the seam means:
6
+ * every rule the interface documents in prose (a new version starts in `draft`, retagging edits
7
+ * in place, `mostRecentActiveVersion` is answered against the key rather than the row) is code
8
+ * here, so a backend author has something to compare behaviour against rather than only prose.
9
+ *
10
+ * Not for production: nothing is persisted, nothing is locked, and every filter is evaluated by
11
+ * scanning the whole store.
12
+ */
13
+ import type { BaseTemplateManagerBackend } from './base-template-manager-backend.js';
14
+ import type { ManagedTemplateStatus, ManagedTemplateTagStatus } from './constants.js';
15
+ import { type ManagedTemplateFilter, type ManagedTemplateFilterCapabilities, type ManagedTemplateOrderBy } from './filters.js';
16
+ import type { ManagedTemplate, ManagedTemplateCreateInput, ManagedTemplateStatusHistory, ManagedTemplateTag, ManagedTemplateUpdateInput } from './types.js';
17
+ export type InMemoryTemplateManagerBackendOptions = {
18
+ /** Overridden in tests so timestamps are deterministic. */
19
+ now?: () => Date;
20
+ };
21
+ export declare class InMemoryTemplateManagerBackend implements BaseTemplateManagerBackend {
22
+ private templates;
23
+ private tags;
24
+ private history;
25
+ private nextId;
26
+ private readonly now;
27
+ constructor(options?: InMemoryTemplateManagerBackendOptions);
28
+ createTemplate(data: ManagedTemplateCreateInput): Promise<ManagedTemplate>;
29
+ getTemplate(templateKey: string, version?: number | null): Promise<ManagedTemplate>;
30
+ updateTemplate(templateKey: string, data: ManagedTemplateUpdateInput): Promise<ManagedTemplate>;
31
+ deleteTemplate(templateKey: string, version?: number | null): Promise<void>;
32
+ createTemplateStatusUpdate(params: {
33
+ templateKey: string;
34
+ version: number;
35
+ status: ManagedTemplateStatus;
36
+ changedBy?: string | null;
37
+ }): Promise<void>;
38
+ getTemplateStatusHistory(templateKey: string, version?: number | null): Promise<ManagedTemplateStatusHistory[]>;
39
+ getOrCreateTags(texts: string[], tenant?: string | null): Promise<ManagedTemplateTag[]>;
40
+ createTag(text: string, tenant?: string | null): Promise<ManagedTemplateTag>;
41
+ getTag(slug: string): Promise<ManagedTemplateTag>;
42
+ updateTag(slug: string, text: string): Promise<ManagedTemplateTag>;
43
+ setTagStatus(slug: string, status: ManagedTemplateTagStatus): Promise<ManagedTemplateTag>;
44
+ deleteTag(slug: string): Promise<void>;
45
+ getTags(status?: ManagedTemplateTagStatus[] | null, search?: string | null, tenant?: string | null): Promise<ManagedTemplateTag[]>;
46
+ getTemplateTags(templateKey: string, version?: number | null): Promise<ManagedTemplateTag[]>;
47
+ setTemplateTags(templateKey: string, tags: string[], version?: number | null): Promise<ManagedTemplate>;
48
+ getAllTemplates(): Promise<ManagedTemplate[]>;
49
+ getTemplatesByStatus(status: ManagedTemplateStatus[]): Promise<ManagedTemplate[]>;
50
+ getFilteredTemplates(filters: ManagedTemplateFilter): Promise<ManagedTemplate[]>;
51
+ /**
52
+ * Everything, including every order.
53
+ *
54
+ * This backend holds the whole store in memory, so it can sort a complete result set before
55
+ * paging it — which is what ordering requires. It therefore declares the `orderBy.*` keys
56
+ * explicitly: they default to false, and a backend that can genuinely do it has to say so.
57
+ */
58
+ getFilterCapabilities(): ManagedTemplateFilterCapabilities;
59
+ getPaginatedTemplates(page: number, pageSize: number, orderBy?: ManagedTemplateOrderBy): Promise<ManagedTemplate[]>;
60
+ getPaginatedFilteredTemplates(filters: ManagedTemplateFilter, page: number, pageSize: number, orderBy?: ManagedTemplateOrderBy): Promise<ManagedTemplate[]>;
61
+ private deriveIsAbstract;
62
+ private versionsOf;
63
+ private find;
64
+ private requireTag;
65
+ private cleanText;
66
+ private insertTag;
67
+ /**
68
+ * Resolve texts to tags, creating what is missing — one tag per distinct text, in order.
69
+ *
70
+ * An existing tag is returned as it stands: its text and status are left alone, so re-using an
71
+ * archived tag does not quietly bring it back.
72
+ */
73
+ private resolveTags;
74
+ /** Keep the copies embedded on template rows in step with the tag record itself. */
75
+ private syncTagOnTemplates;
76
+ /**
77
+ * Evaluate a filter over the whole store.
78
+ *
79
+ * The semantics live in `filter-evaluation`, shared with every backend that has to finish a
80
+ * filter its query language could not express — so this store and a FHIR one agree on what
81
+ * `includesAllTags: []` means without either of them re-deriving it.
82
+ */
83
+ private matches;
84
+ }
85
+ //# sourceMappingURL=in-memory-template-manager-backend.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"in-memory-template-manager-backend.d.ts","sourceRoot":"","sources":["../src/in-memory-template-manager-backend.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,323 @@
1
+ /**
2
+ * A complete `BaseTemplateManagerBackend` that keeps everything in process memory.
3
+ *
4
+ * Two jobs. It is what a test suite — this package's, a renderer's, an API's — runs against
5
+ * without standing up a database. And it is the executable statement of what the seam means:
6
+ * every rule the interface documents in prose (a new version starts in `draft`, retagging edits
7
+ * in place, `mostRecentActiveVersion` is answered against the key rather than the row) is code
8
+ * here, so a backend author has something to compare behaviour against rather than only prose.
9
+ *
10
+ * Not for production: nothing is persisted, nothing is locked, and every filter is evaluated by
11
+ * scanning the whole store.
12
+ */
13
+ import { isAbstract } from './composition.js';
14
+ import { ManagedTemplateInvalidTagError, ManagedTemplateNotFoundError, ManagedTemplateTagAlreadyExistsError, ManagedTemplateTagNotFoundError, } from './errors.js';
15
+ import { matchesTemplateFilter, paginate, sortTemplates } from './filter-evaluation.js';
16
+ import { MANAGED_TEMPLATE_ORDER_BY_FIELDS, orderByCapabilityKey, } from './filters.js';
17
+ import { nextAvailableSlug, normalizeTagText, slugifyTag } from './tags.js';
18
+ export class InMemoryTemplateManagerBackend {
19
+ constructor(options = {}) {
20
+ this.templates = [];
21
+ this.tags = [];
22
+ this.history = [];
23
+ this.nextId = 1;
24
+ this.now = options.now ?? (() => new Date());
25
+ }
26
+ // -------------------------------------------------------------------------------------------
27
+ // Templates
28
+ // -------------------------------------------------------------------------------------------
29
+ async createTemplate(data) {
30
+ const timestamp = this.now();
31
+ const template = {
32
+ id: this.nextId++,
33
+ key: data.key,
34
+ version: 1,
35
+ name: data.name,
36
+ description: data.description,
37
+ templateManagedBackend: data.templateManagedBackend,
38
+ bodyTemplate: data.bodyTemplate,
39
+ subjectTemplate: data.subjectTemplate,
40
+ preheaderTemplate: data.preheaderTemplate,
41
+ status: 'draft',
42
+ tenant: data.tenant,
43
+ createdAt: timestamp,
44
+ updatedAt: timestamp,
45
+ tags: await this.resolveTags(data.tags ?? [], data.tenant),
46
+ isAbstract: this.deriveIsAbstract(data),
47
+ };
48
+ this.templates.push(template);
49
+ return structuredCloneTemplate(template);
50
+ }
51
+ async getTemplate(templateKey, version = null) {
52
+ const template = this.find(templateKey, version);
53
+ if (template === undefined) {
54
+ throw new ManagedTemplateNotFoundError(describeMissing(templateKey, version));
55
+ }
56
+ return structuredCloneTemplate(template);
57
+ }
58
+ async updateTemplate(templateKey, data) {
59
+ const previous = this.find(templateKey, null);
60
+ if (previous === undefined) {
61
+ throw new ManagedTemplateNotFoundError(describeMissing(templateKey, null));
62
+ }
63
+ // Resolved before the insert so an unusable tag text fails the whole update rather than
64
+ // leaving a new version behind with the wrong labels.
65
+ const tags = data.tags === undefined || data.tags === null
66
+ ? previous.tags
67
+ : await this.resolveTags(data.tags, previous.tenant);
68
+ const timestamp = this.now();
69
+ const sources = {
70
+ bodyTemplate: data.bodyTemplate || previous.bodyTemplate,
71
+ subjectTemplate: data.subjectTemplate ?? previous.subjectTemplate,
72
+ preheaderTemplate: data.preheaderTemplate ?? previous.preheaderTemplate,
73
+ };
74
+ const template = {
75
+ id: this.nextId++,
76
+ key: previous.key,
77
+ version: previous.version + 1,
78
+ name: data.name || previous.name,
79
+ description: data.description ?? previous.description,
80
+ templateManagedBackend: previous.templateManagedBackend,
81
+ ...sources,
82
+ // A copy nobody has reviewed should not inherit "published".
83
+ status: 'draft',
84
+ tenant: previous.tenant,
85
+ createdAt: timestamp,
86
+ updatedAt: timestamp,
87
+ tags,
88
+ isAbstract: this.deriveIsAbstract(sources),
89
+ };
90
+ this.templates.push(template);
91
+ return structuredCloneTemplate(template);
92
+ }
93
+ async deleteTemplate(templateKey, version = null) {
94
+ const template = this.find(templateKey, version);
95
+ if (template === undefined) {
96
+ throw new ManagedTemplateNotFoundError(describeMissing(templateKey, version));
97
+ }
98
+ this.templates = this.templates.filter((candidate) => candidate !== template);
99
+ }
100
+ async createTemplateStatusUpdate(params) {
101
+ const template = this.find(params.templateKey, params.version);
102
+ if (template === undefined) {
103
+ throw new ManagedTemplateNotFoundError(describeMissing(params.templateKey, params.version));
104
+ }
105
+ template.status = params.status;
106
+ template.updatedAt = this.now();
107
+ this.history.push({
108
+ templateKey: params.templateKey,
109
+ version: params.version,
110
+ status: params.status,
111
+ createdAt: this.now(),
112
+ changedBy: params.changedBy ?? null,
113
+ tenant: template.tenant,
114
+ });
115
+ }
116
+ async getTemplateStatusHistory(templateKey, version = null) {
117
+ if (this.versionsOf(templateKey).length === 0) {
118
+ throw new ManagedTemplateNotFoundError(describeMissing(templateKey, null));
119
+ }
120
+ return this.history
121
+ .filter((record) => record.templateKey === templateKey && (version === null || record.version === version))
122
+ .map((record) => ({ ...record }));
123
+ }
124
+ // -------------------------------------------------------------------------------------------
125
+ // Tags
126
+ // -------------------------------------------------------------------------------------------
127
+ async getOrCreateTags(texts, tenant = null) {
128
+ return this.resolveTags(texts, tenant);
129
+ }
130
+ async createTag(text, tenant = null) {
131
+ const cleaned = this.cleanText(text);
132
+ const slug = slugifyTag(cleaned);
133
+ if (this.tags.some((tag) => tag.slug === slug)) {
134
+ throw new ManagedTemplateTagAlreadyExistsError(`A tag with slug '${slug}' already exists.`);
135
+ }
136
+ return { ...(await this.insertTag(cleaned, tenant)) };
137
+ }
138
+ async getTag(slug) {
139
+ return { ...this.requireTag(slug) };
140
+ }
141
+ async updateTag(slug, text) {
142
+ const tag = this.requireTag(slug);
143
+ const cleaned = this.cleanText(text);
144
+ const base = slugifyTag(cleaned);
145
+ tag.text = cleaned;
146
+ tag.slug = await nextAvailableSlug(base, (candidate) => this.tags.some((other) => other !== tag && other.slug === candidate));
147
+ tag.updatedAt = this.now();
148
+ this.syncTagOnTemplates(tag);
149
+ return { ...tag };
150
+ }
151
+ async setTagStatus(slug, status) {
152
+ const tag = this.requireTag(slug);
153
+ tag.status = status;
154
+ tag.updatedAt = this.now();
155
+ this.syncTagOnTemplates(tag);
156
+ return { ...tag };
157
+ }
158
+ async deleteTag(slug) {
159
+ const tag = this.requireTag(slug);
160
+ this.tags = this.tags.filter((candidate) => candidate !== tag);
161
+ for (const template of this.templates) {
162
+ template.tags = template.tags.filter((candidate) => candidate.id !== tag.id);
163
+ }
164
+ }
165
+ async getTags(status = null, search = null, tenant = null) {
166
+ const term = search === null ? null : search.toLowerCase();
167
+ return this.tags
168
+ .filter((tag) => status === null || status.includes(tag.status))
169
+ .filter((tag) => tenant === null || tag.tenant === tenant)
170
+ .filter((tag) => term === null ||
171
+ tag.text.toLowerCase().includes(term) ||
172
+ tag.slug.toLowerCase().includes(term))
173
+ .map((tag) => ({ ...tag }));
174
+ }
175
+ async getTemplateTags(templateKey, version = null) {
176
+ const template = this.find(templateKey, version);
177
+ if (template === undefined) {
178
+ throw new ManagedTemplateNotFoundError(describeMissing(templateKey, version));
179
+ }
180
+ return template.tags.map((tag) => ({ ...tag }));
181
+ }
182
+ async setTemplateTags(templateKey, tags, version = null) {
183
+ const template = this.find(templateKey, version);
184
+ if (template === undefined) {
185
+ throw new ManagedTemplateNotFoundError(describeMissing(templateKey, version));
186
+ }
187
+ template.tags = await this.resolveTags(tags, template.tenant);
188
+ template.updatedAt = this.now();
189
+ return structuredCloneTemplate(template);
190
+ }
191
+ // -------------------------------------------------------------------------------------------
192
+ // Queries
193
+ // -------------------------------------------------------------------------------------------
194
+ async getAllTemplates() {
195
+ return this.templates.map(structuredCloneTemplate);
196
+ }
197
+ async getTemplatesByStatus(status) {
198
+ return this.templates
199
+ .filter((template) => status.includes(template.status))
200
+ .map(structuredCloneTemplate);
201
+ }
202
+ async getFilteredTemplates(filters) {
203
+ return this.templates
204
+ .filter((template) => this.matches(template, filters))
205
+ .map(structuredCloneTemplate);
206
+ }
207
+ /**
208
+ * Everything, including every order.
209
+ *
210
+ * This backend holds the whole store in memory, so it can sort a complete result set before
211
+ * paging it — which is what ordering requires. It therefore declares the `orderBy.*` keys
212
+ * explicitly: they default to false, and a backend that can genuinely do it has to say so.
213
+ */
214
+ getFilterCapabilities() {
215
+ return Object.fromEntries(MANAGED_TEMPLATE_ORDER_BY_FIELDS.map((field) => [orderByCapabilityKey(field), true]));
216
+ }
217
+ async getPaginatedTemplates(page, pageSize, orderBy) {
218
+ return paginate(sortTemplates(await this.getAllTemplates(), orderBy), page, pageSize);
219
+ }
220
+ async getPaginatedFilteredTemplates(filters, page, pageSize, orderBy) {
221
+ // Sorted before paging, never after: the page has to be chosen from an ordered set.
222
+ return paginate(sortTemplates(await this.getFilteredTemplates(filters), orderBy), page, pageSize);
223
+ }
224
+ // -------------------------------------------------------------------------------------------
225
+ // Internals
226
+ // -------------------------------------------------------------------------------------------
227
+ deriveIsAbstract(sources) {
228
+ try {
229
+ return isAbstract(sources);
230
+ }
231
+ catch {
232
+ // A source nobody can parse has no answer. Storing `false` keeps the syntax error out of
233
+ // the write, where the edit boundary has already had its chance to refuse it.
234
+ return false;
235
+ }
236
+ }
237
+ versionsOf(templateKey) {
238
+ return this.templates.filter((template) => template.key === templateKey);
239
+ }
240
+ find(templateKey, version) {
241
+ const versions = this.versionsOf(templateKey);
242
+ if (version !== null) {
243
+ return versions.find((template) => template.version === version);
244
+ }
245
+ return versions.reduce((latest, template) => latest === undefined || template.version > latest.version ? template : latest, undefined);
246
+ }
247
+ requireTag(slug) {
248
+ const normalized = slugifyTag(slug);
249
+ const tag = this.tags.find((candidate) => candidate.slug === normalized);
250
+ if (tag === undefined) {
251
+ throw new ManagedTemplateTagNotFoundError(`No tag with slug '${slug}' was found.`);
252
+ }
253
+ return tag;
254
+ }
255
+ cleanText(text) {
256
+ const cleaned = normalizeTagText(text);
257
+ if (!cleaned || !slugifyTag(cleaned)) {
258
+ throw new ManagedTemplateInvalidTagError(`Tag text ${JSON.stringify(text)} has no characters that can be turned into a slug.`);
259
+ }
260
+ return cleaned;
261
+ }
262
+ async insertTag(text, tenant) {
263
+ const timestamp = this.now();
264
+ const slug = await nextAvailableSlug(slugifyTag(text), (candidate) => this.tags.some((tag) => tag.slug === candidate));
265
+ const tag = {
266
+ id: this.nextId++,
267
+ text,
268
+ slug,
269
+ status: 'active',
270
+ createdAt: timestamp,
271
+ updatedAt: timestamp,
272
+ tenant,
273
+ };
274
+ this.tags.push(tag);
275
+ return tag;
276
+ }
277
+ /**
278
+ * Resolve texts to tags, creating what is missing — one tag per distinct text, in order.
279
+ *
280
+ * An existing tag is returned as it stands: its text and status are left alone, so re-using an
281
+ * archived tag does not quietly bring it back.
282
+ */
283
+ async resolveTags(texts, tenant) {
284
+ const resolved = [];
285
+ for (const text of texts) {
286
+ const cleaned = this.cleanText(text);
287
+ const slug = slugifyTag(cleaned);
288
+ if (resolved.some((tag) => tag.slug === slug)) {
289
+ continue;
290
+ }
291
+ const existing = this.tags.find((tag) => tag.slug === slug);
292
+ resolved.push(existing ?? (await this.insertTag(cleaned, tenant)));
293
+ }
294
+ return resolved;
295
+ }
296
+ /** Keep the copies embedded on template rows in step with the tag record itself. */
297
+ syncTagOnTemplates(tag) {
298
+ for (const template of this.templates) {
299
+ template.tags = template.tags.map((candidate) => (candidate.id === tag.id ? tag : candidate));
300
+ }
301
+ }
302
+ /**
303
+ * Evaluate a filter over the whole store.
304
+ *
305
+ * The semantics live in `filter-evaluation`, shared with every backend that has to finish a
306
+ * filter its query language could not express — so this store and a FHIR one agree on what
307
+ * `includesAllTags: []` means without either of them re-deriving it.
308
+ */
309
+ matches(template, filters) {
310
+ return matchesTemplateFilter(template, filters, {
311
+ versionsOfKey: (key) => this.versionsOf(key),
312
+ });
313
+ }
314
+ }
315
+ function structuredCloneTemplate(template) {
316
+ return { ...template, tags: template.tags.map((tag) => ({ ...tag })) };
317
+ }
318
+ function describeMissing(templateKey, version) {
319
+ if (version === null) {
320
+ return `No template with key '${templateKey}' was found.`;
321
+ }
322
+ return `Template '${templateKey}' has no version ${version}.`;
323
+ }