@adminiumjs/manifest 0.3.0-rc.4 → 0.3.1

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 (67) hide show
  1. package/dist/add-ons.d.ts +63 -0
  2. package/dist/add-ons.d.ts.map +1 -0
  3. package/dist/add-ons.js +100 -0
  4. package/dist/add-ons.js.map +1 -0
  5. package/dist/booking.d.ts +100 -0
  6. package/dist/booking.d.ts.map +1 -0
  7. package/dist/booking.js +237 -0
  8. package/dist/booking.js.map +1 -0
  9. package/dist/documents.d.ts +100 -0
  10. package/dist/documents.d.ts.map +1 -0
  11. package/dist/documents.js +148 -0
  12. package/dist/documents.js.map +1 -0
  13. package/dist/formula.d.ts +124 -0
  14. package/dist/formula.d.ts.map +1 -0
  15. package/dist/formula.js +428 -0
  16. package/dist/formula.js.map +1 -0
  17. package/dist/index.d.ts +14 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +14 -3
  20. package/dist/index.js.map +1 -1
  21. package/dist/outbox.d.ts +543 -0
  22. package/dist/outbox.d.ts.map +1 -0
  23. package/dist/outbox.js +559 -0
  24. package/dist/outbox.js.map +1 -0
  25. package/dist/page-calendar.d.ts +42 -0
  26. package/dist/page-calendar.d.ts.map +1 -0
  27. package/dist/page-calendar.js +95 -0
  28. package/dist/page-calendar.js.map +1 -0
  29. package/dist/plan-model.d.ts +1 -1
  30. package/dist/plan-model.d.ts.map +1 -1
  31. package/dist/public-access.d.ts +208 -0
  32. package/dist/public-access.d.ts.map +1 -0
  33. package/dist/public-access.js +681 -0
  34. package/dist/public-access.js.map +1 -0
  35. package/dist/refs.d.ts +91 -0
  36. package/dist/refs.d.ts.map +1 -0
  37. package/dist/refs.js +82 -0
  38. package/dist/refs.js.map +1 -0
  39. package/dist/roles.d.ts +44 -0
  40. package/dist/roles.d.ts.map +1 -0
  41. package/dist/roles.js +75 -0
  42. package/dist/roles.js.map +1 -0
  43. package/dist/sample.d.ts +150 -2
  44. package/dist/sample.d.ts.map +1 -1
  45. package/dist/sample.js +101 -3
  46. package/dist/sample.js.map +1 -1
  47. package/dist/schema.d.ts +3464 -151
  48. package/dist/schema.d.ts.map +1 -1
  49. package/dist/schema.js +681 -143
  50. package/dist/schema.js.map +1 -1
  51. package/dist/semver.d.ts +28 -0
  52. package/dist/semver.d.ts.map +1 -0
  53. package/dist/semver.js +85 -0
  54. package/dist/semver.js.map +1 -0
  55. package/dist/shapes.d.ts +97 -0
  56. package/dist/shapes.d.ts.map +1 -0
  57. package/dist/shapes.js +190 -0
  58. package/dist/shapes.js.map +1 -0
  59. package/dist/states.d.ts +134 -0
  60. package/dist/states.d.ts.map +1 -0
  61. package/dist/states.js +244 -0
  62. package/dist/states.js.map +1 -0
  63. package/dist/validate.d.ts +14 -0
  64. package/dist/validate.d.ts.map +1 -1
  65. package/dist/validate.js +29 -2
  66. package/dist/validate.js.map +1 -1
  67. package/package.json +2 -2
package/dist/schema.js CHANGED
@@ -11,6 +11,18 @@
11
11
  */
12
12
  import { addOnBlockSchema, addOnCategorySchema, i18nMessageSchema, isSlotId, } from '@adminium/add-on-contracts';
13
13
  import { z } from 'zod';
14
+ import { addOnNeedsIssues, addOnsSchema, requiresAddOn } from './add-ons.js';
15
+ import { bookingIssues, bookingSchema } from './booking.js';
16
+ import { appDocumentIssues, appDocumentSchema, mappingIssues } from './documents.js';
17
+ import { formulaExprSchema, tableFormulaIssues } from './formula.js';
18
+ import { pageCalendarIssues } from './page-calendar.js';
19
+ import { emailTemplateSchema, outboxIssues, outboxProducerSchema, outboxSchema } from './outbox.js';
20
+ import { publicAccessIssues, publicAccessSchema, publicKeysSchema } from './public-access.js';
21
+ import { roleLimitIssues, roleLimitsSchema } from './roles.js';
22
+ import { statesIssues, statesSchema } from './states.js';
23
+ import { NUMERIC_TYPES, labelsSchema, numberOrSetting, refSchema, scalarSchema, settingSourceSchema, tableIndex, textOrLabels, valueFits, } from './refs.js';
24
+ import { compareSemver, parseSemverRange } from './semver.js';
25
+ export { compareSemver };
14
26
  /** Integer spec version, frozen at 1 for Adminium 1.x. */
15
27
  export const MANIFEST_VERSION = 1;
16
28
  /**
@@ -134,6 +146,21 @@ export const compatibilitySchema = z
134
146
  */
135
147
  engines: z.array(z.enum(MANIFEST_ENGINES)).min(1).optional(),
136
148
  requires: z.array(capabilitySchema).optional(),
149
+ /**
150
+ * The installed versions this release can update in place, as a semver
151
+ * range (`>=0.2.0`). An install outside it is not offered the update and
152
+ * is refused one: a release whose tables changed shape says so here, and
153
+ * the operator is told to uninstall first rather than left with an update
154
+ * that fails half-way. Absent means any older version.
155
+ */
156
+ updatesFrom: z
157
+ .string()
158
+ .min(1)
159
+ .max(120)
160
+ .refine((range) => parseSemverRange(range) !== null, {
161
+ message: 'a semver range such as ">=0.2.0", "^0.2.0" or ">=0.2.0 <1.0.0"',
162
+ })
163
+ .optional(),
137
164
  })
138
165
  .strict();
139
166
  // ── requiredSchema (create-or-map) ───────────────────────────────────────────
@@ -167,25 +194,7 @@ export const COLUMN_SEMANTICS = [
167
194
  ];
168
195
  /** Structural role markers. */
169
196
  export const COLUMN_ROLES = ['pk', 'created_at', 'updated_at'];
170
- /** A snake_case identifier: a table or column ref. */
171
- const refSchema = z.string().regex(/^[a-z][a-z0-9_]*$/, 'must be a snake_case identifier');
172
- /** A BCP 47 tag, the way every label map in a manifest is keyed (`de-DE`). */
173
- const bcp47TagSchema = z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, 'keyed by BCP 47 tag');
174
- /** A label in several languages. US English is the one every reader falls back to. */
175
- export const labelsSchema = z
176
- .record(bcp47TagSchema, z.string().min(1).max(120))
177
- .refine((labels) => labels['en-US'] !== undefined, { message: 'labels must include en-US' });
178
- /** A text that is either one string, or the same text in several languages. */
179
- const textOrLabels = z.union([z.string().min(1).max(256), labelsSchema]);
180
- /**
181
- * A number the manifest states, or one the app's own settings row holds — so
182
- * a venue can change its capacity without a new release. `{table, column}`
183
- * reads the one row of that (one-row) table at write time.
184
- */
185
- const numberOrSetting = z.union([
186
- z.number().int().nonnegative(),
187
- z.object({ table: refSchema, column: refSchema }).strict(),
188
- ]);
197
+ export { labelsSchema };
189
198
  /**
190
199
  * The rules an app asks Adminium to keep on a column (written as column-rule
191
200
  * overrides at install). The first five are the rules an operator can set in
@@ -201,6 +210,71 @@ const numberOrSetting = z.union([
201
210
  * - `personal`: whether the column is personal data, overriding the guess
202
211
  * Adminium makes from its name.
203
212
  */
