@vxil/feature-configs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/hooks.d.ts +167 -0
- package/dist/hooks.js +914 -0
- package/dist/index.d.ts +587 -0
- package/dist/index.js +1191 -0
- package/dist/readmodels.d.ts +38 -0
- package/dist/readmodels.js +229 -0
- package/package.json +30 -0
- package/src/hooks.test.ts +451 -0
- package/src/hooks.ts +830 -0
- package/src/index.test.ts +739 -0
- package/src/index.ts +1572 -0
- package/src/readmodels.ts +248 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// cms-rel B5/E config-write gates (docs/cms-relational-depth-options.md §3/§4)
|
|
2
|
+
// — the hooks.ts sibling: PURE structural validation of the `readModels` and
|
|
3
|
+
// `cdc` bags at config-write time (the anti-malice-gate pattern). The grammar
|
|
4
|
+
// (fn/rank allow-lists, arity caps, window bounds, dotted-term shape) is
|
|
5
|
+
// closed HERE, before a spec can ever be published to KV; collection/field/
|
|
6
|
+
// slot EXISTENCE is checked at runtime by cms-v1 (the field list is
|
|
7
|
+
// per-collection DB data, not known here) — the same split the hooks gate
|
|
8
|
+
// documents. This package is typebox-only, so the checks are a pure mirror of
|
|
9
|
+
// workers/cms-v1/src/relational.ts (the publicHttpsUrlError precedent).
|
|
10
|
+
|
|
11
|
+
const NAME_RE = /^[a-z][a-z0-9_]{0,62}$/;
|
|
12
|
+
/** own-field or ONE-hop dotted key (`channel.visibility`, `channel.$status`) */
|
|
13
|
+
const TERM_KEY_RE = /^[$]?[a-z][a-z0-9_]{0,62}(\.\$?[a-z][a-z0-9_]{0,62})?$/;
|
|
14
|
+
const AGG_FNS = new Set(['count', 'sum', 'min', 'max', 'avg']);
|
|
15
|
+
const RANK_FNS = new Set(['row_number', 'rank', 'percent_rank']);
|
|
16
|
+
const CDC_EVENTS = new Set(['created', 'updated', 'deleted', 'published']);
|
|
17
|
+
|
|
18
|
+
export const READ_MODEL_LIMITS = {
|
|
19
|
+
maxReadModels: 20,
|
|
20
|
+
maxAggregates: 4,
|
|
21
|
+
maxGroupBy: 2,
|
|
22
|
+
maxWindowDays: 366,
|
|
23
|
+
maxSpecBytes: 4096,
|
|
24
|
+
maxCdcRules: 25,
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
function isObj(v: unknown): v is Record<string, unknown> {
|
|
28
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Structural (grammar-only) check of ONE aggregate expression. */
|
|
32
|
+
function aggExprErrors(where: string, raw: unknown): string[] {
|
|
33
|
+
if (!isObj(raw)) return [`${where}: must be { fn, field?, as? }`];
|
|
34
|
+
const errs: string[] = [];
|
|
35
|
+
if (typeof raw.fn !== 'string' || !AGG_FNS.has(raw.fn)) {
|
|
36
|
+
errs.push(`${where}/fn: must be one of ${[...AGG_FNS].join('|')}`);
|
|
37
|
+
}
|
|
38
|
+
if (raw.fn === 'count' && raw.field !== undefined) errs.push(`${where}: count takes no field`);
|
|
39
|
+
if (raw.fn !== 'count' && raw.fn !== undefined && typeof raw.field !== 'string') {
|
|
40
|
+
errs.push(`${where}/field: required for ${String(raw.fn)}`);
|
|
41
|
+
}
|
|
42
|
+
if (raw.field !== undefined && (typeof raw.field !== 'string' || !NAME_RE.test(raw.field))) {
|
|
43
|
+
errs.push(`${where}/field: must match ${NAME_RE.source}`);
|
|
44
|
+
}
|
|
45
|
+
if (raw.as !== undefined && (typeof raw.as !== 'string' || !NAME_RE.test(raw.as))) {
|
|
46
|
+
errs.push(`${where}/as: must match ${NAME_RE.source}`);
|
|
47
|
+
}
|
|
48
|
+
return errs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function windowErrors(where: string, raw: unknown): string[] {
|
|
52
|
+
if (raw === undefined) return [];
|
|
53
|
+
if (!isObj(raw)) return [`${where}: must be { field, sinceDays } or { field, since, until? }`];
|
|
54
|
+
const errs: string[] = [];
|
|
55
|
+
if (typeof raw.field !== 'string' || !NAME_RE.test(raw.field)) {
|
|
56
|
+
errs.push(`${where}/field: required (a datetime field or created_at/updated_at/published_at)`);
|
|
57
|
+
}
|
|
58
|
+
if (raw.sinceDays !== undefined) {
|
|
59
|
+
if (!Number.isInteger(raw.sinceDays) || (raw.sinceDays as number) < 1
|
|
60
|
+
|| (raw.sinceDays as number) > READ_MODEL_LIMITS.maxWindowDays) {
|
|
61
|
+
errs.push(`${where}/sinceDays: must be 1–${READ_MODEL_LIMITS.maxWindowDays}`);
|
|
62
|
+
}
|
|
63
|
+
} else if (raw.since !== undefined) {
|
|
64
|
+
if (typeof raw.since !== 'string' || Number.isNaN(Date.parse(raw.since))) {
|
|
65
|
+
errs.push(`${where}/since: must be an ISO datetime`);
|
|
66
|
+
}
|
|
67
|
+
if (raw.until !== undefined
|
|
68
|
+
&& (typeof raw.until !== 'string' || Number.isNaN(Date.parse(raw.until)))) {
|
|
69
|
+
errs.push(`${where}/until: must be an ISO datetime`);
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
errs.push(`${where}: needs sinceDays or since`);
|
|
73
|
+
}
|
|
74
|
+
return errs;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function filterErrors(where: string, raw: unknown): string[] {
|
|
78
|
+
if (raw === undefined) return [];
|
|
79
|
+
if (!isObj(raw)) return [`${where}: must be a JSON object`];
|
|
80
|
+
const errs: string[] = [];
|
|
81
|
+
let dotted = 0;
|
|
82
|
+
const dottedPrefixes = new Set<string>();
|
|
83
|
+
for (const key of Object.keys(raw)) {
|
|
84
|
+
if (!TERM_KEY_RE.test(key)) {
|
|
85
|
+
errs.push(`${where}/${key}: not a valid field or one-hop dotted key`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const dot = key.indexOf('.');
|
|
89
|
+
if (dot > 0) {
|
|
90
|
+
dotted += 1;
|
|
91
|
+
dottedPrefixes.add(key.slice(0, dot));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (dotted > 4) errs.push(`${where}: at most 4 dotted (joined) terms`);
|
|
95
|
+
if (dottedPrefixes.size > 1) {
|
|
96
|
+
errs.push(`${where}: dotted terms must reference ONE relation (got ${[...dottedPrefixes].join(', ')})`);
|
|
97
|
+
}
|
|
98
|
+
return errs;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Structural validation of a declared read-model `spec` — shared by the
|
|
103
|
+
* config-write gate here and re-exported for anyone needing the pure check.
|
|
104
|
+
* `kind` selects the §3.1 (aggregate) or §4.1 (rank) grammar.
|
|
105
|
+
*/
|
|
106
|
+
export function validateReadModelSpecShape(
|
|
107
|
+
where: string,
|
|
108
|
+
kind: 'aggregate' | 'rank',
|
|
109
|
+
spec: unknown,
|
|
110
|
+
): string[] {
|
|
111
|
+
if (!isObj(spec)) return [`${where}: spec must be an object`];
|
|
112
|
+
if (JSON.stringify(spec).length > READ_MODEL_LIMITS.maxSpecBytes) {
|
|
113
|
+
return [`${where}: serialized spec exceeds ${READ_MODEL_LIMITS.maxSpecBytes} bytes`];
|
|
114
|
+
}
|
|
115
|
+
const errs: string[] = [];
|
|
116
|
+
if (spec.limit !== undefined) {
|
|
117
|
+
errs.push(`${where}/limit: a declared read-model carries no limit (materialization uses the platform cap)`);
|
|
118
|
+
}
|
|
119
|
+
errs.push(...filterErrors(`${where}/filter`, spec.filter));
|
|
120
|
+
errs.push(...windowErrors(`${where}/window`, spec.window));
|
|
121
|
+
if (kind === 'aggregate') {
|
|
122
|
+
if (!Array.isArray(spec.aggregates) || spec.aggregates.length === 0
|
|
123
|
+
|| spec.aggregates.length > READ_MODEL_LIMITS.maxAggregates) {
|
|
124
|
+
errs.push(`${where}/aggregates: must be 1–${READ_MODEL_LIMITS.maxAggregates} expressions`);
|
|
125
|
+
} else {
|
|
126
|
+
spec.aggregates.forEach((a, i) => errs.push(...aggExprErrors(`${where}/aggregates/${i}`, a)));
|
|
127
|
+
}
|
|
128
|
+
if (spec.groupBy !== undefined) {
|
|
129
|
+
if (!Array.isArray(spec.groupBy) || spec.groupBy.length > READ_MODEL_LIMITS.maxGroupBy
|
|
130
|
+
|| !spec.groupBy.every((g) => typeof g === 'string' && NAME_RE.test(g))) {
|
|
131
|
+
errs.push(`${where}/groupBy: must be ≤${READ_MODEL_LIMITS.maxGroupBy} field names`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (spec.sort !== undefined && typeof spec.sort !== 'string') {
|
|
135
|
+
errs.push(`${where}/sort: must be a string`);
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
if (typeof spec.groupBy !== 'string' || !NAME_RE.test(spec.groupBy)) {
|
|
139
|
+
errs.push(`${where}/groupBy: required (the ranked entity's field name)`);
|
|
140
|
+
}
|
|
141
|
+
if (spec.metric !== undefined) errs.push(...aggExprErrors(`${where}/metric`, spec.metric));
|
|
142
|
+
if (typeof spec.rank !== 'string' || !RANK_FNS.has(spec.rank)) {
|
|
143
|
+
errs.push(`${where}/rank: must be one of ${[...RANK_FNS].join('|')}`);
|
|
144
|
+
}
|
|
145
|
+
if (spec.partitionBy !== undefined
|
|
146
|
+
&& (typeof spec.partitionBy !== 'string' || !NAME_RE.test(spec.partitionBy))) {
|
|
147
|
+
errs.push(`${where}/partitionBy: must be a field name`);
|
|
148
|
+
}
|
|
149
|
+
if (spec.direction !== undefined && spec.direction !== 'asc' && spec.direction !== 'desc') {
|
|
150
|
+
errs.push(`${where}/direction: must be 'asc' or 'desc'`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return errs;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Shape of one declared read-model (mirrors the CmsConfigSchema Record). */
|
|
157
|
+
interface ReadModelDefLike {
|
|
158
|
+
collection?: string;
|
|
159
|
+
kind?: string;
|
|
160
|
+
spec?: unknown;
|
|
161
|
+
materialize?: { cron?: string; to?: string };
|
|
162
|
+
enabled?: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 5-field cron shape (jobs-v1 owns real parsing; this rejects garbage early). */
|
|
166
|
+
const CRON_RE = /^\s*\S+\s+\S+\s+\S+\s+\S+\s+\S+\s*$/;
|
|
167
|
+
|
|
168
|
+
/** Cross-field rule for CmsConfig.readModels (called from validateFeatureConfig). */
|
|
169
|
+
export function validateReadModelsConfig(
|
|
170
|
+
readModels: Record<string, ReadModelDefLike> | undefined,
|
|
171
|
+
): string[] {
|
|
172
|
+
if (!readModels) return [];
|
|
173
|
+
const errs: string[] = [];
|
|
174
|
+
const names = Object.keys(readModels);
|
|
175
|
+
if (names.length > READ_MODEL_LIMITS.maxReadModels) {
|
|
176
|
+
errs.push(`/readModels: at most ${READ_MODEL_LIMITS.maxReadModels} read-models (got ${names.length})`);
|
|
177
|
+
}
|
|
178
|
+
for (const name of names) {
|
|
179
|
+
const where = `/readModels/${name}`;
|
|
180
|
+
if (!NAME_RE.test(name)) errs.push(`${where}: name must match ${NAME_RE.source}`);
|
|
181
|
+
const rm = readModels[name]!;
|
|
182
|
+
if (typeof rm.collection !== 'string' || !NAME_RE.test(rm.collection)) {
|
|
183
|
+
errs.push(`${where}/collection: must be a collection slug`);
|
|
184
|
+
}
|
|
185
|
+
if (rm.kind !== 'aggregate' && rm.kind !== 'rank') {
|
|
186
|
+
errs.push(`${where}/kind: must be 'aggregate' or 'rank'`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
errs.push(...validateReadModelSpecShape(`${where}/spec`, rm.kind, rm.spec));
|
|
190
|
+
if (rm.materialize !== undefined) {
|
|
191
|
+
if (!isObj(rm.materialize)) {
|
|
192
|
+
errs.push(`${where}/materialize: must be { cron, to }`);
|
|
193
|
+
} else {
|
|
194
|
+
if (typeof rm.materialize.to !== 'string' || !NAME_RE.test(rm.materialize.to)) {
|
|
195
|
+
errs.push(`${where}/materialize/to: must be a collection slug`);
|
|
196
|
+
}
|
|
197
|
+
if (typeof rm.materialize.cron !== 'string' || !CRON_RE.test(rm.materialize.cron)) {
|
|
198
|
+
errs.push(`${where}/materialize/cron: must be a 5-field cron expression`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return errs;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Shape of one CDC rule (mirrors the CmsConfigSchema Record). */
|
|
207
|
+
interface CdcRuleLike {
|
|
208
|
+
collection?: string;
|
|
209
|
+
channel?: string;
|
|
210
|
+
events?: unknown;
|
|
211
|
+
payload?: unknown;
|
|
212
|
+
enabled?: boolean;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const CHANNEL_RE = /^[a-zA-Z0-9:_.-]{1,128}$/;
|
|
216
|
+
|
|
217
|
+
/** Cross-field rule for CmsConfig.cdc (called from validateFeatureConfig). */
|
|
218
|
+
export function validateCdcConfig(
|
|
219
|
+
cdc: Record<string, CdcRuleLike> | undefined,
|
|
220
|
+
): string[] {
|
|
221
|
+
if (!cdc) return [];
|
|
222
|
+
const errs: string[] = [];
|
|
223
|
+
const names = Object.keys(cdc);
|
|
224
|
+
if (names.length > READ_MODEL_LIMITS.maxCdcRules) {
|
|
225
|
+
errs.push(`/cdc: at most ${READ_MODEL_LIMITS.maxCdcRules} rules (got ${names.length})`);
|
|
226
|
+
}
|
|
227
|
+
for (const name of names) {
|
|
228
|
+
const where = `/cdc/${name}`;
|
|
229
|
+
if (!NAME_RE.test(name)) errs.push(`${where}: name must match ${NAME_RE.source}`);
|
|
230
|
+
const r = cdc[name]!;
|
|
231
|
+
if (typeof r.collection !== 'string' || !NAME_RE.test(r.collection)) {
|
|
232
|
+
errs.push(`${where}/collection: must be a collection slug`);
|
|
233
|
+
}
|
|
234
|
+
if (typeof r.channel !== 'string' || !CHANNEL_RE.test(r.channel)) {
|
|
235
|
+
errs.push(`${where}/channel: must match ${CHANNEL_RE.source}`);
|
|
236
|
+
}
|
|
237
|
+
if (r.events !== undefined) {
|
|
238
|
+
if (!Array.isArray(r.events) || r.events.length === 0
|
|
239
|
+
|| !r.events.every((e) => typeof e === 'string' && CDC_EVENTS.has(e))) {
|
|
240
|
+
errs.push(`${where}/events: must be a non-empty subset of ${[...CDC_EVENTS].join('|')}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (r.payload !== undefined && r.payload !== 'ids' && r.payload !== 'full') {
|
|
244
|
+
errs.push(`${where}/payload: must be 'ids' or 'full'`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return errs;
|
|
248
|
+
}
|