@adminiumjs/manifest 0.3.0 → 0.3.2

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 (51) 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/documents.d.ts +115 -0
  6. package/dist/documents.d.ts.map +1 -0
  7. package/dist/documents.js +171 -0
  8. package/dist/documents.js.map +1 -0
  9. package/dist/formula.d.ts +124 -0
  10. package/dist/formula.d.ts.map +1 -0
  11. package/dist/formula.js +428 -0
  12. package/dist/formula.js.map +1 -0
  13. package/dist/index.d.ts +10 -4
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +10 -4
  16. package/dist/index.js.map +1 -1
  17. package/dist/outbox.d.ts +354 -9
  18. package/dist/outbox.d.ts.map +1 -1
  19. package/dist/outbox.js +223 -9
  20. package/dist/outbox.js.map +1 -1
  21. package/dist/plan-model.d.ts +1 -1
  22. package/dist/plan-model.d.ts.map +1 -1
  23. package/dist/public-access.d.ts +48 -7
  24. package/dist/public-access.d.ts.map +1 -1
  25. package/dist/public-access.js +235 -28
  26. package/dist/public-access.js.map +1 -1
  27. package/dist/refs.d.ts +17 -0
  28. package/dist/refs.d.ts.map +1 -1
  29. package/dist/refs.js +12 -0
  30. package/dist/refs.js.map +1 -1
  31. package/dist/sample.d.ts +49 -1
  32. package/dist/sample.d.ts.map +1 -1
  33. package/dist/sample.js +39 -2
  34. package/dist/sample.js.map +1 -1
  35. package/dist/schema.d.ts +2501 -158
  36. package/dist/schema.d.ts.map +1 -1
  37. package/dist/schema.js +518 -26
  38. package/dist/schema.js.map +1 -1
  39. package/dist/shapes.d.ts +97 -0
  40. package/dist/shapes.d.ts.map +1 -0
  41. package/dist/shapes.js +190 -0
  42. package/dist/shapes.js.map +1 -0
  43. package/dist/states.d.ts +134 -0
  44. package/dist/states.d.ts.map +1 -0
  45. package/dist/states.js +244 -0
  46. package/dist/states.js.map +1 -0
  47. package/dist/validate.d.ts +14 -0
  48. package/dist/validate.d.ts.map +1 -1
  49. package/dist/validate.js +29 -2
  50. package/dist/validate.js.map +1 -1
  51. package/package.json +2 -2
package/dist/schema.js CHANGED
@@ -11,12 +11,16 @@
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';
14
15
  import { bookingIssues, bookingSchema } from './booking.js';
16
+ import { appDocumentIssues, appDocumentSchema, mappingIssues } from './documents.js';
17
+ import { formulaExprSchema, tableFormulaIssues } from './formula.js';
15
18
  import { pageCalendarIssues } from './page-calendar.js';
16
- import { emailTemplateSchema, outboxIssues, outboxSchema } from './outbox.js';
19
+ import { emailTemplateSchema, outboxIssues, outboxProducerSchema, outboxSchema } from './outbox.js';
17
20
  import { publicAccessIssues, publicAccessSchema, publicKeysSchema } from './public-access.js';
18
21
  import { roleLimitIssues, roleLimitsSchema } from './roles.js';
19
- import { NUMERIC_TYPES, labelsSchema, numberOrSetting, refSchema, scalarSchema, tableIndex, textOrLabels, valueFits, } from './refs.js';
22
+ import { statesIssues, statesSchema } from './states.js';
23
+ import { NUMERIC_TYPES, labelsSchema, numberOrSetting, refSchema, scalarSchema, settingSourceSchema, tableIndex, textOrLabels, valueFits, } from './refs.js';
20
24
  import { compareSemver, parseSemverRange } from './semver.js';
21
25
  export { compareSemver };
22
26
  /** Integer spec version, frozen at 1 for Adminium 1.x. */
@@ -206,6 +210,71 @@ export { labelsSchema };
206
210
  * - `personal`: whether the column is personal data, overriding the guess
207
211
  * Adminium makes from its name.
208
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
+ ]);
209
278
  export const columnRulesSchema = z