213
+ /** A child list a fingerprint covers: its rows in `orderBy` order, then by key. */
214
+ const hashChildSchema = z
215
+ .object({ table: refSchema, via: refSchema, columns: z.array(refSchema).min(1).max(24), orderBy: refSchema.optional() })
216
+ .strict();
217
+ /**
218
+ * What a stamp writes:
219
+ *
220
+ * - `now` (a timestamptz), `today` (a date, on the venue's calendar),
221
+ * `user-name`, `user-id`;
222
+ * - `byOrigin`: one value for a public write, another for staff — or, with no
223
+ * `staff`, whatever the staff writer chose (a desk records how a client
224
+ * approved; the portal always says "portal");
225
+ * - `copy`: another column of the same row as it stands at that moment (a
226
+ * client's first answer, kept when they edit it later);
227
+ * - `claim`: a column of the signed-in person's own row (their email, their
228
+ * name) on a public write; on a staff write, `staff` says what instead;
229
+ * - `addDays`: a date so many days after another (`due_on` = `issued_on` +
230
+ * the terms' days, `map` giving each term its days);
231
+ * - `hashOf`: a fingerprint — SHA-256 over the named columns, child rows and
232
+ * a linked row's text, in a canonical form anyone can recompute.
233
+ */
234
+ export const stampSetSchema = z.union([
235
+ z.enum(['now', 'today', 'user-name', 'user-id']),
236
+ z.object({ byOrigin: z.object({ public: z.string().min(1), staff: z.string().min(1).optional() }).strict() }).strict(),
237
+ z.object({ claim: refSchema, staff: z.enum(['user-name', 'user-id']).optional() }).strict(),
238
+ z.object({ copy: refSchema }).strict(),
239
+ z
240
+ .object({
241
+ addDays: z
242
+ .object({
243
+ date: refSchema,
244
+ days: z.union([refSchema, z.number().int().min(0).max(3650)]),
245
+ map: z.record(z.string().min(1), z.number().int().min(0).max(3650)).optional(),
246
+ })
247
+ .strict(),
248
+ })
249
+ .strict(),
250
+ z
251
+ .object({
252
+ hashOf: z
253
+ .object({
254
+ columns: z.array(refSchema).min(1).max(24),
255
+ children: z.array(hashChildSchema).max(4).optional(),
256
+ linked: z
257
+ .array(z
258
+ .object({
259
+ via: refSchema,
260
+ table: refSchema,
261
+ columns: z.array(refSchema).min(1).max(24),
262
+ children: z.array(hashChildSchema).max(4).optional(),
263
+ })
264
+ .strict())
265
+ .max(4)
266
+ .optional(),
267
+ })
268
+ .strict(),
269
+ })
270
+ .strict(),
271
+ ]);
272
+ /** When a stamp is written: on create, when a column changes to a value, or when a column is first filled. */
273
+ export const stampTriggerSchema = z.union([
274
+ z.literal('create'),
275
+ z.object({ column: refSchema, values: z.array(scalarSchema).min(1).max(16) }).strict(),
276
+ z.object({ column: refSchema, filled: z.literal(true) }).strict(),
277
+ ]);
204
278
  export const columnRulesSchema = z
205
279
  .object({
206
280
  options: z
@@ -240,14 +314,59 @@ export const columnRulesSchema = z
240
314
  .object({ via: refSchema, from: refSchema, mode: z.enum(['default', 'always']).optional() })
241
315
  .strict()
242
316
  .optional(),
243
- sequence: z.object({ start: z.number().int().min(1).optional() }).strict().optional(),
317
+ /**
318
+ * A value filled on create when the writer gives none (and a `copy` on the
319
+ * same column came back empty): the connection's currency, a column of the
320
+ * app's settings row, or a setting of an add-on the app requires.
321
+ */
322
+ default: z
323
+ .object({ from: z.union([z.literal('connection.currency'), settingSourceSchema]) })
324
+ .strict()
325
+ .optional(),
326
+ /**
327
+ * A running number. `gapless` numbers are taken inside the write that
328
+ * creates the row, so the series never skips or repeats (an invoice's);
329
+ * `scope` numbers per parent row (a deliverable's versions v1, v2 …);
330
+ * `startSetting` reads the first number from a setting.
331
+ */
332
+ sequence: z
333
+ .object({
334
+ start: z.number().int().min(1).optional(),
335
+ gapless: z.literal(true).optional(),
336
+ startSetting: settingSourceSchema.optional(),
337
+ scope: refSchema.optional(),
338
+ })
339
+ .strict()
340
+ .optional(),
341
+ /**
342
+ * A text column written from a running number of the same row: the
343
+ * prefix, then the digits padded (`INV-2042`). The prefix may come from a
344
+ * setting, so a studio can change it; a change applies to the next number.
345
+ */
346
+ format: z
347
+ .object({
348
+ from: refSchema,
349
+ prefix: z.string().regex(/^[A-Za-z0-9_/.-]{0,12}$/, 'a prefix of up to 12 letters, digits and - _ / .').optional(),
350
+ prefixSetting: settingSourceSchema.optional(),
351
+ pad: z.number().int().min(0).max(12).optional(),
352
+ })
353
+ .strict()
354
+ .optional(),
244
355
  code: z
245
356
  .object({
246
357
  prefix: z.string().regex(/^[A-Z][A-Z0-9]{0,5}-?$/, 'an upper-case prefix, e.g. MR-').optional(),
247
- length: z.number().int().min(4).max(12),
358
+ length: z.number().int().min(4).max(16),
248
359
  })
249
360
  .strict()
250
361
  .optional(),
362
+ /** A value Adminium works out from the row's other columns (see `formula.ts`). */
363
+ formula: formulaExprSchema.optional(),
364
+ /**
365
+ * How a text value is stored whoever writes it: `trim` without spaces at
366
+ * either end, `email` trimmed and in lower case — so a unique address and
367
+ * a person signing in with it agree on every database.
368
+ */
369
+ normalize: z.enum(['trim', 'email']).optional(),
251
370
  rollup: z
252
371
  .object({
253
372
  /** The child table, its foreign key back to this row, and what to add up. */
@@ -258,6 +377,32 @@ export const columnRulesSchema = z
258
377
  times: refSchema.optional(),
259
378
  /** A child row whose column holds a value is left out — a voided line (`voided_at`). */
260
379
  unlessSet: refSchema.optional(),
380
+ /** Only child rows whose column equals the value are added up (`voided = false`). */
381
+ where: z.object({ column: refSchema, eq: scalarSchema }).strict().optional(),
382
+ /**
383
+ * A second column of this row kept in step: `of − Σminus − total`
384
+ * (`balance = fee − waived − paid`). Adminium writes it; nobody else may.
385
+ */
386
+ balance: z
387
+ .object({ column: refSchema, of: refSchema, minus: z.array(refSchema).max(4).optional() })
388
+ .strict()
389
+ .optional(),
390
+ /** A child write that would take the balance below zero is refused. */
391
+ cap: z.literal(true).optional(),
392
+ })
393
+ .strict()
394
+ .optional(),
395
+ /**
396
+ * A value Adminium writes when something happens: the moment, or who did
397
+ * it, on a create or when another column changes to one of `values`
398
+ * (`checked_in_at` when `status` becomes `checked_in`). `byOrigin` writes
399
+ * one value for a public write and another for staff. A public write never
400
+ * stamps a person: a browser key is nobody.
401
+ */
402
+ stamp: z
403
+ .object({
404
+ set: stampSetSchema,
405
+ on: z.union([stampTriggerSchema, z.array(stampTriggerSchema).min(2).max(3)]),
261
406
  })
262
407
  .strict()
263
408
  .optional(),
@@ -269,6 +414,17 @@ export const columnRulesSchema = z
269
414
  * mark it otherwise.
270
415
  */
271
416
  personal: z.boolean().optional(),
417
+ /**
418
+ * A date that may never be later than today, on the venue's calendar — a
419
+ * payment is recorded when it came in, not when it might.
420
+ */
421
+ notAfter: z.literal('today').optional(),
422
+ /**
423
+ * A date that may never be earlier than another: a column of the same row,
424
+ * or — with `via`, a foreign key of this row — a column of the row it
425
+ * points at (a payment is never dated before its invoice was issued).
426
+ */
427
+ notBefore: z.object({ column: refSchema, via: refSchema.optional() }).strict().optional(),
272
428
  })
