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.
- package/README.md +529 -0
- package/dist/base-template-manager-backend.d.ts +202 -0
- package/dist/base-template-manager-backend.d.ts.map +1 -0
- package/dist/base-template-manager-backend.js +1 -0
- package/dist/composition.d.ts +238 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +0 -0
- package/dist/constants.d.ts +32 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +31 -0
- package/dist/errors.d.ts +80 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +86 -0
- package/dist/filter-evaluation.d.ts +69 -0
- package/dist/filter-evaluation.d.ts.map +1 -0
- package/dist/filter-evaluation.js +252 -0
- package/dist/filters.d.ts +192 -0
- package/dist/filters.d.ts.map +1 -0
- package/dist/filters.js +252 -0
- package/dist/in-memory-template-manager-backend.d.ts +85 -0
- package/dist/in-memory-template-manager-backend.d.ts.map +1 -0
- package/dist/in-memory-template-manager-backend.js +323 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/managed-template-renderer.d.ts +153 -0
- package/dist/managed-template-renderer.d.ts.map +1 -0
- package/dist/managed-template-renderer.js +152 -0
- package/dist/managed-template-service.d.ts +415 -0
- package/dist/managed-template-service.d.ts.map +1 -0
- package/dist/managed-template-service.js +712 -0
- package/dist/tags.d.ts +54 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/tags.js +108 -0
- package/dist/types.d.ts +100 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +41 -0
package/dist/filters.js
ADDED
|
@@ -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
|
+
}
|