210
279
  .object({
211
280
  options: z
@@ -245,14 +314,59 @@ export const columnRulesSchema = z
245
314
  .object({ via: refSchema, from: refSchema, mode: z.enum(['default', 'always']).optional() })
246
315
  .strict()
247
316
  .optional(),
248
- 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(),
249
355
  code: z
250
356
  .object({
251
357
  prefix: z.string().regex(/^[A-Z][A-Z0-9]{0,5}-?$/, 'an upper-case prefix, e.g. MR-').optional(),
252
- length: z.number().int().min(4).max(12),
358
+ length: z.number().int().min(4).max(16),
253
359
  })
254
360
  .strict()
255
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(),
256
370
  rollup: z
257
371
  .object({
258
372
  /** The child table, its foreign key back to this row, and what to add up. */
@@ -287,14 +401,8 @@ export const columnRulesSchema = z
287
401
  */
288
402
  stamp: z
289
403
  .object({
290
- set: z.union([
291
- z.enum(['now', 'user-name', 'user-id']),
292
- z.object({ byOrigin: z.object({ public: z.string().min(1), staff: z.string().min(1) }).strict() }).strict(),
293
- ]),
294
- on: z.union([
295
- z.literal('create'),
296
- z.object({ column: refSchema, values: z.array(scalarSchema).min(1).max(16) }).strict(),
297
- ]),
404
+ set: stampSetSchema,
405
+ on: z.union([stampTriggerSchema, z.array(stampTriggerSchema).min(2).max(3)]),
298
406
  })
299
407
  .strict()
300
408
  .optional(),
@@ -306,6 +414,17 @@ export const columnRulesSchema = z
306
414
  * mark it otherwise.
307
415
  */
308
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(),
309
428
  })
310
429
  .strict();
311
430
  /**
@@ -411,6 +530,13 @@ export const requiredColumnSchema = z
411
530
  * row, so a few long columns would refuse the table.
412
531
  */
413
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(),
414
540
  /**
415
541
  * No two rows may hold the same value (empty values excepted). A text
416
542
  * column needs `maxLength`: MySQL indexes no unbounded text.
@@ -437,6 +563,10 @@ export const requiredColumnSchema = z
437
563
  .refine((c) => c.maxLength === undefined || c.type === 'text', {
438
564
  message: 'maxLength applies to a text column only',
439
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'],
440
570
  })
441
571
  .refine((c) => c.unique === undefined || (c.role !== 'pk' && !['json', 'blob'].includes(c.type) && (c.type !== 'text' || c.maxLength !== undefined)), {
442
572
  message: 'unique needs a column that can be indexed: not the key, not json or blob, text with maxLength',
@@ -459,6 +589,21 @@ export const requiredTableSchema = z
459
589
  capacity: capacitySchema.optional(),
460
590
  /** Booking people: no two counted rows of one resource may overlap (see `booking.ts`). */
461
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(),
462
607
  /**
463
608
  * What a person calls ONE row ("Category") and the table ("Categories"),
464
609
  * in every language the app speaks: a form's title and button, a page's
@@ -483,6 +628,14 @@ export const requiredTableSchema = z
483
628
  .refine((t) => t.capacity === undefined || t.booking === undefined, {
484
629
  message: 'a table has a capacity or a booking rule, not both',
485
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'],
486
639
  });
487
640
  export const requiredSchemaSchema = z
488
641
  .object({
@@ -499,6 +652,99 @@ export const requiredSchemaSchema = z
499
652
  message: 'duplicate table ref in requiredSchema',
500
653
  path: ['tables'],
501
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
+ }
502
748
  // ── pages ────────────────────────────────────────────────────────────────────
503
749
  export const pageNavSchema = z
504
750
  .object({
@@ -524,6 +770,8 @@ export const pageSchema = z
524
770
  titles: z
525
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))
526
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(),
527
775
  })
528
776
  .strict();
529
777
  // ── roles ────────────────────────────────────────────────────────────────────
@@ -732,11 +980,35 @@ export const MAX_TABLE_NAME = 63;
732
980
  * templates. Checked here so an app's CI refuses a typo long before an
733
981
  * operator's install would.
734
982
  */
735
- 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) {
736
989
  const out = [];
737
990
  const index = tableIndex(m.requiredSchema.tables);
738
991
  const tables = new Map(m.requiredSchema.tables.map((t) => [t.ref, t]));
739
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));
740
1012
  /** Per table, the columns Adminium decides and no writer may set. */
741
1013
  const decided = new Map();