273
429
  .strict();
274
430
  /**
@@ -374,6 +530,18 @@ export const requiredColumnSchema = z
374
530
  * row, so a few long columns would refuse the table.
375
531
  */
376
532
  maxLength: z.number().int().min(1).max(MAX_TEXT_LENGTH).optional(),
533
+ /**
534
+ * `decimal` / `money` only: the places kept after the point, 0–4, or
535
+ * `"currency"` — the decimals of the row's own `currency` column (JPY 0,
536
+ * EUR 2, KWD 3), else the connection's currency, else 2. Without it a
537
+ * decimal keeps four places. Totals, rollups and formulas round to it.
538
+ */
539
+ scale: z.union([z.number().int().min(0).max(4), z.literal('currency')]).optional(),
540
+ /**
541
+ * No two rows may hold the same value (empty values excepted). A text
542
+ * column needs `maxLength`: MySQL indexes no unbounded text.
543
+ */
544
+ unique: z.literal(true).optional(),
377
545
  /** Rules Adminium keeps on the column once installed (see `columnRulesSchema`). */
378
546
  rules: columnRulesSchema.optional(),
379
547
  /**
@@ -395,6 +563,14 @@ export const requiredColumnSchema = z
395
563
  .refine((c) => c.maxLength === undefined || c.type === 'text', {
396
564
  message: 'maxLength applies to a text column only',
397
565
  path: ['maxLength'],
566
+ })
567
+ .refine((c) => c.scale === undefined || c.type === 'decimal' || c.type === 'money', {
568
+ message: 'scale applies to a decimal or money column only',
569
+ path: ['scale'],
570
+ })
571
+ .refine((c) => c.unique === undefined || (c.role !== 'pk' && !['json', 'blob'].includes(c.type) && (c.type !== 'text' || c.maxLength !== undefined)), {
572
+ message: 'unique needs a column that can be indexed: not the key, not json or blob, text with maxLength',
573
+ path: ['unique'],
398
574
  })
399
575
  .superRefine((c, ctx) => {
400
576
  const issue = defaultIssue(c);
@@ -411,6 +587,23 @@ export const requiredTableSchema = z
411
587
  */
412
588
  shape: z.string().regex(/^[a-z][a-z0-9-]*@\d+$/, 'a shape is <name>@<version>').optional(),
413
589
  capacity: capacitySchema.optional(),
590
+ /** Booking people: no two counted rows of one resource may overlap (see `booking.ts`). */
591
+ booking: bookingSchema.optional(),
592
+ /** States, the moves between them, locks and no-delete (see `states.ts`). */
593
+ states: statesSchema.optional(),
594
+ /**
595
+ * A table built on a shape an add-on defines, `<addOn>/<name>@<version>`
596
+ * (`invoices/invoice@1`), and which part of it (`document`, `lines`).
597
+ * The table spells out the shape's columns and rules beside its own; the
598
+ * install checks them against the installed add-on. Not the same as
599
+ * `shape`, which lets apps SHARE one table: two apps built on one shape
600
+ * get a table each.
601
+ */
602
+ builtOn: z
603
+ .string()
604
+ .regex(/^[a-z][a-z0-9-]{1,79}\/[a-z][a-z0-9-]*@[1-9]\d*$/, 'builtOn is <add-on key>/<shape name>@<version>')
605
+ .optional(),
606
+ part: z.string().regex(/^[a-z][a-z0-9_]*$/, 'a part of the shape').optional(),
414
607
  /**
415
608
  * What a person calls ONE row ("Category") and the table ("Categories"),
416
609
  * in every language the app speaks: a form's title and button, a page's
@@ -431,6 +624,18 @@ export const requiredTableSchema = z
431
624
  .refine((t) => t.labelPlural === undefined || t.label !== undefined, {
432
625
  message: 'labelPlural needs a label',
433
626
  path: ['labelPlural'],
627
+ })
628
+ .refine((t) => t.capacity === undefined || t.booking === undefined, {
629
+ message: 'a table has a capacity or a booking rule, not both',
630
+ path: ['booking'],
631
+ })
632
+ .refine((t) => (t.builtOn === undefined) === (t.part === undefined), {
633
+ message: 'a table built on a shape names the part it is, and only such a table does',
634
+ path: ['part'],
635
+ })
636
+ .refine((t) => t.builtOn === undefined || t.shape === undefined, {
637
+ message: 'a table is built on an add-on\'s shape or shared under a shape, not both',
638
+ path: ['builtOn'],
434
639
  });
435
640
  export const requiredSchemaSchema = z
436
641
  .object({
@@ -447,6 +652,99 @@ export const requiredSchemaSchema = z
447
652
  message: 'duplicate table ref in requiredSchema',
448
653
  path: ['tables'],
449
654
  });
655
+ // ── shapes an add-on defines ──────────────────────────────────────────────────
656
+ /**
657
+ * One part of a shape: its columns and the rules on them, and the table's
658
+ * states. A column's `references` names another part of the same shape
659
+ * (`document`), or a part of another of the add-on's shapes
660
+ * (`quote@1/document`).
661
+ */
662
+ export const shapePartSchema = z
663
+ .object({
664
+ columns: z.array(requiredColumnSchema).min(1).max(60),
665
+ states: statesSchema.optional(),
666
+ })
667
+ .strict()
668
+ .refine((p) => new Set(p.columns.map((c) => c.ref)).size === p.columns.length, {
669
+ message: 'duplicate column ref in part',
670
+ path: ['columns'],
671
+ });
672
+ /**
673
+ * A shape an add-on defines for apps to build their tables on (see
674
+ * `shapes.ts`): named parts, the document profiles made for an app's tables
675
+ * at install (in part and column names), and the messages it sends.
676
+ */
677
+ export const shapeDefinitionSchema = z
678
+ .object({
679
+ name: z.string().regex(/^[a-z][a-z0-9-]*$/, 'a shape name is kebab-case'),
680
+ version: z.number().int().min(1).max(99),
681
+ parts: z.record(z.string().regex(/^[a-z][a-z0-9_]*$/, 'a part name is snake_case'), shapePartSchema),
682
+ documentProfiles: z
683
+ .array(appDocumentSchema.omit({ addOn: true, table: true, feature: true }).extend({ part: refSchema }).strict())
684
+ .max(8)
685
+ .optional(),
686
+ outbox: z
687
+ .object({
688
+ producers: z.array(outboxProducerSchema).min(1).max(16),
689
+ templates: z
690
+ .array(emailTemplateSchema.omit({ key: true }).extend({ kind: z.string().min(1).max(40) }).strict())
691
+ .max(16)
692
+ .optional(),
693
+ })
694
+ .strict()
695
+ .optional(),
696
+ })
697
+ .strict()
698
+ .refine((d) => Object.keys(d.parts).length > 0, { message: 'a shape has at least one part', path: ['parts'] });
699
+ /**
700
+ * Everything wrong inside an add-on's shapes: each part is checked as the
701
+ * app's tables are, with the parts of all its shapes as the tables (another
702
+ * shape's part as `<name>@<version>/<part>`), and its rules may read only the
703
+ * add-on's own settings.
704
+ */
705
+ export function shapeDefinitionIssues(addOn, settings, shapes) {
706
+ const out = [];
707
+ const seen = new Set();
708
+ shapes.forEach((shape, s) => {
709
+ const id = `${shape.name}@${String(shape.version)}`;
710
+ if (seen.has(id))
711
+ out.push({ path: ['addOn', 'shapes', s], message: `"${id}" is declared twice` });
712
+ seen.add(id);
713
+ const own = Object.entries(shape.parts).map(([ref, part]) => ({ ref, columns: part.columns, states: part.states }));
714
+ const others = shapes
715
+ .filter((other) => other !== shape)
716
+ .flatMap((other) => Object.entries(other.parts).map(([ref, part]) => ({ ref: `${other.name}@${String(other.version)}/${ref}`, columns: part.columns })));
717
+ const parts = own.map((p) => p.ref);
718
+ const issues = appReferenceIssues({ key: addOn, requiredSchema: { tables: [...own, ...others] } }, { addOn, settings });
719
+ for (const issue of issues) {
720
+ const [, , t, ...rest] = issue.path;
721
+ if (typeof t !== 'number' || t >= parts.length)
722
+ continue;
723
+ out.push({ path: ['addOn', 'shapes', s, 'parts', parts[t], ...rest], message: issue.message });
724
+ }
725
+ const index = tableIndex([...own, ...others]);
726
+ (shape.documentProfiles ?? []).forEach((profile, d) => {
727
+ const at = (...rest) => ['addOn', 'shapes', s, 'documentProfiles', d, ...rest];
728
+ if (shape.parts[profile.part] === undefined) {
729
+ out.push({ path: at('part'), message: `"${id}" has no part "${profile.part}"` });
730
+ return;
731
+ }
732
+ out.push(...mappingIssues(profile.part, profile.mapping, index, at));
733
+ });
734
+ (shape.outbox?.producers ?? []).forEach((producer, p) => {
735
+ const source = 'onCreate' in producer ? producer.onCreate : 'onChange' in producer ? producer.onChange : producer.before;
736
+ if (shape.parts[source.table] === undefined) {
737
+ out.push({ path: ['addOn', 'shapes', s, 'outbox', 'producers', p], message: `"${id}" has no part "${source.table}"` });
738
+ }
739
+ });
740
+ const kinds = new Set((shape.outbox?.producers ?? []).map((producer) => producer.kind));
741
+ (shape.outbox?.templates ?? []).forEach((template, k) => {
742
+ if (!kinds.has(template.kind))
743
+ out.push({ path: ['addOn', 'shapes', s, 'outbox', 'templates', k, 'kind'], message: `no producer sends "${template.kind}"` });
744
+ });
745
+ });
746
+ return out;
747
+ }
450
748
  // ── pages ────────────────────────────────────────────────────────────────────
