@cube-nl/dmp-sdk 0.0.0-stage → 1.0.0-rc.4

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.
@@ -0,0 +1,803 @@
1
+ import { DmpError } from './errors.js';
2
+ import { buildQuery } from './query.js';
3
+ const RESOURCE_NAME = /^[a-z][a-z0-9_]*$/;
4
+ const OPERATORS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'like', 'ilike', 'is', 'between'];
5
+ const SYMBOLS = [['>=', 'gte'], ['<=', 'lte'], ['<>', 'neq'], ['>', 'gt'], ['<', 'lt'], ['=', 'eq']];
6
+ const FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'];
7
+ const UNITS = ['day', 'week', 'month', 'quarter', 'year'];
8
+ const MAX_FILTERS = 20;
9
+ const MAX_DIMENSIONS = 5;
10
+ const MAX_GROUPS = 1000;
11
+ const MAX_IN_VALUES = 200;
12
+ const DEFAULT_PAGE_SIZE = 50;
13
+ const MAX_PAGE_SIZE = 200;
14
+ const CONTRACT = '1.0';
15
+ /**
16
+ * The preview's data: every resource of the mock data, in memory, changed by create, update and
17
+ * delete until the page is reloaded. It answers as the REST face does — the same envelope, the same
18
+ * paging, the same refusals as `DmpError` — and never touches the network.
19
+ */
20
+ export class PreviewStore {
21
+ manifest;
22
+ guard;
23
+ tables = new Map();
24
+ constructor(data, manifest = null,
25
+ /** Runs before every call; throw to refuse it (the preview's signed-out state). */
26
+ guard = () => undefined) {
27
+ this.manifest = manifest;
28
+ this.guard = guard;
29
+ for (const [key, resource] of Object.entries(unwrap(data))) {
30
+ // Not a resource: a comment key such as `_about`, or a value that is no object.
31
+ if (typeof resource !== 'object' || resource === null || Array.isArray(resource)) {
32
+ continue;
33
+ }
34
+ const name = typeof resource.schema?.name === 'string' && resource.schema.name !== '' ? resource.schema.name : key;
35
+ if (RESOURCE_NAME.test(name)) {
36
+ this.tables.set(name, table(name, resource));
37
+ }
38
+ }
39
+ }
40
+ resourceNames() {
41
+ return [...this.tables.keys()];
42
+ }
43
+ resource(name) {
44
+ if (!RESOURCE_NAME.test(name)) {
45
+ throw new TypeError(`'${name}' is not a resource name: cube/api names match [a-z][a-z0-9_]*.`);
46
+ }
47
+ // Answered asynchronously, like the REST face: a refusal is a rejected promise, never a throw.
48
+ const run = (action, work) => Promise.resolve().then(() => work(this.authorize(name, action)));
49
+ const list = (query = {}) => run('list', (table) => listRows(table, query));
50
+ const pages = async function* (query = {}) {
51
+ let number = query.page?.number ?? 1;
52
+ while (true) {
53
+ const page = await list({ ...query, page: { ...query.page, number } });
54
+ yield page;
55
+ if (page.meta.page.number >= page.meta.page.pages || page.data.length === 0) {
56
+ return;
57
+ }
58
+ number = page.meta.page.number + 1;
59
+ }
60
+ };
61
+ return {
62
+ name,
63
+ list,
64
+ pages,
65
+ async *all(query = {}) {
66
+ for await (const page of pages(query)) {
67
+ yield* page.data;
68
+ }
69
+ },
70
+ get: (id, query = {}) => run('get', (table) => getRow(table, id, query)),
71
+ aggregate: (query) => run('aggregate', (table) => aggregateRows(table, query)),
72
+ create: (body, options = {}) => run('create', (table) => createRow(table, body, options)),
73
+ update: (id, body, options = {}) => run('update', (table) => updateRow(table, id, body, options)),
74
+ delete: (id, options = {}) => run('delete', (table) => deleteRow(table, id, options)),
75
+ operation: (id, operation, _input, _options) => run('get', (table) => {
76
+ findRow(table, id);
77
+ throw noSuchOperation(operation);
78
+ }),
79
+ collectionOperation: (operation) => run('list', () => {
80
+ throw noSuchOperation(operation);
81
+ }),
82
+ };
83
+ }
84
+ /**
85
+ * Unknown resource first (404, as the REST face answers it), then the manifest: live, the app's
86
+ * OAuth client is capped to its `uses`, so the preview refuses what the live app would.
87
+ */
88
+ authorize(name, action) {
89
+ this.guard();
90
+ const table = this.tables.get(name);
91
+ if (table === undefined) {
92
+ throw problem(404, 'not_found', `Unknown API resource [${name}].`);
93
+ }
94
+ const uses = this.manifest?.uses;
95
+ if (uses !== undefined && !(uses[name] ?? []).includes(action)) {
96
+ throw problem(403, 'client_scope_exceeded', `This app may not ${action} '${name}': add it to "uses" in cube-app.json.`);
97
+ }
98
+ return table;
99
+ }
100
+ }
101
+ function unwrap(data) {
102
+ const wrapped = data.resources;
103
+ if (Object.keys(data).length === 1 && isObject(wrapped) && !('rows' in wrapped) && !('schema' in wrapped)) {
104
+ return wrapped;
105
+ }
106
+ return data;
107
+ }
108
+ function table(name, resource) {
109
+ const schema = resource.schema ?? {};
110
+ const rows = (resource.rows ?? []).map((row) => clone(row));
111
+ const fields = new Map();
112
+ const declared = Array.isArray(schema.fields)
113
+ ? schema.fields.map((field) => [field.name, field])
114
+ : Object.entries((schema.fields ?? {}));
115
+ for (const [fieldName, definition] of declared) {
116
+ fields.set(fieldName, field(fieldName, definition));
117
+ }
118
+ // Without a schema the rows are the schema: every key any row carries is a field.
119
+ for (const row of fields.size === 0 ? rows : []) {
120
+ for (const key of Object.keys(row)) {
121
+ if (!fields.has(key)) {
122
+ fields.set(key, { name: key, types: [], nullable: true });
123
+ }
124
+ }
125
+ }
126
+ const names = [...fields.keys()];
127
+ // A malformed key or entry is ignored as if absent, so one bad line in a mock never breaks the preview.
128
+ const choiceEntries = (isObject(schema.choices) ? Object.entries(schema.choices) : []).filter((entry) => isObject(entry[1]) && Array.isArray(entry[1].values));
129
+ const readonly = new Set([
130
+ 'id',
131
+ ...(schema.readonly ?? []),
132
+ ...declared.filter(([, definition]) => definition.readonly === true).map(([fieldName]) => fieldName),
133
+ ...choiceEntries
134
+ .filter(([, choice]) => choice.writable === false)
135
+ .map(([fieldName]) => fieldName),
136
+ ]);
137
+ const choices = new Map();
138
+ for (const [fieldName, definition] of declared) {
139
+ if (Array.isArray(definition.enum)) {
140
+ choices.set(fieldName, definition.enum);
141
+ }
142
+ }
143
+ for (const [fieldName, choice] of choiceEntries) {
144
+ choices.set(fieldName, choice.values);
145
+ }
146
+ const known = (list) => Array.isArray(list) ? list.filter((item) => typeof item === 'string' && fields.has(item)) : null;
147
+ return {
148
+ name,
149
+ fields,
150
+ filterable: known(schema.filters) ?? names,
151
+ groupable: known(schema.groupable) ?? names,
152
+ aggregatable: new Map(isObject(schema.aggregatable)
153
+ ? Object.entries(schema.aggregatable)
154
+ .filter(([fieldName, allowed]) => fields.has(fieldName) && Array.isArray(allowed))
155
+ .map(([fieldName, allowed]) => [fieldName, [...allowed]])
156
+ : names.map((fieldName) => [fieldName, [...FUNCTIONS]])),
157
+ choices,
158
+ writable: new Set(names.filter((fieldName) => !readonly.has(fieldName))),
159
+ summary: (known(schema.summary_fields) ?? []).length > 0 ? known(schema.summary_fields) : names,
160
+ rows,
161
+ versions: new Map(rows.map((row) => [key(row.id), 1])),
162
+ replays: new Map(),
163
+ };
164
+ }
165
+ function field(name, definition) {
166
+ const raw = definition.type;
167
+ const list = typeof raw === 'string' ? raw.split('|') : Array.isArray(raw) ? [...raw] : [];
168
+ return {
169
+ name,
170
+ types: list.filter((type) => type !== 'null' && type !== ''),
171
+ nullable: definition.nullable === true || list.includes('null'),
172
+ };
173
+ }
174
+ /**
175
+ * The query is turned into the exact query string the live client would send (`buildQuery`), then
176
+ * read back the way the REST face reads it. The preview so refuses what the API refuses, and a
177
+ * mistake `buildQuery` catches before a request (a bare array as a filter) is the same TypeError.
178
+ */
179
+ function parse(table, query) {
180
+ return new URLSearchParams(buildQuery(table.name, query));
181
+ }
182
+ function listRows(table, query) {
183
+ const params = parse(table, query);
184
+ const selected = selection(table, params);
185
+ const filters = readFilters(table, params);
186
+ const sort = readSort(table, params);
187
+ const { size, number } = readPage(params);
188
+ const omitted = relations(table, params);
189
+ let rows = table.rows.filter((row) => filters.every((filter) => matches(table, row, filter)));
190
+ if (sort.length > 0) {
191
+ rows = [...rows].sort((a, b) => {
192
+ for (const { field: name, descending } of sort) {
193
+ const order = compare(a[name], b[name]);
194
+ if (order !== 0) {
195
+ return descending ? -order : order;
196
+ }
197
+ }
198
+ return 0;
199
+ });
200
+ }
201
+ const total = rows.length;
202
+ const data = rows.slice((number - 1) * size, number * size).map((row) => project(row, selected));
203
+ const page = { size, number, total, pages: Math.max(1, Math.ceil(total / size)) };
204
+ return { ...response(200, data, meta(table, page, omitted)), meta: { ...meta(table, page, omitted), page } };
205
+ }
206
+ function getRow(table, id, query) {
207
+ const params = parse(table, query);
208
+ const selected = selection(table, params);
209
+ const omitted = relations(table, params);
210
+ const row = findRow(table, id);
211
+ return response(200, project(row, selected), meta(table, null, omitted), version(table, row));
212
+ }
213
+ function selection(table, params) {
214
+ const parameter = `fields[${table.name}]`;
215
+ const raw = params.get(parameter);
216
+ if (raw === null || raw === '') {
217
+ return table.summary;
218
+ }
219
+ const requested = raw.split(',').map((item) => item.trim());
220
+ const unknown = requested.filter((item) => !table.fields.has(item));
221
+ if (unknown.length > 0) {
222
+ throw problem(422, 'query_invalid', `Unknown ${parameter}: ${unknown.join(', ')}.`, {
223
+ [parameter]: [`Allowed values: ${[...table.fields.keys()].join(', ')}.`],
224
+ });
225
+ }
226
+ return requested;
227
+ }
228
+ /**
229
+ * Mock data names no relations, so the preview cannot follow one. A requested include or count
230
+ * comes back the way the REST face reports a relation the caller may not see: in `meta.omitted`,
231
+ * never as an empty list that looks like data.
232
+ */
233
+ function relations(_table, params) {
234
+ const list = (key) => (params.get(key) ?? '').split(',').filter((item) => item !== '');
235
+ return [...new Set([...list('include'), ...list('with_count')])];
236
+ }
237
+ function readFilters(table, params) {
238
+ const filters = [];
239
+ const entries = [];
240
+ params.forEach((value, name) => entries.push([name, value]));
241
+ for (const [key, value] of entries) {
242
+ const match = /^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/.exec(key);
243
+ if (match === null) {
244
+ continue;
245
+ }
246
+ const name = match[1];
247
+ if (!table.filterable.includes(name)) {
248
+ throw problem(422, 'query_invalid', `Unknown filter: ${name}.`, {
249
+ filter: [`Allowed values: ${table.filterable.join(', ')}.`],
250
+ }, 'not_filterable');
251
+ }
252
+ filters.push(match[2] === undefined ? fromSymbol(name, value) : operand(name, match[2], value));
253
+ }
254
+ if (filters.length > MAX_FILTERS) {
255
+ throw problem(422, 'query_invalid', `A query takes at most ${MAX_FILTERS} filters.`, {
256
+ filter: [`Sent ${filters.length}; the limit is ${MAX_FILTERS}.`],
257
+ });
258
+ }
259
+ return filters;
260
+ }
261
+ function fromSymbol(name, value) {
262
+ for (const [symbol, operator] of SYMBOLS) {
263
+ if (value.startsWith(symbol)) {
264
+ return { field: name, operator, value: value.slice(symbol.length) };
265
+ }
266
+ }
267
+ return { field: name, operator: 'eq', value };
268
+ }
269
+ function operand(name, operator, value) {
270
+ if (!OPERATORS.includes(operator)) {
271
+ throw problem(422, 'query_invalid', `Unknown filter operator: ${operator}.`, {
272
+ filter: [`Allowed operators: ${OPERATORS.join(', ')}.`],
273
+ });
274
+ }
275
+ if (operator === 'between' && value.split(',').length !== 2) {
276
+ throw problem(422, 'query_invalid', `filter[${name}][between] takes exactly 2 values.`, {
277
+ filter: [`Write filter[${name}][between]=low,high. Received ${value.split(',').length} value(s).`],
278
+ });
279
+ }
280
+ if (operator === 'in' && value.split(',').length > MAX_IN_VALUES) {
281
+ throw problem(422, 'query_invalid', `filter[${name}][in] takes at most ${MAX_IN_VALUES} values.`, {
282
+ filter: [`Sent ${value.split(',').length}; the limit is ${MAX_IN_VALUES}.`],
283
+ });
284
+ }
285
+ if (operator === 'is' && value !== 'null' && value !== 'not_null') {
286
+ throw problem(422, 'query_invalid', `filter[${name}][is] takes null or not_null.`, {
287
+ filter: [`Received: ${value}.`],
288
+ });
289
+ }
290
+ return { field: name, operator, value };
291
+ }
292
+ function matches(table, row, filter) {
293
+ const actual = row[filter.field];
294
+ const typed = (value) => coerce(table.fields.get(filter.field), value.trim());
295
+ switch (filter.operator) {
296
+ case 'eq':
297
+ return same(actual, typed(filter.value));
298
+ case 'neq':
299
+ // As SQL's `<>`: a null is neither equal nor unequal.
300
+ return actual != null && !same(actual, typed(filter.value));
301
+ case 'gt':
302
+ return actual != null && compare(actual, typed(filter.value)) > 0;
303
+ case 'gte':
304
+ return actual != null && compare(actual, typed(filter.value)) >= 0;
305
+ case 'lt':
306
+ return actual != null && compare(actual, typed(filter.value)) < 0;
307
+ case 'lte':
308
+ return actual != null && compare(actual, typed(filter.value)) <= 0;
309
+ case 'in':
310
+ return filter.value.split(',').some((item) => same(actual, typed(item)));
311
+ case 'between': {
312
+ const [low, high] = filter.value.split(',').map(typed);
313
+ return actual != null && compare(actual, low) >= 0 && compare(actual, high) <= 0;
314
+ }
315
+ case 'like':
316
+ case 'ilike':
317
+ return typeof actual === 'string' && like(actual, filter.value);
318
+ case 'is':
319
+ return filter.value === 'null' ? actual == null : actual != null;
320
+ default:
321
+ return false;
322
+ }
323
+ }
324
+ /** As the engine: a substring match where `%` is a wildcard and `_` a literal, case-insensitive. */
325
+ function like(actual, pattern) {
326
+ const source = pattern.split('%').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*');
327
+ return new RegExp(source, 'iu').test(actual);
328
+ }
329
+ function readSort(table, params) {
330
+ const raw = params.get('sort');
331
+ if (raw === null || raw === '') {
332
+ return [];
333
+ }
334
+ const sort = raw.split(',').map((item) => item.trim()).filter((item) => item !== '').map((item) => ({
335
+ field: item.replace(/^[-+]/, ''),
336
+ descending: item.startsWith('-'),
337
+ }));
338
+ const unknown = sort.map((item) => item.field).filter((name) => !table.fields.has(name));
339
+ if (unknown.length > 0) {
340
+ throw problem(422, 'query_invalid', `Unknown sort: ${unknown.join(', ')}.`, {
341
+ sort: [`Allowed values: ${[...table.fields.keys()].join(', ')}.`],
342
+ }, 'not_sortable');
343
+ }
344
+ return sort;
345
+ }
346
+ function readPage(params) {
347
+ const integer = (parameter) => {
348
+ const raw = params.get(parameter);
349
+ if (raw === null || raw === '') {
350
+ return null;
351
+ }
352
+ if (!/^\d+$/.test(raw)) {
353
+ throw problem(422, 'query_invalid', `${parameter} must be a positive integer.`, {
354
+ [parameter]: ['Expected a positive integer.'],
355
+ });
356
+ }
357
+ return Number(raw);
358
+ };
359
+ const size = integer('page[size]') ?? DEFAULT_PAGE_SIZE;
360
+ // Refused rather than clamped, as on the REST face.
361
+ if (size > MAX_PAGE_SIZE) {
362
+ throw problem(422, 'cardinality_exceeded', `page[size] may not exceed ${MAX_PAGE_SIZE}.`, {
363
+ 'page.size': [`The maximum page size is ${MAX_PAGE_SIZE}.`],
364
+ });
365
+ }
366
+ return { size: Math.max(1, size), number: Math.max(1, integer('page[number]') ?? 1) };
367
+ }
368
+ function aggregateRows(table, query) {
369
+ const params = parse(table, query);
370
+ // As the wire reads a query: fn entries, then group entries, then filters.
371
+ const rawFn = (params.get('fn') ?? '').trim();
372
+ if (rawFn === '') {
373
+ throw problem(422, 'query_invalid', 'fn is required: name at least one aggregation function.', {
374
+ fn: [`Expected e.g. fn=count or fn=sum(field). ${aggregatableHint(table)}`],
375
+ });
376
+ }
377
+ const functions = rawFn.split(',').map((item) => item.trim());
378
+ const readers = functions.map((fn) => aggregator(table, fn));
379
+ rejectDuplicates(functions, 'fn');
380
+ const rawGroup = (params.get('group') ?? '').trim();
381
+ const groups = rawGroup === '' ? [] : rawGroup.split(',').map((item) => item.trim());
382
+ // Before the entries: the repair for too many dimensions is a coarser question, not a fixed name.
383
+ if (groups.length > MAX_DIMENSIONS) {
384
+ throw problem(422, 'cardinality_exceeded', `group may not exceed ${MAX_DIMENSIONS} dimensions, ${groups.length} given.`, { group: [`Use at most ${MAX_DIMENSIONS} dimensions.`] });
385
+ }
386
+ const dimensions = groups.map((group) => dimension(table, group));
387
+ rejectDuplicates(groups, 'group');
388
+ const filters = readFilters(table, params);
389
+ const buckets = new Map();
390
+ for (const row of table.rows.filter((candidate) => filters.every((filter) => matches(table, candidate, filter)))) {
391
+ const key = {};
392
+ for (const [index, group] of groups.entries()) {
393
+ key[group] = dimensions[index](row);
394
+ }
395
+ const id = JSON.stringify(Object.values(key));
396
+ const bucket = buckets.get(id) ?? { key, rows: [] };
397
+ bucket.rows.push(row);
398
+ buckets.set(id, bucket);
399
+ }
400
+ if (groups.length === 0 && buckets.size === 0) {
401
+ buckets.set('[]', { key: {}, rows: [] });
402
+ }
403
+ if (buckets.size > MAX_GROUPS) {
404
+ throw problem(422, 'cardinality_exceeded', 'The grouping produces more groups than the configured maximum.', {
405
+ groepen: [
406
+ 'The grouping produces more groups than the configured maximum.',
407
+ `Allowed values: groepeer grover, of filter eerst; maximaal ${MAX_GROUPS} groepen.`,
408
+ ],
409
+ }, 'too_many_groups');
410
+ }
411
+ const ordered = [...buckets.values()].sort((a, b) => {
412
+ for (const group of groups) {
413
+ const order = compare(a.key[group], b.key[group]);
414
+ if (order !== 0) {
415
+ return order;
416
+ }
417
+ }
418
+ return 0;
419
+ });
420
+ const data = ordered.map(({ key, rows }) => {
421
+ const result = { ...key };
422
+ for (const [index, fn] of functions.entries()) {
423
+ result[fn] = readers[index](rows);
424
+ }
425
+ return result;
426
+ });
427
+ return response(200, data, { ...meta(table, null, []), groups: data.length });
428
+ }
429
+ /** As live refuses it: two entries would collapse into one response key. */
430
+ function rejectDuplicates(entries, parameter) {
431
+ const duplicates = [...new Set(entries.filter((entry, index) => entries.indexOf(entry) !== index))];
432
+ if (duplicates.length > 0) {
433
+ throw problem(422, 'query_invalid', `Duplicate ${parameter} entry: ${duplicates.join(', ')}.`, {
434
+ [parameter]: [`List each ${parameter} entry once.`],
435
+ });
436
+ }
437
+ }
438
+ function aggregatableHint(table) {
439
+ return table.aggregatable.size === 0
440
+ ? 'This resource has no aggregatable fields; only bare count is available.'
441
+ : `Aggregatable fields: ${[...table.aggregatable.keys()].join(', ')}.`;
442
+ }
443
+ function aggregator(table, fn) {
444
+ const call = /^([a-z]+)\(([a-z0-9_]+)\)$/.exec(fn);
445
+ if (call === null && !/^[a-z]+$/.test(fn)) {
446
+ throw problem(422, 'query_invalid', `Unparseable fn entry: ${fn}.`, {
447
+ fn: ['Expected `count` or `function(field)`, e.g. sum(field).'],
448
+ });
449
+ }
450
+ const name = call === null ? fn : call[1];
451
+ const target = call === null ? null : call[2];
452
+ if (!FUNCTIONS.includes(name)) {
453
+ throw problem(422, 'query_invalid', `Unknown aggregation function: ${name}.`, {
454
+ fn: [`Allowed values: ${FUNCTIONS.join(', ')}.`],
455
+ });
456
+ }
457
+ if (target === null) {
458
+ if (name !== 'count') {
459
+ throw problem(422, 'query_invalid', `${name} must be applied to a field, e.g. ${name}(field).`, {
460
+ fn: [aggregatableHint(table)],
461
+ });
462
+ }
463
+ return (rows) => rows.length;
464
+ }
465
+ const allowed = table.aggregatable.get(target);
466
+ if (allowed === undefined) {
467
+ throw problem(422, 'query_invalid', `Unknown aggregatable field: ${target}.`, { fn: [aggregatableHint(table)] });
468
+ }
469
+ if (!allowed.includes(name)) {
470
+ throw problem(422, 'query_invalid', `${name} is not allowed on ${target}.`, {
471
+ fn: [`Allowed functions for ${target}: ${allowed.join(', ')}.`],
472
+ });
473
+ }
474
+ return (rows) => {
475
+ const values = rows.map((row) => row[target]).filter((value) => value != null);
476
+ const numbers = values.map(Number).filter((value) => Number.isFinite(value));
477
+ switch (name) {
478
+ case 'count':
479
+ // Live emits count(*) whatever the field, so count(field) counts the group's rows, nulls included.
480
+ return rows.length;
481
+ case 'sum':
482
+ return numbers.length === 0 ? null : numbers.reduce((sum, value) => sum + value, 0);
483
+ case 'avg':
484
+ return numbers.length === 0 ? null : numbers.reduce((sum, value) => sum + value, 0) / numbers.length;
485
+ case 'min':
486
+ return values.length === 0 ? null : values.reduce((low, value) => (compare(value, low) < 0 ? value : low));
487
+ default:
488
+ return values.length === 0 ? null : values.reduce((high, value) => (compare(value, high) > 0 ? value : high));
489
+ }
490
+ };
491
+ }
492
+ function dimension(table, group) {
493
+ const parts = group.split('.');
494
+ if (parts.length > 2) {
495
+ throw problem(422, 'query_invalid', `Unparseable group entry: ${group}.`, {
496
+ group: ['Expected `field` or `field.unit`, e.g. published_at.month.'],
497
+ });
498
+ }
499
+ const [name, truncation] = parts;
500
+ if (!table.groupable.includes(name)) {
501
+ throw problem(422, 'query_invalid', `Unknown group: ${name}.`, {
502
+ group: [`Allowed values: ${table.groupable.join(', ')}.`],
503
+ }, 'not_groupable');
504
+ }
505
+ if (truncation === undefined) {
506
+ return (row) => row[name] ?? null;
507
+ }
508
+ if (!UNITS.includes(truncation)) {
509
+ throw problem(422, 'query_invalid', `Unknown truncation unit: ${truncation}.`, {
510
+ group: [`Allowed values: ${UNITS.join(', ')}.`],
511
+ });
512
+ }
513
+ return (row) => bucket(row[name], truncation);
514
+ }
515
+ /** The date as written (first 10 characters), never shifted through a time zone; null when it is not a date. */
516
+ function bucket(value, unit) {
517
+ const match = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
518
+ if (match === null) {
519
+ return null;
520
+ }
521
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
522
+ const date = new Date(Date.UTC(year, month - 1, day));
523
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
524
+ return null;
525
+ }
526
+ switch (unit) {
527
+ case 'day':
528
+ return match[0];
529
+ case 'week': {
530
+ // ISO week: the Thursday of the Monday-based week decides the ISO year.
531
+ const thursday = new Date(date.getTime() + (3 - ((date.getUTCDay() + 6) % 7)) * 86_400_000);
532
+ const isoYear = thursday.getUTCFullYear();
533
+ const week = 1 + Math.floor((thursday.getTime() - Date.UTC(isoYear, 0, 1)) / (7 * 86_400_000));
534
+ return `${isoYear}-W${String(week).padStart(2, '0')}`;
535
+ }
536
+ case 'month':
537
+ return match[0].slice(0, 7);
538
+ case 'quarter':
539
+ return `${match[1]}-Q${Math.ceil(month / 3)}`;
540
+ case 'year':
541
+ return match[1];
542
+ default:
543
+ return null;
544
+ }
545
+ }
546
+ // ---------------------------------------------------------------------------------------------
547
+ // Writing
548
+ // ---------------------------------------------------------------------------------------------
549
+ function createRow(table, body, options) {
550
+ const selected = options.fields === undefined ? table.summary : selection(table, parse(table, { fields: options.fields }));
551
+ const fingerprint = JSON.stringify([body, options.fields ?? null]);
552
+ if (options.idempotencyKey !== undefined) {
553
+ const earlier = table.replays.get(options.idempotencyKey);
554
+ if (earlier !== undefined) {
555
+ if (earlier.body !== fingerprint) {
556
+ throw problem(422, 'validation_failed', 'This Idempotency-Key was already used with a different '
557
+ + 'request body. Use a new key, or resend the original body.');
558
+ }
559
+ return { ...earlier.response, replayed: true };
560
+ }
561
+ }
562
+ const values = validate(table, body);
563
+ const row = { id: nextId(table), ...values };
564
+ for (const name of table.fields.keys()) {
565
+ if (!(name in row)) {
566
+ row[name] = null;
567
+ }
568
+ }
569
+ table.rows.push(row);
570
+ table.versions.set(key(row.id), 1);
571
+ const created = response(201, project(row, selected), meta(table, null, []), version(table, row));
572
+ if (options.idempotencyKey !== undefined) {
573
+ table.replays.set(options.idempotencyKey, { body: fingerprint, response: created });
574
+ }
575
+ return created;
576
+ }
577
+ function updateRow(table, id, body, options) {
578
+ const selected = options.fields === undefined ? table.summary : selection(table, parse(table, { fields: options.fields }));
579
+ const row = findRow(table, id);
580
+ checkVersion(table, row, options.ifMatch);
581
+ Object.assign(row, validate(table, body));
582
+ table.versions.set(key(row.id), (table.versions.get(key(row.id)) ?? 1) + 1);
583
+ return response(200, project(row, selected), meta(table, null, []), version(table, row));
584
+ }
585
+ function deleteRow(table, id, options) {
586
+ const row = findRow(table, id);
587
+ checkVersion(table, row, options.ifMatch);
588
+ table.rows.splice(table.rows.indexOf(row), 1);
589
+ table.versions.delete(key(row.id));
590
+ return response(204, null, null);
591
+ }
592
+ /**
593
+ * The REST face's two refusals for a body: a key that is not writable (422 `query_invalid`, per
594
+ * field) before any value is looked at, then a value of the wrong type (422 `validation_failed`).
595
+ */
596
+ function validate(table, body) {
597
+ const writable = [...table.writable];
598
+ const message = writable.length === 0
599
+ ? 'This resource declares no writable fields.'
600
+ : `Writable fields: ${writable.join(', ')}.`;
601
+ if (!isObject(body) || Object.keys(body).length === 0) {
602
+ throw problem(422, 'validation_failed', 'The request body must be a JSON object naming at least one writable field.', {
603
+ body: [message],
604
+ });
605
+ }
606
+ const refused = Object.keys(body).filter((name) => !table.writable.has(name));
607
+ if (refused.length > 0) {
608
+ throw problem(422, 'query_invalid', `Unknown or read-only fields: ${refused.join(', ')}.`, Object.fromEntries(refused.map((name) => [name, [message]])));
609
+ }
610
+ const errors = {};
611
+ for (const [name, value] of Object.entries(body)) {
612
+ const definition = table.fields.get(name);
613
+ const problemText = typeError(definition, value) ?? choiceError(table, definition, name, value);
614
+ if (problemText !== null) {
615
+ errors[name] = [problemText];
616
+ }
617
+ }
618
+ if (Object.keys(errors).length > 0) {
619
+ throw problem(422, 'validation_failed', 'The request body failed validation.', errors);
620
+ }
621
+ return clone(body);
622
+ }
623
+ /** Laravel's `in` rule: a value outside the field's allowed values. Runs only after the type check passed. */
624
+ function choiceError(table, definition, name, value) {
625
+ const allowed = table.choices.get(name);
626
+ if (allowed === undefined || (value === null && definition?.nullable === true) || allowed.includes(value)) {
627
+ return null;
628
+ }
629
+ return `The selected ${name.replace(/_/g, ' ')} is invalid.`;
630
+ }
631
+ /** Laravel's rules and wording for the one declared type, as the REST face's TypeRules applies them. */
632
+ function typeError(definition, value) {
633
+ if (definition === undefined || definition.types.length !== 1) {
634
+ return null;
635
+ }
636
+ if (value === null) {
637
+ return definition.nullable ? null : messageFor(definition);
638
+ }
639
+ const valid = (() => {
640
+ switch (definition.types[0]) {
641
+ case 'string':
642
+ return typeof value === 'string';
643
+ case 'integer':
644
+ return (typeof value === 'number' && Number.isInteger(value))
645
+ || (typeof value === 'string' && /^[+-]?\d+$/.test(value.trim()));
646
+ case 'number':
647
+ return (typeof value === 'number' && Number.isFinite(value))
648
+ || (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)));
649
+ case 'boolean':
650
+ return [true, false, 0, 1, '0', '1'].includes(value);
651
+ case 'array':
652
+ case 'object':
653
+ return typeof value === 'object';
654
+ default:
655
+ return true;
656
+ }
657
+ })();
658
+ return valid ? null : messageFor(definition);
659
+ }
660
+ function messageFor(definition) {
661
+ const attribute = definition.name.replace(/_/g, ' ');
662
+ const kind = {
663
+ string: 'a string',
664
+ integer: 'an integer',
665
+ number: 'a number',
666
+ boolean: 'true or false',
667
+ array: 'an array',
668
+ object: 'an array',
669
+ };
670
+ return `The ${attribute} field must be ${kind[definition.types[0]] ?? 'valid'}.`;
671
+ }
672
+ function checkVersion(table, row, ifMatch) {
673
+ if (ifMatch === undefined) {
674
+ return;
675
+ }
676
+ if (ifMatch.startsWith('W/')) {
677
+ throw new TypeError('ifMatch takes recordVersion (X-Dmp-Record-Version), not the weak ETag.');
678
+ }
679
+ const current = version(table, row);
680
+ if (ifMatch !== current) {
681
+ throw problem(409, 'conflict', 'The record changed since the version you sent.', undefined, undefined, {
682
+ 'X-Dmp-Record-Version': current,
683
+ });
684
+ }
685
+ }
686
+ function nextId(table) {
687
+ const ids = table.rows.map((row) => row.id);
688
+ if (ids.length > 0 && ids.every((id) => typeof id === 'string') && ids.some((id) => !/^\d+$/.test(id))) {
689
+ return crypto.randomUUID();
690
+ }
691
+ return ids.reduce((max, id) => Math.max(max, Number(id) || 0), 0) + 1;
692
+ }
693
+ // ---------------------------------------------------------------------------------------------
694
+ // Shared
695
+ // ---------------------------------------------------------------------------------------------
696
+ function findRow(table, id) {
697
+ const wanted = key(id);
698
+ if (wanted === '' || wanted === '.' || wanted === '..') {
699
+ throw new TypeError(`${table.name}: '${wanted}' is not an id.`);
700
+ }
701
+ const row = table.rows.find((candidate) => key(candidate.id) === wanted);
702
+ if (row === undefined) {
703
+ throw problem(404, 'not_found', `No such ${table.name} record.`);
704
+ }
705
+ return row;
706
+ }
707
+ function noSuchOperation(operation) {
708
+ return problem(404, 'not_found', `No such operation [${operation}]. The preview runs no custom operations.`);
709
+ }
710
+ function project(row, selected) {
711
+ const result = {};
712
+ for (const name of selected) {
713
+ result[name] = name in row ? clone(row[name]) : null;
714
+ }
715
+ return result;
716
+ }
717
+ function version(table, row) {
718
+ return `"preview-${key(row.id)}-${table.versions.get(key(row.id)) ?? 1}"`;
719
+ }
720
+ function meta(table, page, omitted) {
721
+ return { resource: table.name, contract: CONTRACT, page, groups: null, omitted, pruned: [], forbidden_fields: [] };
722
+ }
723
+ function response(status, data, body, recordVersion = null) {
724
+ const headers = new Headers({ 'X-Dmp-Preview': 'true' });
725
+ if (recordVersion !== null) {
726
+ headers.set('X-Dmp-Record-Version', recordVersion);
727
+ }
728
+ return {
729
+ status,
730
+ data,
731
+ meta: body,
732
+ headers,
733
+ etag: null,
734
+ recordVersion,
735
+ location: null,
736
+ replayed: false,
737
+ rateLimit: { limit: null, remaining: null, reset: null },
738
+ };
739
+ }
740
+ /** A refusal shaped exactly as the REST face writes it, so the app's error handling is the same code. */
741
+ function problem(status, code, detail, errors, reason, headers = {}) {
742
+ return new DmpError(status, new Headers({ 'Content-Type': 'application/problem+json', ...headers }), {
743
+ type: `https://docs.cube.nl/dmp-api/problems/${code}`,
744
+ title: code.replace(/_/g, ' '),
745
+ status,
746
+ code,
747
+ ...(reason !== undefined ? { reason } : {}),
748
+ detail,
749
+ ...(errors !== undefined ? { errors } : {}),
750
+ });
751
+ }
752
+ /** A filter value from the query string, typed after the field it compares against. */
753
+ function coerce(definition, value) {
754
+ const type = definition?.types[0];
755
+ if ((type === 'integer' || type === 'number') && value !== '' && Number.isFinite(Number(value))) {
756
+ return Number(value);
757
+ }
758
+ if (type === 'boolean') {
759
+ if (value === '1' || value === 'true') {
760
+ return true;
761
+ }
762
+ if (value === '0' || value === 'false') {
763
+ return false;
764
+ }
765
+ }
766
+ return value;
767
+ }
768
+ function same(actual, expected) {
769
+ if (actual == null) {
770
+ return false;
771
+ }
772
+ if (typeof actual === 'boolean' && typeof expected !== 'boolean') {
773
+ return (actual ? '1' : '0') === String(expected) || String(actual) === String(expected);
774
+ }
775
+ if (typeof actual === 'number' && typeof expected === 'string') {
776
+ return String(actual) === expected;
777
+ }
778
+ return actual === expected || (typeof actual !== 'object' && String(actual) === String(expected));
779
+ }
780
+ /** Nulls first, numbers as numbers, everything else as text (ISO dates sort correctly as text). */
781
+ function compare(a, b) {
782
+ if (a == null || b == null) {
783
+ return a == null ? (b == null ? 0 : -1) : 1;
784
+ }
785
+ if (typeof a === 'number' && typeof b === 'number') {
786
+ return a - b;
787
+ }
788
+ if (typeof a === 'boolean' || typeof b === 'boolean') {
789
+ return Number(a === true || a === 1 || a === '1') - Number(b === true || b === 1 || b === '1');
790
+ }
791
+ const left = String(a);
792
+ const right = String(b);
793
+ return left < right ? -1 : left > right ? 1 : 0;
794
+ }
795
+ function key(id) {
796
+ return id === null || id === undefined ? '' : String(id);
797
+ }
798
+ function isObject(value) {
799
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
800
+ }
801
+ function clone(value) {
802
+ return value === undefined ? value : JSON.parse(JSON.stringify(value));
803
+ }