742
1014
  const decide = (table, column) => {
@@ -760,8 +1032,96 @@ export function appReferenceIssues(m) {
760
1032
  if (rules === undefined)
761
1033
  return;
762
1034
  const here = (...rest) => at('columns', c, 'rules', ...rest);
763
- if (rules.copy !== undefined || rules.sequence !== undefined || rules.code !== undefined || rules.rollup !== undefined || rules.stamp !== undefined) {
1035
+ const deciders = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default'].filter((name) => rules[name] !== undefined);
1036
+ if (deciders.length > 0)
764
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' });
765
1125
  }
766
1126
  // A list the app ships, or one Adminium has built in; nothing else exists
767
1127
  // on every install.
@@ -837,31 +1197,117 @@ export function appReferenceIssues(m) {
837
1197
  if (set === 'now' && column.type !== 'timestamptz') {
838
1198
  out.push({ path: here('stamp', 'set'), message: 'a "now" stamp needs a timestamptz column' });
839
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
+ }
840
1203
  else if ((set === 'user-name' || set === 'user-id') && column.type !== 'text') {
841
1204
  out.push({ path: here('stamp', 'set'), message: `a "${set}" stamp needs a text column` });
842
1205
  }
843
- else if (typeof set === 'object') {
844
- for (const value of [set.byOrigin.public, set.byOrigin.staff]) {
1206
+ else if (typeof set === 'object' && 'byOrigin' in set) {
1207
+ for (const value of [set.byOrigin.public, ...(set.byOrigin.staff === undefined ? [] : [set.byOrigin.staff])]) {
845
1208
  if (!valueFits(column, value))
846
1209
  out.push({ path: here('stamp', 'set'), message: `"${value}" is not a value of "${table.ref}.${column.ref}"` });
847
1210
  }
848
1211
  }
849
- if (stamp.on !== 'create') {
850
- const watched = index.column(table.ref, stamp.on.column);
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);
851
1294
  if (watched === undefined) {
852
- out.push({ path: here('stamp', 'on', 'column'), message: `"${table.ref}" has no column "${stamp.on.column}"` });
1295
+ out.push({ path: [...path, 'column'], message: `"${table.ref}" has no column "${trigger.column}"` });
853
1296
  }
854
1297
  else if (watched.ref === column.ref) {
855
- out.push({ path: here('stamp', 'on', 'column'), message: 'a stamp watches another column' });
1298
+ out.push({ path: [...path, 'column'], message: 'a stamp watches another column' });
856
1299
  }
857
- else {
858
- for (const value of stamp.on.values) {
1300
+ else if ('values' in trigger) {
1301
+ for (const value of trigger.values) {
859
1302
  if (!valueFits(watched, value))
860
- out.push({ path: here('stamp', 'on', 'values'), message: `${JSON.stringify(value)} is not a value of "${table.ref}.${watched.ref}"` });
1303
+ out.push({ path: [...path, 'values'], message: `${JSON.stringify(value)} is not a value of "${table.ref}.${watched.ref}"` });
861
1304
  }
862
1305
  }
863
- }
864
- const others = ['copy', 'sequence', 'code', 'rollup'].filter((name) => rules[name] !== undefined);
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);
865
1311
  if (others.length > 0)
866
1312
  out.push({ path: here('stamp'), message: `a stamped column is not also decided by ${others.join(', ')}` });
867
1313
  }
@@ -891,6 +1337,20 @@ export function appReferenceIssues(m) {
891
1337
  if (table.booking.cancel?.flag !== undefined)
892
1338
  decide(table.ref, table.booking.cancel.flag);
893
1339
  }
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)));
1347
+ }
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)` });
1352
+ }
1353
+ }
894
1354
  });
895
1355
  out.push(...publicAccessIssues(m.publicAccess ?? [], {
896
1356
  index,
@@ -901,6 +1361,15 @@ export function appReferenceIssues(m) {
901
1361
  }));
902
1362
  out.push(...outboxIssues(m, index));
903
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
+ }
904
1373
  return out;
905
1374
  }
906
1375
  export const appManifestSchema = z
@@ -935,8 +1404,12 @@ export const appManifestSchema = z
935
1404
  /** The app's emails: its outbox table and what queues rows in it (see `outbox.ts`). */
936
1405
  outbox: outboxSchema.optional(),
937
1406
  /** The templates the outbox sends, in each language the app ships. */
938
- emailTemplates: z.array(emailTemplateSchema).max(16).optional(),
1407
+ emailTemplates: z.array(emailTemplateSchema).max(32).optional(),
939
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(),
940
1413
  })
941
1414
  .strict()
942
1415
  .refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
@@ -987,6 +1460,25 @@ export const addOnManifestSchema = z
987
1460
  .refine((m) => m.requiredSchema?.prefixed !== true, {
988
1461
  message: 'an add-on uses its host app\'s tables, so its own cannot be prefixed',
989
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
+ }
990
1482
  });
991
1483
  /**
992
1484
  * The envelope. A document with no `kind` is treated as an app before the union