@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.
Files changed (66) hide show
  1. package/dist/capacity.d.ts +786 -0
  2. package/dist/capacity.d.ts.map +1 -0
  3. package/dist/capacity.js +717 -0
  4. package/dist/capacity.js.map +1 -0
  5. package/dist/documents.d.ts +91 -5
  6. package/dist/documents.d.ts.map +1 -1
  7. package/dist/documents.js +129 -32
  8. package/dist/documents.js.map +1 -1
  9. package/dist/formula.d.ts +114 -2
  10. package/dist/formula.d.ts.map +1 -1
  11. package/dist/formula.js +291 -5
  12. package/dist/formula.js.map +1 -1
  13. package/dist/index.d.ts +12 -6
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +12 -6
  16. package/dist/index.js.map +1 -1
  17. package/dist/marketplace-wire.d.ts +230 -0
  18. package/dist/marketplace-wire.d.ts.map +1 -0
  19. package/dist/marketplace-wire.js +138 -0
  20. package/dist/marketplace-wire.js.map +1 -0
  21. package/dist/outbox.d.ts +242 -27
  22. package/dist/outbox.d.ts.map +1 -1
  23. package/dist/outbox.js +553 -27
  24. package/dist/outbox.js.map +1 -1
  25. package/dist/plan-context.d.ts +28 -3
  26. package/dist/plan-context.d.ts.map +1 -1
  27. package/dist/plan-context.js +291 -14
  28. package/dist/plan-context.js.map +1 -1
  29. package/dist/plan-model.d.ts +58 -3
  30. package/dist/plan-model.d.ts.map +1 -1
  31. package/dist/public-access.d.ts +637 -0
  32. package/dist/public-access.d.ts.map +1 -1
  33. package/dist/public-access.js +1593 -12
  34. package/dist/public-access.js.map +1 -1
  35. package/dist/refs.d.ts +86 -0
  36. package/dist/refs.d.ts.map +1 -1
  37. package/dist/refs.js +186 -0
  38. package/dist/refs.js.map +1 -1
  39. package/dist/roles.d.ts +10 -2
  40. package/dist/roles.d.ts.map +1 -1
  41. package/dist/roles.js +27 -5
  42. package/dist/roles.js.map +1 -1
  43. package/dist/sample.d.ts +218 -2
  44. package/dist/sample.d.ts.map +1 -1
  45. package/dist/sample.js +145 -7
  46. package/dist/sample.js.map +1 -1
  47. package/dist/schema.d.ts +2860 -360
  48. package/dist/schema.d.ts.map +1 -1
  49. package/dist/schema.js +1002 -54
  50. package/dist/schema.js.map +1 -1
  51. package/dist/shapes.d.ts +5 -1
  52. package/dist/shapes.d.ts.map +1 -1
  53. package/dist/shapes.js +13 -3
  54. package/dist/shapes.js.map +1 -1
  55. package/dist/states.d.ts +342 -2
  56. package/dist/states.d.ts.map +1 -1
  57. package/dist/states.js +566 -7
  58. package/dist/states.js.map +1 -1
  59. package/dist/table-shapes.d.ts +47 -0
  60. package/dist/table-shapes.d.ts.map +1 -0
  61. package/dist/table-shapes.js +159 -0
  62. package/dist/table-shapes.js.map +1 -0
  63. package/dist/validate.d.ts.map +1 -1
  64. package/dist/validate.js +80 -2
  65. package/dist/validate.js.map +1 -1
  66. 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 { NUMERIC_TYPES, labelsSchema, numberOrSetting, refSchema, scalarSchema, settingSourceSchema, tableIndex, textOrLabels, valueFits, } from './refs.js';
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
- /** When a stamp is written: on create, when a column changes to a value, or when a column is first filled. */
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
- sum: refSchema,
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 slot rows may take — the booking guard. Only rows
451
- * whose `countWhere` column holds one of its values count (a cancelled booking
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 const capacitySchema = z
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
- if (source?.personal === true && into?.personal !== true && into?.secret !== true)
1045
- return 'personal data';
1046
- return null;
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
- const cap = table.capacity;
1397
- if (cap !== undefined) {
1398
- for (const [name, value] of [['slot', cap.slot], ['amount', cap.amount], ['resource', cap.resource], ['countWhere', cap.countWhere?.column]]) {
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'),