451
749
  export const pageNavSchema = z
452
750
  .object({
@@ -472,6 +770,8 @@ export const pageSchema = z
472
770
  titles: z
473
771
  .record(z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, 'titles are keyed by BCP 47 tag'), z.string().min(1).max(120))
474
772
  .optional(),
773
+ /** The page shows only while this feature's add-ons are there (see `addOns.features`). */
774
+ feature: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a feature id').optional(),
475
775
  })
476
776
  .strict();
477
777
  // ── roles ────────────────────────────────────────────────────────────────────
@@ -484,6 +784,8 @@ export const roleSchema = z
484
784
  permissions: z.array(z.string().min(1)).optional(),
485
785
  /** Opens the app's own screens and never the dashboard (a till cashier). */
486
786
  screensOnly: z.boolean().optional(),
787
+ /** Per table, what the role's update there may write (roles.ts). */
788
+ limits: roleLimitsSchema.optional(),
487
789
  })
488
790
  .strict();
489
791
  // ── settings ─────────────────────────────────────────────────────────────────
@@ -622,55 +924,8 @@ export const navGroupSchema = z
622
924
  order: z.number().int(),
623
925
  })
624
926
  .strict();
625
- /**
626
- * What the app's public screens may do with one table, through the one
627
- * browser key the install creates. `GET` reads, `POST` creates; `PATCH` only
628
- * behind a claim (a guest changing their own booking), on a narrow writable
629
- * list. `availability` answers free or full per slot and never a row.
630
- */
631
- export const publicAccessSchema = z
632
- .object({
633
- table: refSchema,
634
- kind: z.enum(['records', 'availability']).optional(),
635
- methods: z.array(z.enum(['GET', 'POST', 'PATCH'])).min(1),
636
- select: z.array(refSchema).optional(),
637
- writable: z.array(refSchema).optional(),
638
- filters: z
639
- .array(z
640
- .object({
641
- column: refSchema,
642
- op: z.enum(['eq', 'neq', 'in', 'gte', 'lte']),
643
- value: z.union([z.string(), z.number(), z.boolean(), z.array(z.union([z.string(), z.number()]))]),
644
- })
645
- .strict())
646
- .optional(),
647
- /** Values the server writes, whatever the browser sends (`status: confirmed`). */
648
- defaults: z.record(refSchema, z.union([z.string(), z.number(), z.boolean()])).optional(),
649
- /** Proving you know a row's details, e.g. `{match: [code, mobile]}`. */
650
- claim: z.object({ match: z.array(refSchema).min(1).max(3) }).strict().optional(),
651
- /**
652
- * The confirmation Adminium emails when a guest creates a row here: the
653
- * column holding their address, the columns the email shows, the app's
654
- * one-row venue table, and the path under the guest side that manages it.
655
- */
656
- confirm: z
657
- .object({
658
- template: z.enum(['booking-confirmation']),
659
- to: refSchema,
660
- code: refSchema.optional(),
661
- when: refSchema.optional(),
662
- party: refSchema.optional(),
663
- name: refSchema.optional(),
664
- venue: z
665
- .object({ table: refSchema, name: refSchema.optional(), address: refSchema.optional(), phone: refSchema.optional() })
666
- .strict()
667
- .optional(),
668
- link: z.string().max(200).optional(),
669
- })
670
- .strict()
671
- .optional(),
672
- })
673
- .strict();
927
+ /** What the app's public screens may do (see `public-access.ts`). */
928
+ export { publicAccessSchema, publicKeysSchema };
674
929
  /** The bundled sample data file, inside the package's `seeds/` folder. */
675
930
  export const sampleDataSchema = z
