@adminiumjs/manifest 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capacity.d.ts +786 -0
- package/dist/capacity.d.ts.map +1 -0
- package/dist/capacity.js +717 -0
- package/dist/capacity.js.map +1 -0
- package/dist/documents.d.ts +91 -5
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +129 -32
- package/dist/documents.js.map +1 -1
- package/dist/formula.d.ts +114 -2
- package/dist/formula.d.ts.map +1 -1
- package/dist/formula.js +291 -5
- package/dist/formula.js.map +1 -1
- package/dist/index.d.ts +12 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -6
- package/dist/index.js.map +1 -1
- package/dist/marketplace-wire.d.ts +230 -0
- package/dist/marketplace-wire.d.ts.map +1 -0
- package/dist/marketplace-wire.js +138 -0
- package/dist/marketplace-wire.js.map +1 -0
- package/dist/outbox.d.ts +242 -27
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +553 -27
- package/dist/outbox.js.map +1 -1
- package/dist/plan-context.d.ts +28 -3
- package/dist/plan-context.d.ts.map +1 -1
- package/dist/plan-context.js +291 -14
- package/dist/plan-context.js.map +1 -1
- package/dist/plan-model.d.ts +58 -3
- package/dist/plan-model.d.ts.map +1 -1
- package/dist/public-access.d.ts +637 -0
- package/dist/public-access.d.ts.map +1 -1
- package/dist/public-access.js +1593 -12
- package/dist/public-access.js.map +1 -1
- package/dist/refs.d.ts +86 -0
- package/dist/refs.d.ts.map +1 -1
- package/dist/refs.js +186 -0
- package/dist/refs.js.map +1 -1
- package/dist/roles.d.ts +10 -2
- package/dist/roles.d.ts.map +1 -1
- package/dist/roles.js +27 -5
- package/dist/roles.js.map +1 -1
- package/dist/sample.d.ts +218 -2
- package/dist/sample.d.ts.map +1 -1
- package/dist/sample.js +145 -7
- package/dist/sample.js.map +1 -1
- package/dist/schema.d.ts +2860 -360
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1002 -54
- package/dist/schema.js.map +1 -1
- package/dist/shapes.d.ts +5 -1
- package/dist/shapes.d.ts.map +1 -1
- package/dist/shapes.js +13 -3
- package/dist/shapes.js.map +1 -1
- package/dist/states.d.ts +342 -2
- package/dist/states.d.ts.map +1 -1
- package/dist/states.js +566 -7
- package/dist/states.js.map +1 -1
- package/dist/table-shapes.d.ts +47 -0
- package/dist/table-shapes.d.ts.map +1 -0
- package/dist/table-shapes.js +159 -0
- package/dist/table-shapes.js.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +80 -2
- package/dist/validate.js.map +1 -1
- package/package.json +2 -2
package/dist/schema.js
CHANGED
|
@@ -13,14 +13,16 @@ import { addOnBlockSchema, addOnCategorySchema, i18nMessageSchema, isSlotId, } f
|
|
|
13
13
|
import { z } from 'zod';
|
|
14
14
|
import { addOnNeedsIssues, addOnsSchema, requiresAddOn } from './add-ons.js';
|
|
15
15
|
import { bookingIssues, bookingSchema } from './booking.js';
|
|
16
|
+
import { capacityIssues, capacitySchema, isLegacyCapacity, kindOf, rulesOf, viaIndexIssues } from './capacity.js';
|
|
16
17
|
import { appDocumentIssues, appDocumentSchema, mappingIssues } from './documents.js';
|
|
17
18
|
import { formulaColumns, formulaExprSchema, tableFormulaIssues } from './formula.js';
|
|
18
19
|
import { pageCalendarIssues } from './page-calendar.js';
|
|
19
20
|
import { emailTemplateSchema, outboxIssues, outboxProducerSchema, outboxSchema } from './outbox.js';
|
|
20
|
-
import { publicAccessIssues, publicAccessSchema, publicKeysSchema, shareCodeColumns } from './public-access.js';
|
|
21
|
+
import { codeWhereSchema, personalColumn, publicAccessIssues, publicAccessSchema, publicKeysSchema, shareCodeColumns, unlistedColumn } from './public-access.js';
|
|
21
22
|
import { roleLimitIssues, roleLimitsSchema } from './roles.js';
|
|
22
23
|
import { statesIssues, statesSchema } from './states.js';
|
|
23
|
-
import {
|
|
24
|
+
import { MOMENT_LIMITS, clockTimeSchema, momentIssues, momentSchema, settingRefSchema } from './refs.js';
|
|
25
|
+
import { NUMERIC_TYPES, labelsSchema, refSchema, scalarSchema, settingSourceSchema, tableIndex, textOrLabels, valueFits, } from './refs.js';
|
|
24
26
|
import { compareSemver, parseSemverRange } from './semver.js';
|
|
25
27
|
export { compareSemver };
|
|
26
28
|
/** Integer spec version, frozen at 1 for Adminium 1.x. */
|
|
@@ -216,6 +218,17 @@ export { labelsSchema };
|
|
|
216
218
|
const hashChildSchema = z
|
|
217
219
|
.object({ table: refSchema, via: refSchema, columns: z.array(refSchema).min(1).max(24), orderBy: refSchema.optional() })
|
|
218
220
|
.strict();
|
|
221
|
+
/** A number the manifest states, or a whole-number setting. */
|
|
222
|
+
const stampAmount = (max) => z.union([z.number().int().min(0).max(max), settingRefSchema]);
|
|
223
|
+
/** `addMinutes`: now plus minutes or hours — one of the two — capped by a moment. */
|
|
224
|
+
const addMinutesSchema = z
|
|
225
|
+
.object({ minutes: stampAmount(MOMENT_LIMITS.minutes).optional(), hours: stampAmount(MOMENT_LIMITS.hours).optional(), notAfter: momentSchema.optional() })
|
|
226
|
+
.strict()
|
|
227
|
+
.refine((a) => (a.minutes === undefined) !== (a.hours === undefined), { message: 'a stamp adds minutes or hours, one of the two' });
|
|
228
|
+
/** `deadline`: so many days after today at a time of day, capped by a moment. */
|
|
229
|
+
const deadlineSchema = z
|
|
230
|
+
.object({ days: stampAmount(MOMENT_LIMITS.days), time: clockTimeSchema, notAfter: momentSchema.optional() })
|
|
231
|
+
.strict();
|
|
219
232
|
/**
|
|
220
233
|
* What a stamp writes:
|
|
221
234
|
*
|
|
@@ -231,7 +244,18 @@ const hashChildSchema = z
|
|
|
231
244
|
* - `addDays`: a date so many days after another (`due_on` = `issued_on` +
|
|
232
245
|
* the terms' days, `map` giving each term its days);
|
|
233
246
|
* - `hashOf`: a fingerprint — SHA-256 over the named columns, child rows and
|
|
234
|
-
* a linked row's text, in a canonical form anyone can recompute
|
|
247
|
+
* a linked row's text, in a canonical form anyone can recompute;
|
|
248
|
+
* - `addMinutes`: the moment so many minutes or hours from now (a hold for
|
|
249
|
+
* ten minutes), the number stated or read from the settings row — but
|
|
250
|
+
* never later than `notAfter`, as a deadline is capped (an offer ends at
|
|
251
|
+
* the doors at the latest);
|
|
252
|
+
* - `deadline`: `days` after today on the venue's calendar at `time`, but
|
|
253
|
+
* never later than `notAfter`, a moment of the row or a linked row (a
|
|
254
|
+
* transfer due in five days at 18:00, or three days before the show);
|
|
255
|
+
* - `moment`: a moment of the row or a linked row (see `refs.ts`), written
|
|
256
|
+
* again whenever the stamp fires — with `on: {columns}`, whenever what it
|
|
257
|
+
* is worked out from changes (a stay's cancel-by, from its arrival); a
|
|
258
|
+
* moment that cannot be found writes nothing (empty).
|
|
235
259
|
*/
|
|
236
260
|
export const stampSetSchema = z.union([
|
|
237
261
|
z.enum(['now', 'today', 'user-name', 'user-id']),
|
|
@@ -270,13 +294,65 @@ export const stampSetSchema = z.union([
|
|
|
270
294
|
.strict(),
|
|
271
295
|
})
|
|
272
296
|
.strict(),
|
|
297
|
+
z.object({ addMinutes: addMinutesSchema }).strict(),
|
|
298
|
+
z.object({ deadline: deadlineSchema }).strict(),
|
|
299
|
+
z.object({ moment: momentSchema }).strict(),
|
|
273
300
|
]);
|
|
274
|
-
/**
|
|
301
|
+
/**
|
|
302
|
+
* When a stamp is written: on create, when a column changes to a value, when
|
|
303
|
+
* a column is first filled, or whenever one of `columns` changes (a create
|
|
304
|
+
* sets them all).
|
|
305
|
+
*/
|
|
275
306
|
export const stampTriggerSchema = z.union([
|
|
276
307
|
z.literal('create'),
|
|
277
308
|
z.object({ column: refSchema, values: z.array(scalarSchema).min(1).max(16) }).strict(),
|
|
278
309
|
z.object({ column: refSchema, filled: z.literal(true) }).strict(),
|
|
310
|
+
z.object({ columns: z.array(refSchema).min(1).max(8) }).strict(),
|
|
279
311
|
]);
|
|
312
|
+
/** What renews a code: a change of a column, or a column moving to one of `values`. */
|
|
313
|
+
export const codeRenewTriggerSchema = z.union([
|
|
314
|
+
z.object({ column: refSchema, changed: z.literal(true) }).strict(),
|
|
315
|
+
z.object({ column: refSchema, values: z.array(scalarSchema).min(1).max(16) }).strict(),
|
|
316
|
+
]);
|
|
317
|
+
/**
|
|
318
|
+
* A price worked out night by night (`rules.perNight`): for each night from
|
|
319
|
+
* `from` up to the day before `to`, the base rate read from the row `rate.via`
|
|
320
|
+
* points at, plus every adjustment row that matches that night (a weekend, a
|
|
321
|
+
* season). Each night is rounded, and the column holds their sum.
|
|
322
|
+
*/
|
|
323
|
+
export const perNightSchema = z
|
|
324
|
+
.object({
|
|
325
|
+
/** Date columns of this row: the first night, and the day after the last. */
|
|
326
|
+
from: refSchema,
|
|
327
|
+
to: refSchema,
|
|
328
|
+
/** A foreign key of this row, and the number column of the row it points at. */
|
|
329
|
+
rate: z.object({ via: refSchema, column: refSchema }).strict(),
|
|
330
|
+
adjust: z
|
|
331
|
+
.object({
|
|
332
|
+
/** The table of adjustments. */
|
|
333
|
+
table: refSchema,
|
|
334
|
+
match: z
|
|
335
|
+
.object({
|
|
336
|
+
/** A foreign key of the adjustments to what `rate.via` points at; empty = every one. */
|
|
337
|
+
via: refSchema.optional(),
|
|
338
|
+
/** A text column listing the nights it applies on (`fri,sat`); empty = every night. */
|
|
339
|
+
weekdays: refSchema.optional(),
|
|
340
|
+
/** Date columns: the first and the last night it applies on, both included; empty = open. */
|
|
341
|
+
from: refSchema.optional(),
|
|
342
|
+
to: refSchema.optional(),
|
|
343
|
+
})
|
|
344
|
+
.strict(),
|
|
345
|
+
/** The number added to a night (negative for a discount). */
|
|
346
|
+
add: refSchema,
|
|
347
|
+
/** The text a night's line is tagged with. */
|
|
348
|
+
name: refSchema,
|
|
349
|
+
/** Only the adjustment rows whose column equals the value (`active = true`). */
|
|
350
|
+
where: z.object({ column: refSchema, eq: scalarSchema }).strict().optional(),
|
|
351
|
+
})
|
|
352
|
+
.strict()
|
|
353
|
+
.optional(),
|
|
354
|
+
})
|
|
355
|
+
.strict();
|
|
280
356
|
export const columnRulesSchema = z
|
|
281
357
|
.object({
|
|
282
358
|
options: z
|
|
@@ -321,8 +397,12 @@ export const columnRulesSchema = z
|
|
|
321
397
|
})
|
|
322
398
|
.strict()
|
|
323
399
|
.optional(),
|
|
400
|
+
/**
|
|
401
|
+
* A value copied from the row `via` points at. `follow`: kept in step
|
|
402
|
+
* when that row's column changes later (an extra's nights follow its stay's).
|
|
403
|
+
*/
|
|
324
404
|
copy: z
|
|
325
|
-
.object({ via: refSchema, from: refSchema, mode: z.enum(['default', 'always']).optional() })
|
|
405
|
+
.object({ via: refSchema, from: refSchema, mode: z.enum(['default', 'always']).optional(), follow: z.literal(true).optional() })
|
|
326
406
|
.strict()
|
|
327
407
|
.optional(),
|
|
328
408
|
/**
|
|
@@ -367,23 +447,53 @@ export const columnRulesSchema = z
|
|
|
367
447
|
.object({
|
|
368
448
|
prefix: z.string().regex(/^[A-Z][A-Z0-9]{0,5}-?$/, 'an upper-case prefix, e.g. MR-').optional(),
|
|
369
449
|
length: z.number().int().min(4).max(16),
|
|
450
|
+
/**
|
|
451
|
+
* A new code in the same write when a column changes (a ticket sent
|
|
452
|
+
* to another address), or moves to one of `values` (a transfer
|
|
453
|
+
* accepted): the old code stops working as the write commits.
|
|
454
|
+
*/
|
|
455
|
+
renew: z.object({ on: z.union([codeRenewTriggerSchema, z.array(codeRenewTriggerSchema).min(2).max(3)]) }).strict().optional(),
|
|
370
456
|
})
|
|
371
457
|
.strict()
|
|
372
458
|
.optional(),
|
|
373
459
|
/** A value Adminium works out from the row's other columns (see `formula.ts`). */
|
|
374
460
|
formula: formulaExprSchema.optional(),
|
|
461
|
+
/** A price worked out night by night (see `perNightSchema`). */
|
|
462
|
+
perNight: perNightSchema.optional(),
|
|
375
463
|
/**
|
|
376
464
|
* How a text value is stored whoever writes it: `trim` without spaces at
|
|
377
465
|
* either end, `email` trimmed and in lower case — so a unique address and
|
|
378
466
|
* a person signing in with it agree on every database.
|
|
379
467
|
*/
|
|
380
|
-
normalize: z.enum(['trim', 'email']).optional(),
|
|
468
|
+
normalize: z.enum(['trim', 'email', 'code']).optional(),
|
|
469
|
+
/**
|
|
470
|
+
* A foreign key filled from a code a person types into `from`: the one
|
|
471
|
+
* row of `table` whose `column` holds it, compared as a code (upper
|
|
472
|
+
* case, spaces and dashes left out), among the rows `where` allows and,
|
|
473
|
+
* with `scope`, the ones for the same parent as this row.
|
|
474
|
+
*/
|
|
475
|
+
lookup: z
|
|
476
|
+
.object({
|
|
477
|
+
from: refSchema,
|
|
478
|
+
table: refSchema,
|
|
479
|
+
column: refSchema,
|
|
480
|
+
where: codeWhereSchema.optional(),
|
|
481
|
+
scope: z
|
|
482
|
+
.array(z.object({ column: refSchema, equals: refSchema, orEmpty: z.literal(true).optional() }).strict())
|
|
483
|
+
.min(1)
|
|
484
|
+
.max(2)
|
|
485
|
+
.optional(),
|
|
486
|
+
})
|
|
487
|
+
.strict()
|
|
488
|
+
.optional(),
|
|
381
489
|
rollup: z
|
|
382
490
|
.object({
|
|
383
491
|
/** The child table, its foreign key back to this row, and what to add up. */
|
|
384
492
|
from: refSchema,
|
|
385
493
|
via: refSchema,
|
|
386
|
-
|
|
494
|
+
/** The child column to add up; or, instead, `count` the child rows. */
|
|
495
|
+
sum: refSchema.optional(),
|
|
496
|
+
count: z.literal(true).optional(),
|
|
387
497
|
/** Multiplied into `sum` per child row, e.g. `qty`. */
|
|
388
498
|
times: refSchema.optional(),
|
|
389
499
|
/** A child row whose column holds a value is left out — a voided line (`voided_at`). */
|
|
@@ -414,6 +524,12 @@ export const columnRulesSchema = z
|
|
|
414
524
|
.object({
|
|
415
525
|
set: stampSetSchema,
|
|
416
526
|
on: z.union([stampTriggerSchema, z.array(stampTriggerSchema).min(2).max(3)]),
|
|
527
|
+
/**
|
|
528
|
+
* Emptied again when a move marked `undo` takes the row back out of a
|
|
529
|
+
* state the stamp watches (the time an order was marked ready, when
|
|
530
|
+
* the kitchen undoes the Ready).
|
|
531
|
+
*/
|
|
532
|
+
clearOnBack: z.literal(true).optional(),
|
|
417
533
|
})
|
|
418
534
|
.strict()
|
|
419
535
|
.optional(),
|
|
@@ -447,29 +563,10 @@ export const columnRulesSchema = z
|
|
|
447
563
|
})
|
|
448
564
|
.strict();
|
|
449
565
|
/**
|
|
450
|
-
* A limit on how much of a
|
|
451
|
-
*
|
|
452
|
-
* holds no seats).
|
|
566
|
+
* A limit on how much of a pool rows may take — the booking guard. One rule
|
|
567
|
+
* or a list; slot, parent and night kinds (see `capacity.ts`).
|
|
453
568
|
*/
|
|
454
|
-
export
|
|
455
|
-
.object({
|
|
456
|
-
slot: refSchema,
|
|
457
|
-
amount: refSchema,
|
|
458
|
-
perSlot: numberOrSetting,
|
|
459
|
-
countWhere: z.object({ column: refSchema, values: z.array(z.string().min(1)).min(1) }).strict().optional(),
|
|
460
|
-
slotMinutes: numberOrSetting,
|
|
461
|
-
windowDays: numberOrSetting.optional(),
|
|
462
|
-
opens: z.union([z.string().regex(/^\d{2}:\d{2}$/), z.object({ table: refSchema, column: refSchema }).strict()]).optional(),
|
|
463
|
-
closes: z.union([z.string().regex(/^\d{2}:\d{2}$/), z.object({ table: refSchema, column: refSchema }).strict()]).optional(),
|
|
464
|
-
/** A table, a room: the limit applies per value of this column too. */
|
|
465
|
-
resource: refSchema.optional(),
|
|
466
|
-
/**
|
|
467
|
-
* How many hours before its time a guest may still cancel through the
|
|
468
|
-
* public API; later, only the venue can. Staff are never held to it.
|
|
469
|
-
*/
|
|
470
|
-
cancelHours: numberOrSetting.optional(),
|
|
471
|
-
})
|
|
472
|
-
.strict();
|
|
569
|
+
export { capacitySchema };
|
|
473
570
|
/** The longest `maxLength` a text column may ask for (see `maxLength` below). */
|
|
474
571
|
export const MAX_TEXT_LENGTH = 1000;
|
|
475
572
|
/**
|
|
@@ -561,6 +658,12 @@ export const requiredColumnSchema = z
|
|
|
561
658
|
* column needs `maxLength`: MySQL indexes no unbounded text.
|
|
562
659
|
*/
|
|
563
660
|
unique: z.literal(true).optional(),
|
|
661
|
+
/**
|
|
662
|
+
* A plain index on a foreign key a limit or a total counts by (a
|
|
663
|
+
* capacity's `via`, a rollup's `via`), so the count under the limit's
|
|
664
|
+
* lock reads the rows it needs and not the whole table.
|
|
665
|
+
*/
|
|
666
|
+
index: z.literal(true).optional(),
|
|
564
667
|
/** Rules Adminium keeps on the column once installed (see `columnRulesSchema`). */
|
|
565
668
|
rules: columnRulesSchema.optional(),
|
|
566
669
|
/**
|
|
@@ -633,6 +736,12 @@ export const requiredTableSchema = z
|
|
|
633
736
|
labelPlural: textOrLabels.optional(),
|
|
634
737
|
/** The column that names a row wherever another table links to it (a category's `name`). */
|
|
635
738
|
keyField: z.string().regex(/^[a-z][a-z0-9_]*$/, 'a column ref').optional(),
|
|
739
|
+
/**
|
|
740
|
+
* Sets of 2 to 4 columns no two rows may hold the same values in together
|
|
741
|
+
* (one waitlist entry per show per address). A row with any of them empty
|
|
742
|
+
* never collides.
|
|
743
|
+
*/
|
|
744
|
+
unique: z.array(z.array(refSchema).min(2).max(4)).min(1).max(8).optional(),
|
|
636
745
|
})
|
|
637
746
|
.strict()
|
|
638
747
|
.refine((t) => new Set(t.columns.map((c) => c.ref)).size === t.columns.length, { message: 'duplicate column ref in table', path: ['columns'] })
|
|
@@ -655,7 +764,50 @@ export const requiredTableSchema = z
|
|
|
655
764
|
.refine((t) => t.builtOn === undefined || t.shape === undefined, {
|
|
656
765
|
message: 'a table is built on an add-on\'s shape or shared under a shape, not both',
|
|
657
766
|
path: ['builtOn'],
|
|
767
|
+
})
|
|
768
|
+
.superRefine((t, ctx) => {
|
|
769
|
+
for (const issue of uniqueSetIssues(t))
|
|
770
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
658
771
|
});
|
|
772
|
+
/**
|
|
773
|
+
* Everything wrong with a table's unique sets: a column it lacks, a column
|
|
774
|
+
* named twice, a set given twice, a column no index can hold, a set its own
|
|
775
|
+
* running number already keeps unique, and a set on a table built on an
|
|
776
|
+
* add-on's shape (the add-on's own writes could break it).
|
|
777
|
+
*/
|
|
778
|
+
function uniqueSetIssues(t) {
|
|
779
|
+
const out = [];
|
|
780
|
+
const seen = new Set();
|
|
781
|
+
(t.unique ?? []).forEach((set, k) => {
|
|
782
|
+
const path = ['unique', k];
|
|
783
|
+
if (t.builtOn !== undefined)
|
|
784
|
+
out.push({ path, message: "a table built on an add-on's shape keeps the shape's rules, and adds no unique set" });
|
|
785
|
+
if (new Set(set).size !== set.length)
|
|
786
|
+
out.push({ path, message: 'a unique set names each column once' });
|
|
787
|
+
const key = [...new Set(set)].sort().join('\u0000');
|
|
788
|
+
if (seen.has(key))
|
|
789
|
+
out.push({ path, message: 'the same columns are unique twice' });
|
|
790
|
+
seen.add(key);
|
|
791
|
+
for (const ref of set) {
|
|
792
|
+
const column = t.columns.find((c) => c.ref === ref);
|
|
793
|
+
if (column === undefined) {
|
|
794
|
+
out.push({ path, message: `no column "${ref}" to be unique with` });
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
const indexable = column.type !== 'json' && column.type !== 'blob' && (column.type !== 'text' || column.maxLength !== undefined || column.rules?.code !== undefined);
|
|
798
|
+
if (!indexable)
|
|
799
|
+
out.push({ path, message: `unique needs columns that can be indexed: not json or blob, text with maxLength ("${ref}")` });
|
|
800
|
+
}
|
|
801
|
+
// A running number counted per parent is unique with its parent already.
|
|
802
|
+
const numbered = t.columns.some((c) => {
|
|
803
|
+
const sequence = c.rules?.sequence;
|
|
804
|
+
return sequence?.gapless === true && sequence.scope !== undefined && [sequence.scope, c.ref].sort().join('\u0000') === key;
|
|
805
|
+
});
|
|
806
|
+
if (numbered)
|
|
807
|
+
out.push({ path, message: 'already unique by its number rule' });
|
|
808
|
+
});
|
|
809
|
+
return out;
|
|
810
|
+
}
|
|
659
811
|
export const requiredSchemaSchema = z
|
|
660
812
|
.object({
|
|
661
813
|
tables: z.array(requiredTableSchema).min(1),
|
|
@@ -949,6 +1101,19 @@ export { publicAccessSchema, publicKeysSchema };
|
|
|
949
1101
|
export const sampleDataSchema = z
|
|
950
1102
|
.object({
|
|
951
1103
|
file: z.string().regex(/^seeds\/[a-z0-9][a-z0-9._-]*\.json$/, 'a file in seeds/, ending .json'),
|
|
1104
|
+
/**
|
|
1105
|
+
* A table shared with another app (one with a `shape`) that already holds
|
|
1106
|
+
* real rows — rows no installed app's sample data added — leaves these
|
|
1107
|
+
* tables' sample rows out, so a venue's real menu never gains sample
|
|
1108
|
+
* dishes (nor sample orders of them).
|
|
1109
|
+
*/
|
|
1110
|
+
skipWhenShared: z
|
|
1111
|
+
.object({
|
|
1112
|
+
table: z.string().min(1).max(64),
|
|
1113
|
+
skip: z.array(z.string().min(1).max(64)).min(1).max(50),
|
|
1114
|
+
})
|
|
1115
|
+
.strict()
|
|
1116
|
+
.optional(),
|
|
952
1117
|
})
|
|
953
1118
|
.strict();
|
|
954
1119
|
/** Hosted-only and offline-required are mutually exclusive. */
|
|
@@ -992,6 +1157,359 @@ export function prefixFor(appKey) {
|
|
|
992
1157
|
}
|
|
993
1158
|
/** The longest real table name every engine accepts (Postgres: 63 bytes). */
|
|
994
1159
|
export const MAX_TABLE_NAME = 63;
|
|
1160
|
+
/** Column types a count is kept in: a whole number. */
|
|
1161
|
+
const WHOLE_TYPES = ['int', 'bigint'];
|
|
1162
|
+
/**
|
|
1163
|
+
* A total adds a column up (`sum`) or counts the rows (`count: true`), and a
|
|
1164
|
+
* count is a whole number with nothing multiplied into it and no balance.
|
|
1165
|
+
*/
|
|
1166
|
+
function rollupCountIssues(r, type, here) {
|
|
1167
|
+
const out = [];
|
|
1168
|
+
if ((r.sum === undefined) === (r.count === undefined)) {
|
|
1169
|
+
out.push({ path: here('rollup'), message: 'a total adds up `sum` or counts rows (`count: true`), not both' });
|
|
1170
|
+
}
|
|
1171
|
+
if (r.count === true) {
|
|
1172
|
+
if (r.times !== undefined || r.balance !== undefined || r.cap !== undefined) {
|
|
1173
|
+
out.push({ path: here('rollup', 'count'), message: 'a count takes no `times`, `balance` or `cap`' });
|
|
1174
|
+
}
|
|
1175
|
+
if (!WHOLE_TYPES.includes(type))
|
|
1176
|
+
out.push({ path: here('rollup', 'count'), message: 'a count is kept in a whole-number column (`int` or `bigint`)' });
|
|
1177
|
+
}
|
|
1178
|
+
return out;
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* The columns of a table a total decides, and for each the totals it comes
|
|
1182
|
+
* from: a rollup column, a rollup's balance, and a formula that reads one of
|
|
1183
|
+
* them (however many formulas deep).
|
|
1184
|
+
*/
|
|
1185
|
+
function totalsOf(table) {
|
|
1186
|
+
const out = new Map();
|
|
1187
|
+
for (const column of table.columns) {
|
|
1188
|
+
const r = column.rules?.rollup;
|
|
1189
|
+
if (r === undefined)
|
|
1190
|
+
continue;
|
|
1191
|
+
out.set(column.ref, new Set([column.ref]));
|
|
1192
|
+
if (r.balance !== undefined)
|
|
1193
|
+
out.set(r.balance.column, new Set([column.ref]));
|
|
1194
|
+
}
|
|
1195
|
+
const formulas = table.columns.filter((column) => column.rules?.formula !== undefined);
|
|
1196
|
+
for (let changed = true; changed;) {
|
|
1197
|
+
changed = false;
|
|
1198
|
+
for (const column of formulas) {
|
|
1199
|
+
const from = new Set(out.get(column.ref) ?? []);
|
|
1200
|
+
for (const input of formulaColumns(column.rules.formula))
|
|
1201
|
+
for (const total of out.get(input) ?? [])
|
|
1202
|
+
from.add(total);
|
|
1203
|
+
if (from.size > (out.get(column.ref)?.size ?? 0)) {
|
|
1204
|
+
out.set(column.ref, from);
|
|
1205
|
+
changed = true;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
return out;
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Totals that add up other totals (an option's price into its line, the
|
|
1213
|
+
* line into its order, the order into its customer) climb at most three
|
|
1214
|
+
* tables, and never in a circle: a total whose child table is its own, or
|
|
1215
|
+
* two tables adding each other up.
|
|
1216
|
+
*/
|
|
1217
|
+
function rollupChainIssues(tables) {
|
|
1218
|
+
const out = [];
|
|
1219
|
+
const byRef = new Map(tables.map((t) => [t.ref, t]));
|
|
1220
|
+
const totals = new Map(tables.map((t) => [t.ref, totalsOf(t)]));
|
|
1221
|
+
/** Each rollup, by `table.column`: its path, its child table, and the rollups of the child it reads. */
|
|
1222
|
+
const nodes = new Map();
|
|
1223
|
+
tables.forEach((table, t) => {
|
|
1224
|
+
table.columns.forEach((column, c) => {
|
|
1225
|
+
const r = column.rules?.rollup;
|
|
1226
|
+
if (r === undefined)
|
|
1227
|
+
return;
|
|
1228
|
+
const read = [r.sum, r.times, r.unlessSet, r.where?.column].filter((ref) => ref !== undefined);
|
|
1229
|
+
const childTotals = totals.get(r.from) ?? new Map();
|
|
1230
|
+
const reads = new Set();
|
|
1231
|
+
for (const ref of read)
|
|
1232
|
+
for (const total of childTotals.get(ref) ?? [])
|
|
1233
|
+
reads.add(`${r.from}.${total}`);
|
|
1234
|
+
nodes.set(`${table.ref}.${column.ref}`, {
|
|
1235
|
+
path: ['requiredSchema', 'tables', t, 'columns', c, 'rules', 'rollup'],
|
|
1236
|
+
table: table.ref,
|
|
1237
|
+
child: r.from,
|
|
1238
|
+
reads: byRef.has(r.from) ? [...reads] : [],
|
|
1239
|
+
});
|
|
1240
|
+
});
|
|
1241
|
+
});
|
|
1242
|
+
/** The longest chain of tables below a rollup, bottom first, or null for a circle. */
|
|
1243
|
+
const chains = new Map();
|
|
1244
|
+
const chainOf = (key, seen) => {
|
|
1245
|
+
if (chains.has(key))
|
|
1246
|
+
return chains.get(key);
|
|
1247
|
+
const node = nodes.get(key);
|
|
1248
|
+
if (seen.has(key) || node.child === node.table)
|
|
1249
|
+
return null;
|
|
1250
|
+
let longest = [node.child];
|
|
1251
|
+
for (const next of node.reads) {
|
|
1252
|
+
const below = chainOf(next, new Set([...seen, key]));
|
|
1253
|
+
if (below === null) {
|
|
1254
|
+
chains.set(key, null);
|
|
1255
|
+
return null;
|
|
1256
|
+
}
|
|
1257
|
+
if (below.length > longest.length)
|
|
1258
|
+
longest = below;
|
|
1259
|
+
}
|
|
1260
|
+
const chain = [...longest, node.table];
|
|
1261
|
+
chains.set(key, chain);
|
|
1262
|
+
return chain;
|
|
1263
|
+
};
|
|
1264
|
+
for (const [key, node] of nodes) {
|
|
1265
|
+
const chain = chainOf(key, new Set());
|
|
1266
|
+
if (chain === null) {
|
|
1267
|
+
out.push({ path: node.path, message: `totals climb in a circle: ${node.child} → ${node.table}${node.child === node.table ? '' : ' → …'}` });
|
|
1268
|
+
}
|
|
1269
|
+
else if (chain.length - 1 > 3) {
|
|
1270
|
+
out.push({ path: node.path, message: `totals climb at most three tables: ${chain.join(' → ')}` });
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return out;
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* A copy that follows its row (`follow`): it always wins; it follows one
|
|
1277
|
+
* level (the column it copies is not itself following another row); the row
|
|
1278
|
+
* it follows is not worked out from this table's own totals (a loop); and
|
|
1279
|
+
* this table adds up into that row alone, so every write takes its rows in
|
|
1280
|
+
* one order.
|
|
1281
|
+
*/
|
|
1282
|
+
function followIssues(tables, table, own, copy, here) {
|
|
1283
|
+
const out = [];
|
|
1284
|
+
if (copy.mode !== 'always')
|
|
1285
|
+
out.push({ path: here('copy', 'mode'), message: 'a copy that follows its row always wins (`mode: "always"`)' });
|
|
1286
|
+
const parentRef = table.columns.find((c) => c.ref === copy.via)?.references;
|
|
1287
|
+
const parent = tables.find((t) => t.ref === parentRef);
|
|
1288
|
+
if (parent === undefined)
|
|
1289
|
+
return out;
|
|
1290
|
+
const source = parent.columns.find((c) => c.ref === copy.from);
|
|
1291
|
+
if (source?.rules?.copy?.follow === true)
|
|
1292
|
+
out.push({ path: here('copy', 'follow'), message: 'a copy follows one level' });
|
|
1293
|
+
// Everything the parent's column is worked out from, on the parent.
|
|
1294
|
+
const inputs = new Set([copy.from]);
|
|
1295
|
+
for (let changed = true; changed;) {
|
|
1296
|
+
changed = false;
|
|
1297
|
+
for (const ref of [...inputs]) {
|
|
1298
|
+
const rules = parent.columns.find((c) => c.ref === ref)?.rules;
|
|
1299
|
+
const more = [
|
|
1300
|
+
...(rules?.formula === undefined ? [] : formulaColumns(rules.formula)),
|
|
1301
|
+
...(rules?.perNight === undefined ? [] : [rules.perNight.from, rules.perNight.to, rules.perNight.rate.via]),
|
|
1302
|
+
];
|
|
1303
|
+
for (const next of more)
|
|
1304
|
+
if (!inputs.has(next)) {
|
|
1305
|
+
inputs.add(next);
|
|
1306
|
+
changed = true;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
// What this table's rows move on the parent: its totals, their balances, the formulas over them.
|
|
1311
|
+
const moved = new Set();
|
|
1312
|
+
const parentTotals = totalsOf(parent);
|
|
1313
|
+
for (const column of parent.columns) {
|
|
1314
|
+
if (column.rules?.rollup?.from !== table.ref)
|
|
1315
|
+
continue;
|
|
1316
|
+
for (const [ref, from] of parentTotals)
|
|
1317
|
+
if (from.has(column.ref))
|
|
1318
|
+
moved.add(ref);
|
|
1319
|
+
}
|
|
1320
|
+
const loop = [...inputs].find((ref) => moved.has(ref));
|
|
1321
|
+
if (loop !== undefined) {
|
|
1322
|
+
out.push({ path: here('copy', 'follow'), message: `"${parent.ref}.${copy.from}" is worked out from "${table.ref}", so "${table.ref}.${own}" cannot follow it` });
|
|
1323
|
+
}
|
|
1324
|
+
else {
|
|
1325
|
+
// A total, a balance or a stamp changes by a settle or a move, never by a change of the row the copies follow:
|
|
1326
|
+
// a copy of one — or of a formula over one — would keep the value it had.
|
|
1327
|
+
const balances = new Set(parent.columns.flatMap((c) => (c.rules?.rollup?.balance === undefined ? [] : [c.rules.rollup.balance.column])));
|
|
1328
|
+
const settled = [...inputs].find((ref) => {
|
|
1329
|
+
const rules = parent.columns.find((c) => c.ref === ref)?.rules;
|
|
1330
|
+
return rules?.rollup !== undefined || rules?.stamp !== undefined || balances.has(ref);
|
|
1331
|
+
});
|
|
1332
|
+
if (settled !== undefined) {
|
|
1333
|
+
const kind = balances.has(settled) ? 'a balance' : parent.columns.find((c) => c.ref === settled)?.rules?.rollup !== undefined ? 'a total' : 'a stamp';
|
|
1334
|
+
const over = settled === copy.from ? '' : ` (it is worked out from "${settled}", ${kind})`;
|
|
1335
|
+
out.push({
|
|
1336
|
+
path: here('copy', 'follow'),
|
|
1337
|
+
message: `"${parent.ref}.${copy.from}"${over === '' ? ` is ${kind}` : over}, which changes without a change of the row, so "${table.ref}.${own}" cannot follow it`,
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
// A follow writes the copy and the formulas over it, and settles the totals into the row it follows only:
|
|
1342
|
+
// a total another table keeps of these rows must read none of what it writes, or that total would go stale.
|
|
1343
|
+
const written = new Set([own]);
|
|
1344
|
+
for (let changed = true; changed;) {
|
|
1345
|
+
changed = false;
|
|
1346
|
+
for (const column of table.columns) {
|
|
1347
|
+
const formula = column.rules?.formula;
|
|
1348
|
+
if (formula === undefined || written.has(column.ref))
|
|
1349
|
+
continue;
|
|
1350
|
+
if (formulaColumns(formula).some((input) => written.has(input))) {
|
|
1351
|
+
written.add(column.ref);
|
|
1352
|
+
changed = true;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
for (const other of tables) {
|
|
1357
|
+
if (other.ref === parent.ref)
|
|
1358
|
+
continue;
|
|
1359
|
+
const reads = other.columns.some((c) => {
|
|
1360
|
+
const rollup = c.rules?.rollup;
|
|
1361
|
+
if (rollup?.from !== table.ref)
|
|
1362
|
+
return false;
|
|
1363
|
+
// The link a total groups its rows by counts too: a copy that moves a row to another parent moves it between that table's totals.
|
|
1364
|
+
return [rollup.via, rollup.sum, rollup.times, rollup.unlessSet, rollup.where?.column].some((ref) => ref !== undefined && written.has(ref));
|
|
1365
|
+
});
|
|
1366
|
+
if (reads) {
|
|
1367
|
+
out.push({ path: here('copy', 'follow'), message: `"${table.ref}" totals into "${other.ref}" too, so its copies cannot follow "${parent.ref}": a total "${other.ref}" keeps reads what the follow writes` });
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
return out;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* The columns a money figure Adminium works out reads, per table: a
|
|
1374
|
+
* formula's inputs, what a total adds up, and a column copied into one of
|
|
1375
|
+
* those (a stay's guests into each extra's). What a limit counts is not money
|
|
1376
|
+
* and is left out.
|
|
1377
|
+
*/
|
|
1378
|
+
function figureColumns(tables) {
|
|
1379
|
+
const out = new Map(tables.map((t) => [t.ref, new Set()]));
|
|
1380
|
+
const mark = (table, column) => {
|
|
1381
|
+
const set = out.get(table);
|
|
1382
|
+
if (set === undefined || set.has(column))
|
|
1383
|
+
return false;
|
|
1384
|
+
set.add(column);
|
|
1385
|
+
return true;
|
|
1386
|
+
};
|
|
1387
|
+
for (const table of tables) {
|
|
1388
|
+
for (const column of table.columns) {
|
|
1389
|
+
const rules = column.rules;
|
|
1390
|
+
if (rules?.formula !== undefined)
|
|
1391
|
+
for (const input of formulaColumns(rules.formula))
|
|
1392
|
+
mark(table.ref, input);
|
|
1393
|
+
const r = rules?.rollup;
|
|
1394
|
+
if (r !== undefined)
|
|
1395
|
+
for (const input of [r.sum, r.times])
|
|
1396
|
+
if (input !== undefined)
|
|
1397
|
+
mark(r.from, input);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
for (let changed = true; changed;) {
|
|
1401
|
+
changed = false;
|
|
1402
|
+
for (const table of tables) {
|
|
1403
|
+
for (const column of table.columns) {
|
|
1404
|
+
const copy = column.rules?.copy;
|
|
1405
|
+
if (copy === undefined || !out.get(table.ref)?.has(column.ref))
|
|
1406
|
+
continue;
|
|
1407
|
+
const source = table.columns.find((c) => c.ref === copy.via)?.references;
|
|
1408
|
+
if (source !== undefined && mark(source, copy.from))
|
|
1409
|
+
changed = true;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
return (table) => out.get(table) ?? new Set();
|
|
1414
|
+
}
|
|
1415
|
+
/** A price by the night: its dates, its rate, and the adjustments it reads. */
|
|
1416
|
+
function perNightIssues(index, table, column, rule, keptFromReaders, here) {
|
|
1417
|
+
const out = [];
|
|
1418
|
+
const at = (...rest) => here('perNight', ...rest);
|
|
1419
|
+
if (!['decimal', 'money', 'int', 'bigint'].includes(column.type)) {
|
|
1420
|
+
out.push({ path: at(), message: 'a price by the night is kept in a decimal, money or whole-number column' });
|
|
1421
|
+
}
|
|
1422
|
+
const first = table.columns.find((c) => c.rules?.perNight !== undefined);
|
|
1423
|
+
if (first !== undefined && first.ref !== column.ref)
|
|
1424
|
+
out.push({ path: at(), message: `"${table.ref}" is already priced by the night in "${first.ref}"; a table has one such price` });
|
|
1425
|
+
for (const name of ['from', 'to']) {
|
|
1426
|
+
const found = index.column(table.ref, rule[name]);
|
|
1427
|
+
if (found === undefined)
|
|
1428
|
+
out.push({ path: at(name), message: `"${table.ref}" has no column "${rule[name]}"` });
|
|
1429
|
+
else if (found.type !== 'date')
|
|
1430
|
+
out.push({ path: at(name), message: `"${table.ref}.${rule[name]}" is not a date` });
|
|
1431
|
+
}
|
|
1432
|
+
if (rule.from === rule.to)
|
|
1433
|
+
out.push({ path: at('to'), message: 'the nights run between two different dates' });
|
|
1434
|
+
const via = index.column(table.ref, rule.rate.via);
|
|
1435
|
+
const target = via?.type === 'fk' ? via.references : undefined;
|
|
1436
|
+
if (target === undefined) {
|
|
1437
|
+
out.push({ path: at('rate', 'via'), message: `"${rule.rate.via}" is not a foreign key of "${table.ref}"` });
|
|
1438
|
+
}
|
|
1439
|
+
else {
|
|
1440
|
+
const rate = index.column(target, rule.rate.column);
|
|
1441
|
+
if (rate === undefined)
|
|
1442
|
+
out.push({ path: at('rate', 'column'), message: `"${target}" has no column "${rule.rate.column}"` });
|
|
1443
|
+
else if (!NUMERIC_TYPES.includes(rate.type) || rate.type === 'float')
|
|
1444
|
+
out.push({ path: at('rate', 'column'), message: `"${target}.${rule.rate.column}" is not a price` });
|
|
1445
|
+
else {
|
|
1446
|
+
const kept = keptFromReaders(target, rule.rate.column, column.rules, { table: table.ref, column: column.ref });
|
|
1447
|
+
if (kept !== null)
|
|
1448
|
+
out.push({ path: at('rate', 'column'), message: `"${target}.${rule.rate.column}" is ${kept}, so no price reads it` });
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const adjust = rule.adjust;
|
|
1452
|
+
if (adjust === undefined)
|
|
1453
|
+
return out;
|
|
1454
|
+
if (index.table(adjust.table) === undefined) {
|
|
1455
|
+
out.push({ path: at('adjust', 'table'), message: `"${adjust.table}" is not a table of this app` });
|
|
1456
|
+
return out;
|
|
1457
|
+
}
|
|
1458
|
+
const own = (name) => index.column(adjust.table, name);
|
|
1459
|
+
const add = own(adjust.add);
|
|
1460
|
+
if (add === undefined)
|
|
1461
|
+
out.push({ path: at('adjust', 'add'), message: `"${adjust.table}" has no column "${adjust.add}"` });
|
|
1462
|
+
else if (!NUMERIC_TYPES.includes(add.type) || add.type === 'float')
|
|
1463
|
+
out.push({ path: at('adjust', 'add'), message: `"${adjust.table}.${adjust.add}" is not a price` });
|
|
1464
|
+
const name = own(adjust.name);
|
|
1465
|
+
if (name === undefined)
|
|
1466
|
+
out.push({ path: at('adjust', 'name'), message: `"${adjust.table}" has no column "${adjust.name}"` });
|
|
1467
|
+
else if (name.type !== 'text')
|
|
1468
|
+
out.push({ path: at('adjust', 'name'), message: `"${adjust.table}.${adjust.name}" is not a text column` });
|
|
1469
|
+
else {
|
|
1470
|
+
const kept = keptFromReaders(adjust.table, adjust.name, column.rules, { table: table.ref, column: column.ref });
|
|
1471
|
+
if (kept !== null)
|
|
1472
|
+
out.push({ path: at('adjust', 'name'), message: `"${adjust.table}.${adjust.name}" is ${kept}, so no night is tagged with it` });
|
|
1473
|
+
}
|
|
1474
|
+
const match = adjust.match;
|
|
1475
|
+
if (match.via !== undefined) {
|
|
1476
|
+
const link = own(match.via);
|
|
1477
|
+
if (link?.type !== 'fk' || link.references === undefined) {
|
|
1478
|
+
out.push({ path: at('adjust', 'match', 'via'), message: `"${match.via}" is not a foreign key of "${adjust.table}"` });
|
|
1479
|
+
}
|
|
1480
|
+
else if (target !== undefined && link.references !== target) {
|
|
1481
|
+
out.push({ path: at('adjust', 'match', 'via'), message: `"${adjust.table}.${match.via}" points at "${link.references}", and "${table.ref}.${rule.rate.via}" at "${target}"` });
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
if (match.weekdays !== undefined) {
|
|
1485
|
+
const weekdays = own(match.weekdays);
|
|
1486
|
+
if (weekdays === undefined)
|
|
1487
|
+
out.push({ path: at('adjust', 'match', 'weekdays'), message: `"${adjust.table}" has no column "${match.weekdays}"` });
|
|
1488
|
+
else if (weekdays.type !== 'text' || (weekdays.maxLength !== undefined && weekdays.maxLength < 27)) {
|
|
1489
|
+
out.push({ path: at('adjust', 'match', 'weekdays'), message: `"${adjust.table}.${match.weekdays}" lists nights like "mon,tue,wed,thu,fri,sat,sun": a text column of at least 27 characters` });
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
for (const bound of ['from', 'to']) {
|
|
1493
|
+
const ref = match[bound];
|
|
1494
|
+
if (ref === undefined)
|
|
1495
|
+
continue;
|
|
1496
|
+
const found = own(ref);
|
|
1497
|
+
if (found === undefined)
|
|
1498
|
+
out.push({ path: at('adjust', 'match', bound), message: `"${adjust.table}" has no column "${ref}"` });
|
|
1499
|
+
else if (found.type !== 'date')
|
|
1500
|
+
out.push({ path: at('adjust', 'match', bound), message: `"${adjust.table}.${ref}" is not a date` });
|
|
1501
|
+
}
|
|
1502
|
+
if (adjust.where !== undefined) {
|
|
1503
|
+
const filter = own(adjust.where.column);
|
|
1504
|
+
if (filter === undefined)
|
|
1505
|
+
out.push({ path: at('adjust', 'where'), message: `"${adjust.table}" has no column "${adjust.where.column}"` });
|
|
1506
|
+
else if (!valueFits(filter, adjust.where.eq))
|
|
1507
|
+
out.push({ path: at('adjust', 'where'), message: `${JSON.stringify(adjust.where.eq)} is not a value of "${adjust.table}.${adjust.where.column}"` });
|
|
1508
|
+
else if (filter.nullable === true)
|
|
1509
|
+
out.push({ path: at('adjust', 'where'), message: `"${adjust.table}.${adjust.where.column}" may be empty, so a row could drop out unseen; make it not nullable` });
|
|
1510
|
+
}
|
|
1511
|
+
return out;
|
|
1512
|
+
}
|
|
995
1513
|
/**
|
|
996
1514
|
* Every name a manifest's own blocks use must name something the manifest
|
|
997
1515
|
* declares: a rule's columns, a capacity's or a booking's columns and tables,
|
|
@@ -1034,16 +1552,27 @@ shapeOf) {
|
|
|
1034
1552
|
* over one, would show it where it is not kept, past the audit's and the
|
|
1035
1553
|
* outbox's redaction and the public refusals. A shared link's code is never
|
|
1036
1554
|
* landed anywhere.
|
|
1555
|
+
*
|
|
1556
|
+
* Personal data is told as the install tells it: by its mark, or guessed by
|
|
1557
|
+
* its name (`email`, a guest's `first_name` beside an address), and so is
|
|
1558
|
+
* the column it lands in when `intoAt` names it. Told by the mark alone, a
|
|
1559
|
+
* formula over a guessed column into an unmarked one passed here and was
|
|
1560
|
+
* then skipped by the install, leaving the column empty.
|
|
1037
1561
|
*/
|
|
1038
|
-
const keptFromReaders = (tableRef, columnRef, into) => {
|
|
1562
|
+
const keptFromReaders = (tableRef, columnRef, into, intoAt) => {
|
|
1039
1563
|
if (shareCodeColumns(m.publicAccess ?? [], tableRef).includes(columnRef))
|
|
1040
1564
|
return 'the code a shared link opens its row with';
|
|
1041
1565
|
const source = tables.get(tableRef)?.columns.find((x) => x.ref === columnRef)?.rules;
|
|
1042
1566
|
if (source?.secret === true && into?.secret !== true)
|
|
1043
1567
|
return 'a secret';
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1568
|
+
const personal = (t, c) => {
|
|
1569
|
+
const doc = tables.get(t);
|
|
1570
|
+
return doc !== undefined && personalColumn(doc, c) !== null;
|
|
1571
|
+
};
|
|
1572
|
+
if (!personal(tableRef, columnRef) || into?.secret === true)
|
|
1573
|
+
return null;
|
|
1574
|
+
const intoPersonal = intoAt === undefined ? into?.personal === true : personal(intoAt.table, intoAt.column);
|
|
1575
|
+
return intoPersonal ? null : 'personal data';
|
|
1047
1576
|
};
|
|
1048
1577
|
/** The tables the app's signed-in people are rows of (its claim identities). */
|
|
1049
1578
|
const identityTables = new Set((m.publicAccess ?? []).filter((e) => e.claim !== undefined).map((e) => e.table));
|
|
@@ -1070,7 +1599,7 @@ shapeOf) {
|
|
|
1070
1599
|
if (rules === undefined)
|
|
1071
1600
|
return;
|
|
1072
1601
|
const here = (...rest) => at('columns', c, 'rules', ...rest);
|
|
1073
|
-
const deciders = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default'].filter((name) => rules[name] !== undefined);
|
|
1602
|
+
const deciders = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup', 'perNight'].filter((name) => rules[name] !== undefined);
|
|
1074
1603
|
if (deciders.length > 0)
|
|
1075
1604
|
decide(table.ref, column.ref);
|
|
1076
1605
|
// One rule decides a column; two would race for its value. A copy with
|
|
@@ -1199,21 +1728,28 @@ shapeOf) {
|
|
|
1199
1728
|
out.push({ path: here('copy', 'from'), message: `"${via.references}" has no column "${rules.copy.from}"` });
|
|
1200
1729
|
}
|
|
1201
1730
|
else {
|
|
1202
|
-
const kept = keptFromReaders(via.references, rules.copy.from, column.rules);
|
|
1731
|
+
const kept = keptFromReaders(via.references, rules.copy.from, column.rules, { table: table.ref, column: column.ref });
|
|
1203
1732
|
if (kept !== null)
|
|
1204
1733
|
out.push({ path: here('copy', 'from'), message: `"${via.references}.${rules.copy.from}" is ${kept}, so no column copies it` });
|
|
1205
1734
|
}
|
|
1735
|
+
if (rules.copy.follow === true)
|
|
1736
|
+
out.push(...followIssues(m.requiredSchema.tables, table, column.ref, rules.copy, here));
|
|
1737
|
+
}
|
|
1738
|
+
if (rules.perNight !== undefined) {
|
|
1739
|
+
if (shapeOf !== undefined)
|
|
1740
|
+
out.push({ path: here('perNight'), message: 'a shape does not price by the night' });
|
|
1741
|
+
out.push(...perNightIssues(index, table, column, rules.perNight, keptFromReaders, here));
|
|
1206
1742
|
}
|
|
1207
1743
|
// The same of a stamp that copies a column of its row, and of a formula's inputs.
|
|
1208
1744
|
const stamped = rules.stamp?.set;
|
|
1209
1745
|
if (typeof stamped === 'object' && 'copy' in stamped) {
|
|
1210
|
-
const kept = keptFromReaders(table.ref, stamped.copy, column.rules);
|
|
1746
|
+
const kept = keptFromReaders(table.ref, stamped.copy, column.rules, { table: table.ref, column: column.ref });
|
|
1211
1747
|
if (kept !== null)
|
|
1212
1748
|
out.push({ path: here('stamp', 'set', 'copy'), message: `"${table.ref}.${stamped.copy}" is ${kept}, so no column copies it` });
|
|
1213
1749
|
}
|
|
1214
1750
|
if (rules.formula !== undefined) {
|
|
1215
1751
|
for (const input of formulaColumns(rules.formula)) {
|
|
1216
|
-
const kept = keptFromReaders(table.ref, input, column.rules);
|
|
1752
|
+
const kept = keptFromReaders(table.ref, input, column.rules, { table: table.ref, column: column.ref });
|
|
1217
1753
|
if (kept !== null)
|
|
1218
1754
|
out.push({ path: here('formula'), message: `"${table.ref}.${input}" is ${kept}, so no formula reads it` });
|
|
1219
1755
|
}
|
|
@@ -1229,7 +1765,7 @@ shapeOf) {
|
|
|
1229
1765
|
out.push({ path: here('rollup', 'via'), message: `"${r.from}.${r.via}" does not point at "${table.ref}"` });
|
|
1230
1766
|
}
|
|
1231
1767
|
else {
|
|
1232
|
-
for (const name of [r.sum, ...(r.times === undefined ? [] : [r.times]), ...(r.unlessSet === undefined ? [] : [r.unlessSet])]) {
|
|
1768
|
+
for (const name of [...(r.sum === undefined ? [] : [r.sum]), ...(r.times === undefined ? [] : [r.times]), ...(r.unlessSet === undefined ? [] : [r.unlessSet])]) {
|
|
1233
1769
|
if (!has(r.from, name))
|
|
1234
1770
|
out.push({ path: here('rollup'), message: `"${r.from}" has no column "${name}"` });
|
|
1235
1771
|
}
|
|
@@ -1268,6 +1804,7 @@ shapeOf) {
|
|
|
1268
1804
|
if (!capped)
|
|
1269
1805
|
out.push({ path: here('rollup', 'cap'), message: 'a cap needs a balance: declare one here, or subtract this total in one' });
|
|
1270
1806
|
}
|
|
1807
|
+
out.push(...rollupCountIssues(r, column.type, here));
|
|
1271
1808
|
}
|
|
1272
1809
|
if (rules.stamp !== undefined) {
|
|
1273
1810
|
const stamp = rules.stamp;
|
|
@@ -1363,11 +1900,68 @@ shapeOf) {
|
|
|
1363
1900
|
(link.children ?? []).forEach((child, c2) => childIssues(link.table, child, [...path, 'children', c2]));
|
|
1364
1901
|
});
|
|
1365
1902
|
}
|
|
1903
|
+
if (typeof set === 'object' && 'moment' in set) {
|
|
1904
|
+
if (column.type !== 'timestamptz')
|
|
1905
|
+
out.push({ path: here('stamp', 'set'), message: 'a stamped moment needs a timestamptz column' });
|
|
1906
|
+
out.push(...momentIssues(table.ref, set.moment, index, here('stamp', 'set', 'moment')));
|
|
1907
|
+
const read = [set.moment, ...(set.moment.or ?? [])].flatMap((m) => [m.via ?? m.column]);
|
|
1908
|
+
if (read.includes(column.ref))
|
|
1909
|
+
out.push({ path: here('stamp', 'set', 'moment'), message: 'a stamped moment is worked out from other columns, not its own' });
|
|
1910
|
+
}
|
|
1911
|
+
if (typeof set === 'object' && ('addMinutes' in set || 'deadline' in set)) {
|
|
1912
|
+
if (column.type !== 'timestamptz')
|
|
1913
|
+
out.push({ path: here('stamp', 'set'), message: 'a stamped moment needs a timestamptz column' });
|
|
1914
|
+
const whole = (amount, path) => {
|
|
1915
|
+
if (typeof amount !== 'object' || amount === null)
|
|
1916
|
+
return;
|
|
1917
|
+
const setting = amount;
|
|
1918
|
+
const found = index.column(setting.table, setting.column);
|
|
1919
|
+
if (index.table(setting.table) === undefined)
|
|
1920
|
+
out.push({ path, message: `"${setting.table}" is not a table of this manifest` });
|
|
1921
|
+
else if (found === undefined)
|
|
1922
|
+
out.push({ path, message: `"${setting.table}" has no column "${setting.column}"` });
|
|
1923
|
+
else if (found.type !== 'int' && found.type !== 'bigint')
|
|
1924
|
+
out.push({ path, message: `"${setting.table}.${setting.column}" is not a whole number` });
|
|
1925
|
+
};
|
|
1926
|
+
if ('addMinutes' in set) {
|
|
1927
|
+
whole(set.addMinutes.minutes, here('stamp', 'set', 'addMinutes', 'minutes'));
|
|
1928
|
+
whole(set.addMinutes.hours, here('stamp', 'set', 'addMinutes', 'hours'));
|
|
1929
|
+
if (set.addMinutes.notAfter !== undefined)
|
|
1930
|
+
out.push(...momentIssues(table.ref, set.addMinutes.notAfter, index, here('stamp', 'set', 'addMinutes', 'notAfter')));
|
|
1931
|
+
}
|
|
1932
|
+
else {
|
|
1933
|
+
const d = set.deadline;
|
|
1934
|
+
whole(d.days, here('stamp', 'set', 'deadline', 'days'));
|
|
1935
|
+
if (typeof d.time === 'object') {
|
|
1936
|
+
const found = index.column(d.time.table, d.time.column);
|
|
1937
|
+
const path = here('stamp', 'set', 'deadline', 'time');
|
|
1938
|
+
if (index.table(d.time.table) === undefined)
|
|
1939
|
+
out.push({ path, message: `"${d.time.table}" is not a table of this manifest` });
|
|
1940
|
+
else if (found === undefined)
|
|
1941
|
+
out.push({ path, message: `"${d.time.table}" has no column "${d.time.column}"` });
|
|
1942
|
+
else if (found.type !== 'text')
|
|
1943
|
+
out.push({ path, message: `"${d.time.table}.${d.time.column}" is not a text column holding HH:MM` });
|
|
1944
|
+
else if (found.maxLength !== undefined && found.maxLength < 5)
|
|
1945
|
+
out.push({ path, message: `"${d.time.table}.${d.time.column}" holds fewer than the 5 characters of HH:MM` });
|
|
1946
|
+
}
|
|
1947
|
+
if (d.notAfter !== undefined)
|
|
1948
|
+
out.push(...momentIssues(table.ref, d.notAfter, index, here('stamp', 'set', 'deadline', 'notAfter')));
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1366
1951
|
const triggers = Array.isArray(stamp.on) ? stamp.on : [stamp.on];
|
|
1367
1952
|
triggers.forEach((trigger, k) => {
|
|
1368
1953
|
if (trigger === 'create')
|
|
1369
1954
|
return;
|
|
1370
1955
|
const path = Array.isArray(stamp.on) ? here('stamp', 'on', k) : here('stamp', 'on');
|
|
1956
|
+
if ('columns' in trigger) {
|
|
1957
|
+
for (const ref of trigger.columns) {
|
|
1958
|
+
if (!has(table.ref, ref))
|
|
1959
|
+
out.push({ path: [...path, 'columns'], message: `"${table.ref}" has no column "${ref}"` });
|
|
1960
|
+
else if (ref === column.ref)
|
|
1961
|
+
out.push({ path: [...path, 'columns'], message: 'a stamp watches another column' });
|
|
1962
|
+
}
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1371
1965
|
const watched = index.column(table.ref, trigger.column);
|
|
1372
1966
|
if (watched === undefined) {
|
|
1373
1967
|
out.push({ path: [...path, 'column'], message: `"${table.ref}" has no column "${trigger.column}"` });
|
|
@@ -1385,30 +1979,26 @@ shapeOf) {
|
|
|
1385
1979
|
out.push({ path: [...path, 'filled'], message: `"${table.ref}.${watched.ref}" is never empty, so it is never first filled` });
|
|
1386
1980
|
}
|
|
1387
1981
|
});
|
|
1388
|
-
const others = ['copy', 'sequence', 'code', 'rollup', 'formula', 'format', 'default'].filter((name) => rules[name] !== undefined);
|
|
1982
|
+
const others = ['copy', 'sequence', 'code', 'rollup', 'formula', 'format', 'default', 'lookup', 'perNight'].filter((name) => rules[name] !== undefined);
|
|
1389
1983
|
if (others.length > 0)
|
|
1390
1984
|
out.push({ path: here('stamp'), message: `a stamped column is not also decided by ${others.join(', ')}` });
|
|
1985
|
+
if (stamp.clearOnBack === true)
|
|
1986
|
+
out.push(...clearOnBackIssues(table, column, triggers, here('stamp', 'clearOnBack')));
|
|
1391
1987
|
}
|
|
1392
1988
|
if ((rules.sequence !== undefined || rules.code !== undefined) && column.role === 'pk') {
|
|
1393
1989
|
out.push({ path: here(), message: 'a primary key numbers itself; it takes no sequence or code rule' });
|
|
1394
1990
|
}
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
if (value !== undefined && !has(table.ref, value)) {
|
|
1400
|
-
out.push({ path: at('capacity', name), message: `"${table.ref}" has no column "${value}"` });
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
for (const [name, value] of Object.entries(cap)) {
|
|
1404
|
-
if (typeof value === 'object' && value !== null && 'table' in value && 'column' in value) {
|
|
1405
|
-
const setting = value;
|
|
1406
|
-
if (!has(setting.table, setting.column)) {
|
|
1407
|
-
out.push({ path: at('capacity', name), message: `"${setting.table}" has no column "${setting.column}"` });
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1991
|
+
if (rules.code?.renew !== undefined)
|
|
1992
|
+
out.push(...codeRenewIssues(table, column, rules.code.renew, here('code', 'renew')));
|
|
1993
|
+
if (rules.lookup !== undefined) {
|
|
1994
|
+
out.push(...codeLookupIssues(table, column, rules.lookup, here('lookup'), { index, tables, shareCodes: (ref) => shareCodeColumns(m.publicAccess ?? [], ref) }));
|
|
1410
1995
|
}
|
|
1996
|
+
});
|
|
1997
|
+
if (table.columns.filter((column) => column.rules?.lookup !== undefined).length > 2) {
|
|
1998
|
+
out.push({ path: at('columns'), message: 'a table resolves at most two typed codes' });
|
|
1411
1999
|
}
|
|
2000
|
+
if (table.capacity !== undefined)
|
|
2001
|
+
out.push(...capacityIssues(table, table.capacity, index, at));
|
|
1412
2002
|
if (table.booking !== undefined) {
|
|
1413
2003
|
out.push(...bookingIssues(table, table.booking, index, at));
|
|
1414
2004
|
// The late flag is Adminium's to set.
|
|
@@ -1421,7 +2011,18 @@ shapeOf) {
|
|
|
1421
2011
|
index,
|
|
1422
2012
|
roles: shapeOf !== undefined ? undefined : (m.roles ?? []).map((role) => role.key),
|
|
1423
2013
|
numbered: table.columns.some((c) => c.rules?.sequence?.gapless === true),
|
|
2014
|
+
keptFromReaders: (column) => keptFromReaders(table.ref, column, undefined),
|
|
2015
|
+
decided: (column) => decided.get(table.ref)?.has(column) === true,
|
|
2016
|
+
bookingCancel: table.booking?.cancel === undefined ? undefined : table.booking.cancel.when,
|
|
2017
|
+
statesOf: (ref) => tables.get(ref)?.states,
|
|
2018
|
+
bookedOf: (ref) => tables.get(ref)?.booking !== undefined,
|
|
2019
|
+
lineOf: (ref) => [...tables.values()].find((other) => other.states?.children?.[ref] !== undefined)?.ref,
|
|
2020
|
+
outboxTable: m.outbox?.table,
|
|
1424
2021
|
}, (...rest) => at('states', ...rest)));
|
|
2022
|
+
// A late move's flag is Adminium's to set, as a booking's is.
|
|
2023
|
+
for (const late of table.states.late ?? [])
|
|
2024
|
+
if (late.flag !== undefined)
|
|
2025
|
+
decide(table.ref, late.flag);
|
|
1425
2026
|
}
|
|
1426
2027
|
if (table.builtOn !== undefined && shapeOf === undefined) {
|
|
1427
2028
|
const addOn = table.builtOn.split('/')[0];
|
|
@@ -1430,13 +2031,21 @@ shapeOf) {
|
|
|
1430
2031
|
}
|
|
1431
2032
|
}
|
|
1432
2033
|
});
|
|
2034
|
+
out.push(...rollupChainIssues(m.requiredSchema.tables));
|
|
1433
2035
|
out.push(...publicAccessIssues(m.publicAccess ?? [], {
|
|
1434
2036
|
index,
|
|
1435
2037
|
decided: (table) => decided.get(table) ?? new Set(),
|
|
1436
2038
|
answersAvailability: (table) => tables.get(table)?.capacity !== undefined || tables.get(table)?.booking !== undefined,
|
|
2039
|
+
capacityOf: (table) => tables.get(table)?.capacity,
|
|
2040
|
+
figures: figureColumns(m.requiredSchema.tables),
|
|
2041
|
+
mailsOnCreate: (table) => (m.outbox?.producers ?? []).some((producer) => 'onCreate' in producer && producer.onCreate.table === table),
|
|
1437
2042
|
publicKeys: m.publicKeys,
|
|
1438
2043
|
roles: m.roles ?? [],
|
|
2044
|
+
outbox: m.outbox,
|
|
1439
2045
|
}));
|
|
2046
|
+
out.push(...viaIndexIssues(m.requiredSchema.tables));
|
|
2047
|
+
out.push(...settingTableIssues(m));
|
|
2048
|
+
out.push(...slotPartyIssues(m));
|
|
1440
2049
|
out.push(...outboxIssues(m, index));
|
|
1441
2050
|
out.push(...roleLimitIssues(m.roles ?? [], index));
|
|
1442
2051
|
if (shapeOf === undefined)
|
|
@@ -1446,10 +2055,349 @@ shapeOf) {
|
|
|
1446
2055
|
index,
|
|
1447
2056
|
addOns: new Set([...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key)),
|
|
1448
2057
|
features: new Set((m.addOns?.features ?? []).map((feature) => feature.id)),
|
|
2058
|
+
perNight: (ref) => new Map((tables.get(ref)?.columns ?? []).flatMap((column) => column.rules?.perNight === undefined ? [] : [[column.ref, { rateVia: column.rules.perNight.rate.via }]])),
|
|
2059
|
+
unlisted: (table, column) => unlistedColumn(m, table, column),
|
|
1449
2060
|
}));
|
|
1450
2061
|
}
|
|
1451
2062
|
return out;
|
|
1452
2063
|
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Where a manifest reads a value of the settings row: the keys a `{table,
|
|
2066
|
+
* column}` setting sits under, or the lists whose items are settings (a move
|
|
2067
|
+
* waiting for one, an entry switched off by one). Any other `{table, column}`
|
|
2068
|
+
* (a person found by address, a changed column an email watches) is no
|
|
2069
|
+
* setting.
|
|
2070
|
+
*/
|
|
2071
|
+
const SETTING_KEYS = new Set([
|
|
2072
|
+
'time', 'minutes', 'hours', 'days', 'perSlot', 'slotMinutes', 'windowDays', 'noticeMinutes', 'cancelHours', 'opens', 'closes',
|
|
2073
|
+
'size', 'min', 'max', 'aheadDays', 'grid', 'from', 'startSetting', 'prefixSetting', 'enabledBy', 'fallback', 'setting',
|
|
2074
|
+
]);
|
|
2075
|
+
const SETTING_LISTS = new Set(['setting', 'requireSetting']);
|
|
2076
|
+
/** Whether a value is a setting read from a table (`{table, column}`, with a list item's `eq` or `when`). */
|
|
2077
|
+
function isTableSetting(value) {
|
|
2078
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
2079
|
+
return false;
|
|
2080
|
+
const keys = Object.keys(value);
|
|
2081
|
+
const found = value;
|
|
2082
|
+
return typeof found['table'] === 'string' && typeof found['column'] === 'string' && keys.every((key) => ['table', 'column', 'eq', 'when'].includes(key));
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Every setting a rule reads from a table, anywhere in the manifest: its
|
|
2086
|
+
* tables' rules (limits, moments, moves, stamps, fills), its public entries
|
|
2087
|
+
* and keys, and its outbox.
|
|
2088
|
+
*/
|
|
2089
|
+
export function settingReferences(m) {
|
|
2090
|
+
const out = [];
|
|
2091
|
+
const walk = (value, path, key, list) => {
|
|
2092
|
+
if (Array.isArray(value)) {
|
|
2093
|
+
value.forEach((item, i) => walk(item, [...path, i], i, typeof key === 'string' ? key : undefined));
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
if (typeof value !== 'object' || value === null)
|
|
2097
|
+
return;
|
|
2098
|
+
const named = typeof key === 'string' ? SETTING_KEYS.has(key) : list !== undefined && SETTING_LISTS.has(list);
|
|
2099
|
+
if (named && isTableSetting(value)) {
|
|
2100
|
+
out.push({ path, table: value.table, column: value.column });
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
for (const [child, inner] of Object.entries(value))
|
|
2104
|
+
walk(inner, [...path, child], child, undefined);
|
|
2105
|
+
};
|
|
2106
|
+
walk(m.requiredSchema.tables, ['requiredSchema', 'tables'], 'tables', undefined);
|
|
2107
|
+
walk(m.publicAccess ?? [], ['publicAccess'], 'publicAccess', undefined);
|
|
2108
|
+
walk(m.publicKeys, ['publicKeys'], 'publicKeys', undefined);
|
|
2109
|
+
walk(m.outbox, ['outbox'], 'outbox', undefined);
|
|
2110
|
+
return out;
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* A setting is read from a table holding ONE row — or it reads whichever row
|
|
2114
|
+
* the database returns first (another guest's arrival time). One-row: the
|
|
2115
|
+
* app's settings table (its outbox's `settings.table`), or a table standing
|
|
2116
|
+
* alone — no foreign key of its own, none of another table pointing at it,
|
|
2117
|
+
* and no states, limits or bookings, as a venue's rules are kept. Anything
|
|
2118
|
+
* else is refused; and on the server a table found holding more rows is read
|
|
2119
|
+
* as having no setting at all.
|
|
2120
|
+
*/
|
|
2121
|
+
function settingTableIssues(m) {
|
|
2122
|
+
const tables = m.requiredSchema.tables;
|
|
2123
|
+
const settingsTable = m.outbox?.settings?.table;
|
|
2124
|
+
const oneRow = (ref) => {
|
|
2125
|
+
if (ref === settingsTable)
|
|
2126
|
+
return true;
|
|
2127
|
+
const table = tables.find((t) => t.ref === ref);
|
|
2128
|
+
if (table === undefined)
|
|
2129
|
+
return true; // a table the manifest lacks is refused where the rule is checked
|
|
2130
|
+
if (table.states !== undefined || table.capacity !== undefined || table.booking !== undefined)
|
|
2131
|
+
return false;
|
|
2132
|
+
if (table.columns.some((column) => column.type === 'fk'))
|
|
2133
|
+
return false;
|
|
2134
|
+
return !tables.some((other) => other.columns.some((column) => column.type === 'fk' && column.references === ref));
|
|
2135
|
+
};
|
|
2136
|
+
return settingReferences(m)
|
|
2137
|
+
.filter((ref) => !oneRow(ref.table))
|
|
2138
|
+
.map((ref) => ({
|
|
2139
|
+
path: ref.path,
|
|
2140
|
+
message: `"${ref.table}" may hold many rows, so "${ref.table}.${ref.column}" is no setting: read settings from the app's one-row settings table (outbox.settings.table), or from a table that links nowhere, that no table links to, and that keeps no states or limits`,
|
|
2141
|
+
}));
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* Advice about a capped balance worked out from a formula (`balance.of` a
|
|
2145
|
+
* total of subtotal and tax): a write that changes what the formula reads is
|
|
2146
|
+
* judged against the cap one row at a time, but a batch (a bulk edit, an
|
|
2147
|
+
* import) settles the balance after its rows are written, without the cap. So
|
|
2148
|
+
* every column the formula reads — through other formulas, and the lines a
|
|
2149
|
+
* total adds up — should stay as it is whenever the capped rows (payments) can
|
|
2150
|
+
* exist: locked by the table's states in every state such a row can be
|
|
2151
|
+
* written in or left in (a sent or void invoice), or the lines locked with it.
|
|
2152
|
+
* One path per balance whose formula reads a column that stays writable.
|
|
2153
|
+
*/
|
|
2154
|
+
export function cappedFormulaWarnings(tables) {
|
|
2155
|
+
const out = [];
|
|
2156
|
+
tables.forEach((table, t) => {
|
|
2157
|
+
const columns = new Map(table.columns.map((column) => [column.ref, column]));
|
|
2158
|
+
table.columns.forEach((column, c) => {
|
|
2159
|
+
const rollup = column.rules?.rollup;
|
|
2160
|
+
const balance = rollup?.balance;
|
|
2161
|
+
if (rollup === undefined || balance === undefined)
|
|
2162
|
+
return;
|
|
2163
|
+
const of = columns.get(balance.of);
|
|
2164
|
+
if (of?.rules?.formula === undefined)
|
|
2165
|
+
return;
|
|
2166
|
+
// The rows the cap holds back: this total's, and those of each total the balance takes away that is capped.
|
|
2167
|
+
const capped = [
|
|
2168
|
+
...(rollup.cap === true ? [rollup.from] : []),
|
|
2169
|
+
...(balance.minus ?? []).flatMap((ref) => {
|
|
2170
|
+
const minus = columns.get(ref)?.rules?.rollup;
|
|
2171
|
+
return minus?.cap === true ? [minus.from] : [];
|
|
2172
|
+
}),
|
|
2173
|
+
];
|
|
2174
|
+
if (capped.length === 0)
|
|
2175
|
+
return;
|
|
2176
|
+
const states = table.states;
|
|
2177
|
+
const all = [...new Set([...(table.columns.find((x) => x.ref === states?.column)?.enum ?? []), ...Object.keys(states?.moves ?? {})])];
|
|
2178
|
+
// Every state a capped row can be written in, and every state reached from those: the row stays.
|
|
2179
|
+
const living = new Set();
|
|
2180
|
+
for (const child of capped) {
|
|
2181
|
+
const tie = states?.children?.[child];
|
|
2182
|
+
const from = tie?.createIn ?? tie?.parentIn;
|
|
2183
|
+
for (const state of from ?? all)
|
|
2184
|
+
living.add(state);
|
|
2185
|
+
}
|
|
2186
|
+
if (states !== undefined) {
|
|
2187
|
+
const queue = [...living];
|
|
2188
|
+
while (queue.length > 0) {
|
|
2189
|
+
const next = queue.pop();
|
|
2190
|
+
for (const move of states.moves[next] ?? []) {
|
|
2191
|
+
const to = typeof move === 'string' ? move : move.to;
|
|
2192
|
+
if (!living.has(to)) {
|
|
2193
|
+
living.add(to);
|
|
2194
|
+
queue.push(to);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
const locked = states?.lock !== undefined && living.size > 0 && [...living].every((state) => states.lock.when.includes(state));
|
|
2200
|
+
const kept = (ref) => locked && !(states?.lock?.except ?? []).includes(ref);
|
|
2201
|
+
// The columns the formula reads, down through formulas; a total stands for the lines it adds up.
|
|
2202
|
+
const open = [];
|
|
2203
|
+
const seen = new Set();
|
|
2204
|
+
const read = (ref) => {
|
|
2205
|
+
if (seen.has(ref))
|
|
2206
|
+
return;
|
|
2207
|
+
seen.add(ref);
|
|
2208
|
+
const found = columns.get(ref);
|
|
2209
|
+
if (found?.rules?.formula !== undefined) {
|
|
2210
|
+
for (const input of formulaColumns(found.rules.formula))
|
|
2211
|
+
read(input);
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
const total = found?.rules?.rollup;
|
|
2215
|
+
if (total !== undefined) {
|
|
2216
|
+
if (!(kept(ref) && states?.children?.[total.from]?.lock === true))
|
|
2217
|
+
open.push(ref);
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
if (!kept(ref))
|
|
2221
|
+
open.push(ref);
|
|
2222
|
+
};
|
|
2223
|
+
for (const input of formulaColumns(of.rules.formula))
|
|
2224
|
+
read(input);
|
|
2225
|
+
if (open.length === 0)
|
|
2226
|
+
return;
|
|
2227
|
+
out.push({
|
|
2228
|
+
path: ['requiredSchema', 'tables', t, 'columns', c, 'rules', 'rollup', 'balance', 'of'],
|
|
2229
|
+
message: `"${table.ref}.${balance.of}" is worked out from ${open.map((ref) => `"${ref}"`).join(', ')}, which can change while "${capped.join('", "')}" rows are held to the balance: a bulk edit or an import settles the balance without the cap. Lock them with the table's states while those rows can exist`,
|
|
2230
|
+
});
|
|
2231
|
+
});
|
|
2232
|
+
});
|
|
2233
|
+
return out;
|
|
2234
|
+
}
|
|
2235
|
+
/**
|
|
2236
|
+
* A slot limit a guest may ask about, whose rows each take a party written in
|
|
2237
|
+
* a column: the party asked is capped at what one row may hold, so the column
|
|
2238
|
+
* says it (`validation.max`) — or a page could ask ever larger parties and
|
|
2239
|
+
* learn how full each time is. A released app's slot rule is answered as it
|
|
2240
|
+
* always was.
|
|
2241
|
+
*/
|
|
2242
|
+
function slotPartyIssues(m) {
|
|
2243
|
+
const out = [];
|
|
2244
|
+
(m.publicAccess ?? []).forEach((entry, e) => {
|
|
2245
|
+
if (entry.kind !== 'availability')
|
|
2246
|
+
return;
|
|
2247
|
+
const table = m.requiredSchema.tables.find((t) => t.ref === entry.table);
|
|
2248
|
+
if (table?.capacity === undefined || isLegacyCapacity(table.capacity))
|
|
2249
|
+
return;
|
|
2250
|
+
const rule = rulesOf(table.capacity)[entry.rule ?? 0];
|
|
2251
|
+
if (rule === undefined || kindOf(rule) !== 'slot' || !('amount' in rule) || typeof rule.amount !== 'string')
|
|
2252
|
+
return;
|
|
2253
|
+
const amount = rule.amount;
|
|
2254
|
+
if (table.columns.find((column) => column.ref === amount)?.rules?.validation?.max !== undefined)
|
|
2255
|
+
return;
|
|
2256
|
+
out.push({
|
|
2257
|
+
path: ['publicAccess', e],
|
|
2258
|
+
message: `"${table.ref}.${amount}" is the party a guest asks about: give it a largest value (validation.max), or a page asking ever larger parties learns how full each time is`,
|
|
2259
|
+
});
|
|
2260
|
+
});
|
|
2261
|
+
return out;
|
|
2262
|
+
}
|
|
2263
|
+
/** The rules through which Adminium decides a column: nobody else writes it. */
|
|
2264
|
+
const DECIDING_RULES = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup'];
|
|
2265
|
+
const decidedByRules = (rules) => DECIDING_RULES.some((name) => rules?.[name] !== undefined);
|
|
2266
|
+
/**
|
|
2267
|
+
* A stamp emptied by an undo watches the table's state column, is a column
|
|
2268
|
+
* that may be empty, and some move marked `undo` leaves one of the states it
|
|
2269
|
+
* watches — or it would never be emptied.
|
|
2270
|
+
*/
|
|
2271
|
+
function clearOnBackIssues(table, column, triggers, path) {
|
|
2272
|
+
const out = [];
|
|
2273
|
+
if (column.nullable !== true)
|
|
2274
|
+
out.push({ path, message: `"${table.ref}.${column.ref}" is not nullable, so an undo cannot empty it` });
|
|
2275
|
+
const states = table.states;
|
|
2276
|
+
const watched = triggers.flatMap((trigger) => {
|
|
2277
|
+
const t = trigger;
|
|
2278
|
+
return typeof t === 'object' && t.values !== undefined && states !== undefined && t.column === states.column ? t.values.map(String) : [];
|
|
2279
|
+
});
|
|
2280
|
+
if (states === undefined || watched.length === 0) {
|
|
2281
|
+
out.push({ path, message: 'only a stamp written when the state moves is emptied by an undo: watch the table\'s state column' });
|
|
2282
|
+
return out;
|
|
2283
|
+
}
|
|
2284
|
+
const undone = Object.entries(states.moves).some(([from, moves]) => watched.includes(from) && moves.some((move) => typeof move === 'object' && move.undo === true));
|
|
2285
|
+
if (!undone)
|
|
2286
|
+
out.push({ path, message: `no move marked undo leaves ${watched.map((v) => `"${v}"`).join(', ')}, so nothing ever empties it` });
|
|
2287
|
+
return out;
|
|
2288
|
+
}
|
|
2289
|
+
/** What renews a code must be another column of the row, one a person changes (or a stamp writes). */
|
|
2290
|
+
function codeRenewIssues(table, column, renew, path) {
|
|
2291
|
+
const out = [];
|
|
2292
|
+
const triggers = Array.isArray(renew.on) ? renew.on : [renew.on];
|
|
2293
|
+
triggers.forEach((trigger, k) => {
|
|
2294
|
+
const at = Array.isArray(renew.on) ? [...path, 'on', k] : [...path, 'on'];
|
|
2295
|
+
const watched = table.columns.find((c) => c.ref === trigger.column);
|
|
2296
|
+
if (watched === undefined) {
|
|
2297
|
+
out.push({ path: [...at, 'column'], message: `"${table.ref}" has no column "${trigger.column}"` });
|
|
2298
|
+
}
|
|
2299
|
+
else if (watched.ref === column.ref) {
|
|
2300
|
+
out.push({ path: [...at, 'column'], message: 'a code is renewed by another column' });
|
|
2301
|
+
}
|
|
2302
|
+
else if (watched.rules?.code !== undefined) {
|
|
2303
|
+
out.push({ path: [...at, 'column'], message: `"${table.ref}.${watched.ref}" is a code of its own, so it renews nothing` });
|
|
2304
|
+
}
|
|
2305
|
+
else if (watched.type === 'json' || watched.type === 'blob') {
|
|
2306
|
+
out.push({ path: [...at, 'column'], message: `"${table.ref}.${watched.ref}" is a ${watched.type} column, so a change of it is not compared` });
|
|
2307
|
+
}
|
|
2308
|
+
else if ('values' in trigger) {
|
|
2309
|
+
for (const value of trigger.values) {
|
|
2310
|
+
if (!valueFits(watched, value))
|
|
2311
|
+
out.push({ path: [...at, 'values'], message: `${JSON.stringify(value)} is not a value of "${table.ref}.${watched.ref}"` });
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
else if (decidedByRules(watched.rules) && watched.rules?.stamp === undefined) {
|
|
2315
|
+
// A stamp is decided in the same step as the renewal, before it: a holder copied in as an offer is taken renews.
|
|
2316
|
+
out.push({ path: [...at, 'column'], message: 'a code is renewed by a change a person makes, or a stamp' });
|
|
2317
|
+
}
|
|
2318
|
+
});
|
|
2319
|
+
return out;
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* A foreign key filled from a typed code: the link, the column the code is
|
|
2323
|
+
* typed into, and the column of the other table it is found by — one row,
|
|
2324
|
+
* compared as a code, never a shared link's secret.
|
|
2325
|
+
*/
|
|
2326
|
+
function codeLookupIssues(table, column, lookup, path, ctx) {
|
|
2327
|
+
const out = [];
|
|
2328
|
+
if (column.type !== 'fk' || column.nullable !== true || column.references !== lookup.table) {
|
|
2329
|
+
out.push({ path: [...path, 'table'], message: `a lookup fills this table's link to "${lookup.table}": the column is a nullable foreign key to it` });
|
|
2330
|
+
}
|
|
2331
|
+
const typed = table.columns.find((c) => c.ref === lookup.from);
|
|
2332
|
+
if (typed === undefined) {
|
|
2333
|
+
out.push({ path: [...path, 'from'], message: `"${table.ref}" has no column "${lookup.from}"` });
|
|
2334
|
+
}
|
|
2335
|
+
else if (typed.ref === column.ref ||
|
|
2336
|
+
typed.type !== 'text' ||
|
|
2337
|
+
typed.maxLength === undefined ||
|
|
2338
|
+
typed.maxLength > 64 ||
|
|
2339
|
+
typed.nullable !== true ||
|
|
2340
|
+
decidedByRules(typed.rules)) {
|
|
2341
|
+
out.push({ path: [...path, 'from'], message: 'the code is typed into a nullable text column of up to 64 characters' });
|
|
2342
|
+
}
|
|
2343
|
+
const target = ctx.tables.get(lookup.table);
|
|
2344
|
+
if (target === undefined) {
|
|
2345
|
+
out.push({ path: [...path, 'table'], message: `"${lookup.table}" is not a table of this app` });
|
|
2346
|
+
return out;
|
|
2347
|
+
}
|
|
2348
|
+
const found = target.columns.find((c) => c.ref === lookup.column);
|
|
2349
|
+
const scopeColumns = (lookup.scope ?? []).map((s) => s.column);
|
|
2350
|
+
if (found === undefined) {
|
|
2351
|
+
out.push({ path: [...path, 'column'], message: `"${lookup.table}" has no column "${lookup.column}"` });
|
|
2352
|
+
}
|
|
2353
|
+
else {
|
|
2354
|
+
// A set of columns unique together counts when its other columns are the scope's.
|
|
2355
|
+
const sets = (target.unique ?? []).filter((set) => set.includes(found.ref));
|
|
2356
|
+
const scoped = sets.some((set) => {
|
|
2357
|
+
const others = set.filter((ref) => ref !== found.ref);
|
|
2358
|
+
return others.length === scopeColumns.length && others.every((ref) => scopeColumns.includes(ref));
|
|
2359
|
+
});
|
|
2360
|
+
if (found.type !== 'text' || (found.unique !== true && found.rules?.code === undefined && !scoped)) {
|
|
2361
|
+
out.push({ path: [...path, 'column'], message: `a code finds one row: make "${lookup.table}.${found.ref}" unique (or unique with its scope)` });
|
|
2362
|
+
}
|
|
2363
|
+
if (found.rules?.code === undefined && found.rules?.normalize !== 'code') {
|
|
2364
|
+
out.push({ path: [...path, 'column'], message: `"${lookup.table}.${found.ref}" is compared as a code: give it normalize "code"` });
|
|
2365
|
+
}
|
|
2366
|
+
if (ctx.shareCodes(lookup.table).includes(found.ref)) {
|
|
2367
|
+
out.push({ path: [...path, 'column'], message: "a shared link's code is never looked up" });
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
(lookup.where ?? []).forEach((condition, k) => {
|
|
2371
|
+
const at = [...path, 'where', k];
|
|
2372
|
+
const filter = ctx.index.column(lookup.table, condition.column);
|
|
2373
|
+
if (filter === undefined) {
|
|
2374
|
+
out.push({ path: [...at, 'column'], message: `"${lookup.table}" has no column "${condition.column}"` });
|
|
2375
|
+
}
|
|
2376
|
+
else if ('eq' in condition) {
|
|
2377
|
+
if (!valueFits(filter, condition.eq))
|
|
2378
|
+
out.push({ path: [...at, 'eq'], message: `${JSON.stringify(condition.eq)} is not a value of "${lookup.table}.${filter.ref}"` });
|
|
2379
|
+
}
|
|
2380
|
+
else if (filter.type !== 'date' && filter.type !== 'timestamptz') {
|
|
2381
|
+
out.push({ path: [...at, 'column'], message: `"${lookup.table}.${filter.ref}" is not a date` });
|
|
2382
|
+
}
|
|
2383
|
+
});
|
|
2384
|
+
(lookup.scope ?? []).forEach((scope, k) => {
|
|
2385
|
+
const at = [...path, 'scope', k];
|
|
2386
|
+
const theirs = ctx.index.column(lookup.table, scope.column);
|
|
2387
|
+
const ours = ctx.index.column(table.ref, scope.equals);
|
|
2388
|
+
if (theirs === undefined)
|
|
2389
|
+
out.push({ path: [...at, 'column'], message: `"${lookup.table}" has no column "${scope.column}"` });
|
|
2390
|
+
if (ours === undefined)
|
|
2391
|
+
out.push({ path: [...at, 'equals'], message: `"${table.ref}" has no column "${scope.equals}"` });
|
|
2392
|
+
if (theirs !== undefined && ours !== undefined && (theirs.type !== ours.type || theirs.references !== ours.references)) {
|
|
2393
|
+
out.push({ path: at, message: `"${table.ref}.${ours.ref}" and "${lookup.table}.${theirs.ref}" hold different things` });
|
|
2394
|
+
}
|
|
2395
|
+
if (scope.orEmpty === true && theirs !== undefined && theirs.nullable !== true) {
|
|
2396
|
+
out.push({ path: [...at, 'orEmpty'], message: `"${lookup.table}.${theirs.ref}" is never empty` });
|
|
2397
|
+
}
|
|
2398
|
+
});
|
|
2399
|
+
return out;
|
|
2400
|
+
}
|
|
1453
2401
|
export const appManifestSchema = z
|
|
1454
2402
|
.object({
|
|
1455
2403
|
kind: z.literal('app'),
|