676
931
  .object({
@@ -720,24 +975,154 @@ export function prefixFor(appKey) {
720
975
  export const MAX_TABLE_NAME = 63;
721
976
  /**
722
977
  * Every name a manifest's own blocks use must name something the manifest
723
- * declares: a rule's columns, a capacity's columns, a public table and the
724
- * columns it reads and writes. Checked here so an app's CI refuses a typo long
725
- * before an operator's install would.
978
+ * declares: a rule's columns, a capacity's or a booking's columns and tables,
979
+ * a public table and the columns it reads and writes, the outbox and its
980
+ * templates. Checked here so an app's CI refuses a typo long before an
981
+ * operator's install would.
726
982
  */
727
- export function appReferenceIssues(m) {
983
+ export function appReferenceIssues(m,
984
+ /**
985
+ * When the tables are an add-on's shape rather than an app's: the add-on's
986
+ * own key and setting keys, which its rules may read, and no roles to name.
987
+ */
988
+ shapeOf) {
728
989
  const out = [];
990
+ const index = tableIndex(m.requiredSchema.tables);
729
991
  const tables = new Map(m.requiredSchema.tables.map((t) => [t.ref, t]));
730
- const has = (table, column) => tables.get(table)?.columns.some((c) => c.ref === column) ?? false;
992
+ const has = index.has;
993
+ /** A setting a rule reads must be there when the rule runs. */
994
+ const settingIssue = (source, path) => {
995
+ if ('addOn' in source) {
996
+ if (shapeOf !== undefined) {
997
+ if (source.addOn !== shapeOf.addOn)
998
+ out.push({ path, message: `a shape reads only its own add-on's settings, not "${source.addOn}"` });
999
+ else if (!shapeOf.settings.includes(source.setting))
1000
+ out.push({ path, message: `"${source.addOn}" has no setting "${source.setting}"` });
1001
+ }
1002
+ else if (!requiresAddOn(m.addOns, source.addOn)) {
1003
+ out.push({ path, message: `"${source.addOn}" is not required by the app (addOns.requires), so its setting may not be there` });
1004
+ }
1005
+ }
1006
+ else if (!has(source.table, source.column)) {
1007
+ out.push({ path, message: `"${source.table}" has no column "${source.column}"` });
1008
+ }
1009
+ };
1010
+ /** The tables the app's signed-in people are rows of (its claim identities). */
1011
+ const identityTables = new Set((m.publicAccess ?? []).filter((e) => e.claim !== undefined).map((e) => e.table));
1012
+ /** Per table, the columns Adminium decides and no writer may set. */
1013
+ const decided = new Map();
1014
+ const decide = (table, column) => {
1015
+ const set = decided.get(table) ?? new Set();
1016
+ set.add(column);
1017
+ decided.set(table, set);
1018
+ };
731
1019
  m.requiredSchema.tables.forEach((table, t) => {
732
1020
  const at = (...rest) => ['requiredSchema', 'tables', t, ...rest];
733
1021
  if (m.requiredSchema.prefixed === true && prefixFor(m.key).length + table.ref.length > MAX_TABLE_NAME) {
734
1022
  out.push({ path: at('ref'), message: `"${prefixFor(m.key)}${table.ref}" is longer than ${String(MAX_TABLE_NAME)} characters` });
735
1023
  }
1024
+ const balances = new Map();
1025
+ table.columns.forEach((column) => {
1026
+ const balance = column.rules?.rollup?.balance;
1027
+ if (balance !== undefined)
1028
+ balances.set(balance.column, column.ref);
1029
+ });
736
1030
  table.columns.forEach((column, c) => {
737
1031
  const rules = column.rules;
738
1032
  if (rules === undefined)
739
1033
  return;
740
1034
  const here = (...rest) => at('columns', c, 'rules', ...rest);
1035
+ const deciders = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default'].filter((name) => rules[name] !== undefined);
1036
+ if (deciders.length > 0)
1037
+ decide(table.ref, column.ref);
1038
+ // One rule decides a column; two would race for its value. A copy with
1039
+ // a default behind it is the one pair that means something.
1040
+ const racing = deciders.filter((name) => !(name === 'default' && rules.copy !== undefined) && !(name === 'copy' && rules.default !== undefined));
1041
+ if (racing.length > 1 && rules.stamp === undefined) {
1042
+ out.push({ path: here(), message: `a column is decided by one rule, and this one has ${racing.join(', ')}` });
1043
+ }
1044
+ if (rules.normalize !== undefined && column.type !== 'text') {
1045
+ out.push({ path: here('normalize'), message: 'only text is stored trimmed or in lower case' });
1046
+ }
1047
+ const dated = (type) => type === 'date' || type === 'timestamptz';
1048
+ if ((rules.notAfter !== undefined || rules.notBefore !== undefined) && !dated(column.type)) {
1049
+ out.push({ path: here(rules.notAfter !== undefined ? 'notAfter' : 'notBefore'), message: 'only a date is kept within dates' });
1050
+ }
1051
+ if (rules.notBefore !== undefined) {
1052
+ const bound = rules.notBefore;
1053
+ let owner = table.ref;
1054
+ if (bound.via !== undefined) {
1055
+ const via = index.column(table.ref, bound.via);
1056
+ if (via?.type !== 'fk' || via.references === undefined) {
1057
+ out.push({ path: here('notBefore', 'via'), message: `"${table.ref}.${bound.via}" is not a foreign key` });
1058
+ owner = '';
1059
+ }
1060
+ else {
1061
+ owner = via.references;
1062
+ }
1063
+ }
1064
+ if (owner !== '') {
1065
+ const other = index.column(owner, bound.column);
1066
+ if (other === undefined)
1067
+ out.push({ path: here('notBefore', 'column'), message: `"${owner}" has no column "${bound.column}"` });
1068
+ else if (!dated(other.type))
1069
+ out.push({ path: here('notBefore', 'column'), message: `"${owner}.${bound.column}" is not a date` });
1070
+ else if (bound.via === undefined && other.ref === column.ref)
1071
+ out.push({ path: here('notBefore', 'column'), message: 'a date is bounded by another column' });
1072
+ }
1073
+ }
1074
+ if (rules.default !== undefined) {
1075
+ const from = rules.default.from;
1076
+ if (from === 'connection.currency') {
1077
+ if (column.type !== 'text' || (column.maxLength !== undefined && column.maxLength < 3)) {
1078
+ out.push({ path: here('default', 'from'), message: 'a currency is three letters: a text column of at least 3 characters' });
1079
+ }
1080
+ }
1081
+ else {
1082
+ settingIssue(from, here('default', 'from'));
1083
+ }
1084
+ if (column.nullable !== true)
1085
+ out.push({ path: here('default'), message: 'a column filled from elsewhere may start empty, so make it nullable' });
1086
+ }
1087
+ if (rules.sequence !== undefined) {
1088
+ const seq = rules.sequence;
1089
+ if (seq.start !== undefined && seq.startSetting !== undefined)
1090
+ out.push({ path: here('sequence'), message: 'a running number starts at start or at startSetting, not both' });
1091
+ if (seq.gapless === true) {
1092
+ if (column.type !== 'int' && column.type !== 'bigint')
1093
+ out.push({ path: here('sequence', 'gapless'), message: 'a number without gaps is a whole number: an int column' });
1094
+ // Rows added as samples, and rows imported with a number of their own, carry none.
1095
+ if (column.nullable !== true)
1096
+ out.push({ path: here('sequence', 'gapless'), message: 'a numbered column is nullable: sample rows carry no number' });
1097
+ }
1098
+ else if (seq.scope !== undefined || seq.startSetting !== undefined) {
1099
+ out.push({ path: here('sequence'), message: 'scope and startSetting number without gaps: add gapless' });
1100
+ }
1101
+ if (seq.startSetting !== undefined)
1102
+ settingIssue(seq.startSetting, here('sequence', 'startSetting'));
1103
+ if (seq.scope !== undefined) {
1104
+ const scope = index.column(table.ref, seq.scope);
1105
+ if (scope?.type !== 'fk')
1106
+ out.push({ path: here('sequence', 'scope'), message: `"${seq.scope}" is not a foreign key of "${table.ref}"` });
1107
+ }
1108
+ }
1109
+ if (rules.format !== undefined) {
1110
+ const f = rules.format;
1111
+ const from = index.column(table.ref, f.from);
1112
+ if (from?.rules?.sequence === undefined)
1113
+ out.push({ path: here('format', 'from'), message: `"${f.from}" is not a running number of "${table.ref}"` });
1114
+ if (column.type !== 'text')
1115
+ out.push({ path: here('format'), message: 'a number with a prefix is written into a text column' });
1116
+ if (f.prefix !== undefined && f.prefixSetting !== undefined)
1117
+ out.push({ path: here('format'), message: 'a prefix is written here or read from a setting, not both' });
1118
+ if (f.prefixSetting !== undefined)
1119
+ settingIssue(f.prefixSetting, here('format', 'prefixSetting'));
1120
+ if (column.maxLength !== undefined && (f.prefix ?? '').length + (f.pad ?? 0) > column.maxLength) {
1121
+ out.push({ path: here('format'), message: `"${table.ref}.${column.ref}" holds ${String(column.maxLength)} characters, fewer than the prefix and the padding` });
1122
+ }
1123
+ if (column.nullable !== true)
1124
+ out.push({ path: here('format'), message: 'a numbered column is nullable: sample rows carry their own text' });
1125
+ }
741
1126
  // A list the app ships, or one Adminium has built in; nothing else exists
742
1127
  // on every install.
743
1128
  if (rules.options !== undefined && 'list' in rules.options) {
@@ -770,7 +1155,161 @@ export function appReferenceIssues(m) {
770
1155
  if (!has(r.from, name))
771
1156
  out.push({ path: here('rollup'), message: `"${r.from}" has no column "${name}"` });
772
1157
  }
1158
+ if (r.where !== undefined) {
1159
+ const filter = index.column(r.from, r.where.column);
1160
+ if (filter === undefined)
1161
+ out.push({ path: here('rollup', 'where'), message: `"${r.from}" has no column "${r.where.column}"` });
1162
+ else if (!valueFits(filter, r.where.eq))
1163
+ out.push({ path: here('rollup', 'where'), message: `${JSON.stringify(r.where.eq)} is not a value of "${r.from}.${r.where.column}"` });
1164
+ // An empty value equals nothing: a row left empty would drop out of the total unseen.
1165
+ else if (filter.nullable === true)
1166
+ out.push({ path: here('rollup', 'where'), message: `"${r.from}.${r.where.column}" may be empty, so a row could drop out of the total; make it not nullable` });
1167
+ }
1168
+ }
1169
+ if (r.balance !== undefined) {
1170
+ const b = r.balance;
1171
+ decide(table.ref, b.column);
1172
+ for (const [name, ref] of [['column', b.column], ['of', b.of], ...(b.minus ?? []).map((x) => ['minus', x])]) {
1173
+ const found = index.column(table.ref, ref);
1174
+ if (found === undefined)
1175
+ out.push({ path: here('rollup', 'balance', name), message: `"${table.ref}" has no column "${ref}"` });
1176
+ else if (!NUMERIC_TYPES.includes(found.type))
1177
+ out.push({ path: here('rollup', 'balance', name), message: `"${table.ref}.${ref}" is not a number` });
1178
+ }
1179
+ if ([b.of, ...(b.minus ?? [])].includes(b.column) || b.column === column.ref) {
1180
+ out.push({ path: here('rollup', 'balance', 'column'), message: 'the balance is a column of its own' });
1181
+ }
1182
+ if (index.column(table.ref, b.column)?.rules !== undefined) {
1183
+ out.push({ path: here('rollup', 'balance', 'column'), message: `"${b.column}" is the balance Adminium keeps; it takes no rules of its own` });
1184
+ }
773
1185
  }
1186
+ // A cap holds a balance at zero: this rollup's, or the one that takes
1187
+ // this total away (a write-off is capped by the balance it lowers).
1188
+ if (r.cap === true && r.balance === undefined) {
1189
+ const capped = table.columns.some((x) => x.rules?.rollup?.balance?.minus?.includes(column.ref) === true);
1190
+ if (!capped)
1191
+ out.push({ path: here('rollup', 'cap'), message: 'a cap needs a balance: declare one here, or subtract this total in one' });
1192
+ }
1193
+ }
1194
+ if (rules.stamp !== undefined) {
1195
+ const stamp = rules.stamp;
1196
+ const set = stamp.set;
1197
+ if (set === 'now' && column.type !== 'timestamptz') {
1198
+ out.push({ path: here('stamp', 'set'), message: 'a "now" stamp needs a timestamptz column' });
1199
+ }
1200
+ else if (set === 'today' && column.type !== 'date') {
1201
+ out.push({ path: here('stamp', 'set'), message: 'a "today" stamp needs a date column' });
1202
+ }
1203
+ else if ((set === 'user-name' || set === 'user-id') && column.type !== 'text') {
1204
+ out.push({ path: here('stamp', 'set'), message: `a "${set}" stamp needs a text column` });
1205
+ }
1206
+ else if (typeof set === 'object' && 'byOrigin' in set) {
1207
+ for (const value of [set.byOrigin.public, ...(set.byOrigin.staff === undefined ? [] : [set.byOrigin.staff])]) {
1208
+ if (!valueFits(column, value))
1209
+ out.push({ path: here('stamp', 'set'), message: `"${value}" is not a value of "${table.ref}.${column.ref}"` });
1210
+ }
1211
+ }
1212
+ else if (typeof set === 'object' && 'copy' in set) {
1213
+ const source = index.column(table.ref, set.copy);
1214
+ if (source === undefined)
1215
+ out.push({ path: here('stamp', 'set', 'copy'), message: `"${table.ref}" has no column "${set.copy}"` });
1216
+ else if (source.ref === column.ref)
1217
+ out.push({ path: here('stamp', 'set', 'copy'), message: 'a stamp copies another column' });
1218
+ else if (source.type !== column.type)
1219
+ out.push({ path: here('stamp', 'set', 'copy'), message: `"${table.ref}.${set.copy}" is a ${source.type}, and "${column.ref}" a ${column.type}` });
1220
+ }
1221
+ else if (typeof set === 'object' && 'claim' in set) {
1222
+ if (column.type !== 'text')
1223
+ out.push({ path: here('stamp', 'set'), message: 'a stamp from the signed-in person needs a text column' });
1224
+ if (![...identityTables].some((identity) => has(identity, set.claim))) {
1225
+ out.push({ path: here('stamp', 'set', 'claim'), message: `no table the app's people sign in as has a column "${set.claim}"` });
1226
+ }
1227
+ }
1228
+ else if (typeof set === 'object' && 'addDays' in set) {
1229
+ const a = set.addDays;
1230
+ if (column.type !== 'date')
1231
+ out.push({ path: here('stamp', 'set'), message: 'a date worked out from another needs a date column' });
1232
+ const from = index.column(table.ref, a.date);
1233
+ if (from === undefined)
1234
+ out.push({ path: here('stamp', 'set', 'addDays', 'date'), message: `"${table.ref}" has no column "${a.date}"` });
1235
+ else if (from.type !== 'date' && from.type !== 'timestamptz')
1236
+ out.push({ path: here('stamp', 'set', 'addDays', 'date'), message: `"${table.ref}.${a.date}" is not a date` });
1237
+ if (typeof a.days === 'string') {
1238
+ const days = index.column(table.ref, a.days);
1239
+ if (days === undefined) {
1240
+ out.push({ path: here('stamp', 'set', 'addDays', 'days'), message: `"${table.ref}" has no column "${a.days}"` });
1241
+ }
1242
+ else if (days.type === 'enum' || days.type === 'text') {
1243
+ const missing = (days.enum ?? []).filter((value) => a.map?.[value] === undefined);
1244
+ if (a.map === undefined || missing.length > 0) {
1245
+ out.push({ path: here('stamp', 'set', 'addDays', 'map'), message: `each value of "${table.ref}.${a.days}" needs its days${missing.length > 0 ? ` (${missing.join(', ')})` : ''}` });
1246
+ }
1247
+ }
1248
+ else if (days.type !== 'int' && days.type !== 'bigint') {
1249
+ out.push({ path: here('stamp', 'set', 'addDays', 'days'), message: `"${table.ref}.${a.days}" is neither a number of days nor a choice with a map` });
1250
+ }
1251
+ }
1252
+ }
1253
+ else if (typeof set === 'object' && 'hashOf' in set) {
1254
+ const h = set.hashOf;
1255
+ if (column.type !== 'text' || (column.maxLength !== undefined && column.maxLength < 64)) {
1256
+ out.push({ path: here('stamp', 'set'), message: 'a fingerprint is 64 characters: a text column of at least 64' });
1257
+ }
1258
+ for (const ref of h.columns)
1259
+ if (!has(table.ref, ref))
1260
+ out.push({ path: here('stamp', 'set', 'hashOf', 'columns'), message: `"${table.ref}" has no column "${ref}"` });
1261
+ const childIssues = (parent, child, path) => {
1262
+ if (index.table(child.table) === undefined) {
1263
+ out.push({ path, message: `"${child.table}" is not a table of this manifest` });
1264
+ return;
1265
+ }
1266
+ const via = index.column(child.table, child.via);
1267
+ if (via?.type !== 'fk' || via.references !== parent)
1268
+ out.push({ path, message: `"${child.table}.${child.via}" does not point at "${parent}"` });
1269
+ for (const ref of [...child.columns, ...(child.orderBy === undefined ? [] : [child.orderBy])]) {
1270
+ if (!has(child.table, ref))
1271
+ out.push({ path, message: `"${child.table}" has no column "${ref}"` });
1272
+ }
1273
+ };
1274
+ (h.children ?? []).forEach((child, k) => childIssues(table.ref, child, here('stamp', 'set', 'hashOf', 'children', k)));
1275
+ (h.linked ?? []).forEach((link, k) => {
1276
+ const path = here('stamp', 'set', 'hashOf', 'linked', k);
1277
+ const via = index.column(table.ref, link.via);
1278
+ if (via?.type !== 'fk' || via.references !== link.table) {
1279
+ out.push({ path, message: `"${table.ref}.${link.via}" does not point at "${link.table}"` });
1280
+ return;
1281
+ }
1282
+ for (const ref of link.columns)
1283
+ if (!has(link.table, ref))
1284
+ out.push({ path, message: `"${link.table}" has no column "${ref}"` });
1285
+ (link.children ?? []).forEach((child, c2) => childIssues(link.table, child, [...path, 'children', c2]));
1286
+ });
1287
+ }
1288
+ const triggers = Array.isArray(stamp.on) ? stamp.on : [stamp.on];
1289
+ triggers.forEach((trigger, k) => {
1290
+ if (trigger === 'create')
1291
+ return;
1292
+ const path = Array.isArray(stamp.on) ? here('stamp', 'on', k) : here('stamp', 'on');
1293
+ const watched = index.column(table.ref, trigger.column);
1294
+ if (watched === undefined) {
1295
+ out.push({ path: [...path, 'column'], message: `"${table.ref}" has no column "${trigger.column}"` });
1296
+ }
1297
+ else if (watched.ref === column.ref) {
1298
+ out.push({ path: [...path, 'column'], message: 'a stamp watches another column' });
1299
+ }
1300
+ else if ('values' in trigger) {
1301
+ for (const value of trigger.values) {
1302
+ if (!valueFits(watched, value))
1303
+ out.push({ path: [...path, 'values'], message: `${JSON.stringify(value)} is not a value of "${table.ref}.${watched.ref}"` });
1304
+ }
1305
+ }
1306
+ else if (watched.nullable !== true) {
1307
+ out.push({ path: [...path, 'filled'], message: `"${table.ref}.${watched.ref}" is never empty, so it is never first filled` });
1308
+ }
1309
+ });
1310
+ const others = ['copy', 'sequence', 'code', 'rollup', 'formula', 'format', 'default'].filter((name) => rules[name] !== undefined);
1311
+ if (others.length > 0)
1312
+ out.push({ path: here('stamp'), message: `a stamped column is not also decided by ${others.join(', ')}` });
774
1313
  }
775
1314
  if ((rules.sequence !== undefined || rules.code !== undefined) && column.role === 'pk') {
776
1315
  out.push({ path: here(), message: 'a primary key numbers itself; it takes no sequence or code rule' });
@@ -792,64 +1331,45 @@ export function appReferenceIssues(m) {
792
1331
  }
793
1332
  }
794
1333
  }
795
- });
796
- (m.publicAccess ?? []).forEach((entry, i) => {
797
- const at = (...rest) => ['publicAccess', i, ...rest];
798
- const table = tables.get(entry.table);
799
- if (table === undefined) {
800
- out.push({ path: at('table'), message: `"${entry.table}" is not a table of this app` });
801
- return;
802
- }
803
- for (const list of ['select', 'writable']) {
804
- for (const column of entry[list] ?? []) {
805
- if (!has(entry.table, column))
806
- out.push({ path: at(list), message: `"${entry.table}" has no column "${column}"` });
807
- }
1334
+ if (table.booking !== undefined) {
1335
+ out.push(...bookingIssues(table, table.booking, index, at));
1336
+ // The late flag is Adminium's to set.
1337
+ if (table.booking.cancel?.flag !== undefined)
1338
+ decide(table.ref, table.booking.cancel.flag);
808
1339
  }
809
- for (const column of [...(entry.claim?.match ?? []), ...(entry.filters ?? []).map((f) => f.column), ...Object.keys(entry.defaults ?? {})]) {
810
- if (!has(entry.table, column))
811
- out.push({ path: at(), message: `"${entry.table}" has no column "${column}"` });
812
- }
813
- // A change to an existing row, from a browser, only behind a claim: the
814
- // guest reaches their own row and nothing else.
815
- if (entry.methods.includes('PATCH') && entry.claim === undefined) {
816
- out.push({ path: at('methods'), message: 'PATCH is allowed only with a claim' });
817
- }
818
- // Nothing a server decides may be written by a browser.
819
- for (const column of entry.writable ?? []) {
820
- const rules = table.columns.find((c) => c.ref === column)?.rules;
821
- if (rules?.copy !== undefined || rules?.sequence !== undefined || rules?.code !== undefined || rules?.rollup !== undefined) {
822
- out.push({ path: at('writable'), message: `"${column}" is decided by Adminium and cannot be written publicly` });
823
- }
1340
+ out.push(...tableFormulaIssues(table.columns, (c, ...rest) => at('columns', c, ...rest)));
1341
+ if (table.states !== undefined) {
1342
+ out.push(...statesIssues(table.ref, table.states, {
1343
+ index,
1344
+ roles: shapeOf !== undefined ? undefined : (m.roles ?? []).map((role) => role.key),
1345
+ numbered: table.columns.some((c) => c.rules?.sequence?.gapless === true),
1346
+ }, (...rest) => at('states', ...rest)));
824
1347
  }
825
- if (entry.confirm !== undefined) {
826
- const c = entry.confirm;
827
- for (const [name, column] of [['to', c.to], ['code', c.code], ['when', c.when], ['party', c.party], ['name', c.name]]) {
828
- if (column !== undefined && !has(entry.table, column))
829
- out.push({ path: at('confirm', name), message: `"${entry.table}" has no column "${column}"` });
830
- }
831
- if (!entry.methods.includes('POST'))
832
- out.push({ path: at('confirm'), message: 'a confirmation is sent on a create, and this entry creates nothing' });
833
- if (c.venue !== undefined) {
834
- if (!tables.has(c.venue.table)) {
835
- out.push({ path: at('confirm', 'venue', 'table'), message: `"${c.venue.table}" is not a table of this app` });
836
- }
837
- else {
838
- for (const [name, column] of [['name', c.venue.name], ['address', c.venue.address], ['phone', c.venue.phone]]) {
839
- if (column !== undefined && !has(c.venue.table, column)) {
840
- out.push({ path: at('confirm', 'venue', name), message: `"${c.venue.table}" has no column "${column}"` });
841
- }
842
- }
843
- }
1348
+ if (table.builtOn !== undefined && shapeOf === undefined) {
1349
+ const addOn = table.builtOn.split('/')[0];
1350
+ if (!requiresAddOn(m.addOns, addOn)) {
1351
+ out.push({ path: at('builtOn'), message: `"${addOn}" defines the shape, so the app requires it (addOns.requires)` });
844
1352
  }
845
1353
  }
846
- if (entry.kind === 'availability') {
847
- if (table.capacity === undefined)
848
- out.push({ path: at('kind'), message: `"${entry.table}" declares no capacity to answer from` });
849
- if (entry.methods.some((method) => method !== 'GET'))
850
- out.push({ path: at('methods'), message: 'availability is read-only' });
851
- }
852
1354
  });
1355
+ out.push(...publicAccessIssues(m.publicAccess ?? [], {
1356
+ index,
1357
+ decided: (table) => decided.get(table) ?? new Set(),
1358
+ answersAvailability: (table) => tables.get(table)?.capacity !== undefined || tables.get(table)?.booking !== undefined,
1359
+ publicKeys: m.publicKeys,
1360
+ roles: m.roles ?? [],
1361
+ }));
1362
+ out.push(...outboxIssues(m, index));
1363
+ out.push(...roleLimitIssues(m.roles ?? [], index));
1364
+ if (shapeOf === undefined)
1365
+ out.push(...addOnNeedsIssues(m));
1366
+ if (m.documents !== undefined) {
1367
+ out.push(...appDocumentIssues(m.documents, {
1368
+ index,
1369
+ addOns: new Set([...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key)),
1370
+ features: new Set((m.addOns?.features ?? []).map((feature) => feature.id)),
1371
+ }));
1372
+ }
853
1373
  return out;
854
1374
  }
855
1375
  export const appManifestSchema = z
@@ -879,7 +1399,17 @@ export const appManifestSchema = z
879
1399
  /** Keyed by a kebab-case name; a column names one with `options: {list: name}`. */
880
1400
  optionLists: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/, 'a list name is kebab-case'), optionListSchema).optional(),
881
1401
  publicAccess: z.array(publicAccessSchema).max(32).optional(),
1402
+ /** Browser keys besides the app's own `customer` key (see `public-access.ts`). */
1403
+ publicKeys: publicKeysSchema.optional(),
1404
+ /** The app's emails: its outbox table and what queues rows in it (see `outbox.ts`). */
1405
+ outbox: outboxSchema.optional(),
1406
+ /** The templates the outbox sends, in each language the app ships. */
1407
+ emailTemplates: z.array(emailTemplateSchema).max(32).optional(),
882
1408
  sampleData: sampleDataSchema.optional(),
1409
+ /** The add-ons the app needs, suggests, or needs for a feature (see `add-ons.ts`). */
1410
+ addOns: addOnsSchema.optional(),
1411
+ /** Document profiles on the app's own tables, drawn by an add-on (see `documents.ts`). */
1412
+ documents: z.array(appDocumentSchema).max(16).optional(),
883
1413
  })
884
1414
  .strict()
885
1415
  .refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
@@ -889,6 +1419,10 @@ export const appManifestSchema = z
889
1419
  for (const issue of appReferenceIssues(m)) {
890
1420
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
891
1421
  }
1422
+ // A calendar page's named columns, against the app's own tables.
1423
+ for (const issue of pageCalendarIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
1424
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
1425
+ }
892
1426
  });
893
1427
  /**
894
1428
  * `pages`, `roles` and `frontends` are absent from this branch on purpose, and
@@ -926,6 +1460,25 @@ export const addOnManifestSchema = z
926
1460
  .refine((m) => m.requiredSchema?.prefixed !== true, {
927
1461
  message: 'an add-on uses its host app\'s tables, so its own cannot be prefixed',
928
1462
  path: ['requiredSchema', 'prefixed'],
1463
+ })
1464
+ .superRefine((m, ctx) => {
1465
+ // `addOn.shapes` is typed loosely in the contracts package (it cannot see
1466
+ // the column schema without importing this one); it is checked in full here.
1467
+ const raw = m.addOn.shapes ?? [];
1468
+ const shapes = [];
1469
+ raw.forEach((candidate, s) => {
1470
+ const parsed = shapeDefinitionSchema.safeParse(candidate);
1471
+ if (parsed.success)
1472
+ shapes.push(parsed.data);
1473
+ else
1474
+ for (const issue of parsed.error.issues)
1475
+ ctx.addIssue({ code: 'custom', message: issue.message, path: ['addOn', 'shapes', s, ...issue.path.map((p) => (typeof p === 'symbol' ? String(p) : p))] });
1476
+ });
1477
+ if (shapes.length !== raw.length)
1478
+ return;
1479
+ for (const issue of shapeDefinitionIssues(m.key, (m.settings ?? []).map((setting) => setting.key), shapes)) {
1480
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
1481
+ }
929
1482
  });
930
1483
  /**
931
1484
  * The envelope. A document with no `kind` is treated as an app before the union
@@ -1039,19 +1592,4 @@ export function addOnIssues(m, ctx = {}) {
1039
1592
  });
1040
1593
  return out;
1041
1594
  }
1042
- /**
1043
- * Numeric semver compare on the release triple (pre-release/build ignored —
1044
- * enough for the compatibility-window and upgrade ordering checks). Returns
1045
- * <0, 0, >0. Exported for the installer's upgrade rule.
1046
- */
1047
- export function compareSemver(a, b) {
1048
- const triple = (v) => v
1049
- .split('+')[0]
1050
- .split('-')[0]
1051
- .split('.')
1052
- .map((n) => Number.parseInt(n, 10));
1053
- const [a1 = 0, a2 = 0, a3 = 0] = triple(a);
1054
- const [b1 = 0, b2 = 0, b3 = 0] = triple(b);
1055
- return a1 - b1 || a2 - b2 || a3 - b3;
1056
- }
1057
1595
  //# sourceMappingURL=schema.js.map