@adminiumjs/manifest 0.3.16 → 0.3.18-rc.0

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 (90) hide show
  1. package/dist/adjust.d.ts +322 -0
  2. package/dist/adjust.d.ts.map +1 -0
  3. package/dist/adjust.js +512 -0
  4. package/dist/adjust.js.map +1 -0
  5. package/dist/automations.d.ts +2309 -0
  6. package/dist/automations.d.ts.map +1 -0
  7. package/dist/automations.js +384 -0
  8. package/dist/automations.js.map +1 -0
  9. package/dist/compose.d.ts.map +1 -1
  10. package/dist/compose.js +1 -0
  11. package/dist/compose.js.map +1 -1
  12. package/dist/documents.d.ts +82 -1
  13. package/dist/documents.d.ts.map +1 -1
  14. package/dist/documents.js +36 -6
  15. package/dist/documents.js.map +1 -1
  16. package/dist/index.d.ts +15 -6
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +15 -6
  19. package/dist/index.js.map +1 -1
  20. package/dist/key-bytes.d.ts +27 -0
  21. package/dist/key-bytes.d.ts.map +1 -0
  22. package/dist/key-bytes.js +48 -0
  23. package/dist/key-bytes.js.map +1 -0
  24. package/dist/ledger-indexes.d.ts +19 -0
  25. package/dist/ledger-indexes.d.ts.map +1 -0
  26. package/dist/ledger-indexes.js +74 -0
  27. package/dist/ledger-indexes.js.map +1 -0
  28. package/dist/ledgers.d.ts +691 -0
  29. package/dist/ledgers.d.ts.map +1 -0
  30. package/dist/ledgers.js +778 -0
  31. package/dist/ledgers.js.map +1 -0
  32. package/dist/look-up.d.ts +73 -0
  33. package/dist/look-up.d.ts.map +1 -0
  34. package/dist/look-up.js +140 -0
  35. package/dist/look-up.js.map +1 -0
  36. package/dist/marketplace-wire.d.ts +2 -2
  37. package/dist/outbox.d.ts +29 -3
  38. package/dist/outbox.d.ts.map +1 -1
  39. package/dist/outbox.js +94 -15
  40. package/dist/outbox.js.map +1 -1
  41. package/dist/page-config.d.ts +99 -0
  42. package/dist/page-config.d.ts.map +1 -0
  43. package/dist/page-config.js +197 -0
  44. package/dist/page-config.js.map +1 -0
  45. package/dist/plan-context.d.ts +8 -18
  46. package/dist/plan-context.d.ts.map +1 -1
  47. package/dist/plan-context.js +53 -51
  48. package/dist/plan-context.js.map +1 -1
  49. package/dist/plan-model.d.ts +8 -1
  50. package/dist/plan-model.d.ts.map +1 -1
  51. package/dist/plan.d.ts +1 -1
  52. package/dist/plan.d.ts.map +1 -1
  53. package/dist/plan.js +6 -3
  54. package/dist/plan.js.map +1 -1
  55. package/dist/public-access.d.ts +33 -5
  56. package/dist/public-access.d.ts.map +1 -1
  57. package/dist/public-access.js +82 -4
  58. package/dist/public-access.js.map +1 -1
  59. package/dist/refs.d.ts +2 -0
  60. package/dist/refs.d.ts.map +1 -1
  61. package/dist/refs.js +5 -0
  62. package/dist/refs.js.map +1 -1
  63. package/dist/roles.d.ts +46 -0
  64. package/dist/roles.d.ts.map +1 -1
  65. package/dist/roles.js +50 -0
  66. package/dist/roles.js.map +1 -1
  67. package/dist/sample.d.ts +70 -1
  68. package/dist/sample.d.ts.map +1 -1
  69. package/dist/sample.js +208 -5
  70. package/dist/sample.js.map +1 -1
  71. package/dist/schema.d.ts +9657 -2440
  72. package/dist/schema.d.ts.map +1 -1
  73. package/dist/schema.js +751 -52
  74. package/dist/schema.js.map +1 -1
  75. package/dist/state-actions.d.ts +185 -0
  76. package/dist/state-actions.d.ts.map +1 -0
  77. package/dist/state-actions.js +223 -0
  78. package/dist/state-actions.js.map +1 -0
  79. package/dist/states.d.ts +66 -0
  80. package/dist/states.d.ts.map +1 -1
  81. package/dist/states.js +31 -0
  82. package/dist/states.js.map +1 -1
  83. package/dist/validate.d.ts.map +1 -1
  84. package/dist/validate.js +22 -6
  85. package/dist/validate.js.map +1 -1
  86. package/dist/words.d.ts +33 -0
  87. package/dist/words.d.ts.map +1 -0
  88. package/dist/words.js +208 -0
  89. package/dist/words.js.map +1 -0
  90. package/package.json +2 -2
package/dist/schema.js CHANGED
@@ -8,21 +8,27 @@
8
8
  * The install PLANNER (planInstall / requiredSchema → create-or-map diff) and
9
9
  * the server-side installer are later layers that consume a validated manifest.
10
10
  */
11
- import { addOnBlockSchema, addOnCategorySchema, i18nMessageSchema, isSlotId, } from '@adminium/add-on-contracts';
11
+ import { addOnBlockSchema, addOnCategorySchema, i18nMessageSchema, isSlotId, recordTabIssues, } from '@adminium/add-on-contracts';
12
12
  import { z } from 'zod';
13
13
  import { addOnNeedsIssues, addOnsSchema, requiresAddOn } from './add-ons.js';
14
14
  import { bookingIssues, bookingSchema } from './booking.js';
15
15
  import { capacityIssues, capacitySchema, isLegacyCapacity, kindOf, rulesOf, viaIndexIssues } from './capacity.js';
16
16
  import { appDocumentIssues, appDocumentSchema, mappingIssues } from './documents.js';
17
17
  import { formulaColumns, formulaExprSchema, tableFormulaIssues } from './formula.js';
18
+ import { lookUpIssues, lookUpSchema } from './look-up.js';
18
19
  import { pageCalendarIssues } from './page-calendar.js';
20
+ import { pageConfigIssues } from './page-config.js';
19
21
  import { emailTemplateSchema, outboxIssues, outboxProducerSchema, outboxSchema } from './outbox.js';
20
22
  import { codeWhereSchema, personalColumn, publicAccessIssues, publicAccessSchema, publicKeysSchema, shareCodeColumns, unlistedColumn } from './public-access.js';
21
- import { roleLimitIssues, roleLimitsSchema } from './roles.js';
23
+ import { roleAddOnTableIssues, roleAddOnTablesSchema, roleLimitIssues, roleLimitsSchema } from './roles.js';
22
24
  import { conditionIssues, stateConditionSchema, statesIssues, statesSchema } from './states.js';
23
25
  import { MOMENT_LIMITS, clockTimeSchema, momentIssues, momentSchema, settingRefSchema } from './refs.js';
24
26
  import { NUMERIC_TYPES, labelsSchema, refSchema, scalarSchema, settingSourceSchema, tableIndex, textOrLabels, valueFits, } from './refs.js';
25
27
  import { compareSemver, parseSemverRange } from './semver.js';
28
+ import { installFloorWords } from './words.js';
29
+ import { automationIssues, manifestAutomationsSchema } from './automations.js';
30
+ import { adjustDecidedColumns, adjustIssues, adjustSchema, adjusterIssues, adjusterSchema } from './adjust.js';
31
+ import { ledgerIssues, ledgersSchema, postingIssues, postingsSchema, receiptTableIssues, wordsIssues, wordsListSchema } from './ledgers.js';
26
32
  export { compareSemver };
27
33
  /** Integer spec version, frozen at 1 for Adminium 1.x. */
28
34
  export const MANIFEST_VERSION = 1;
@@ -383,6 +389,13 @@ const dateBoundSchema = z
383
389
  strict: z.union([z.literal(true), z.array(stateConditionSchema).min(1).max(8)]).optional(),
384
390
  })
385
391
  .strict();
392
+ /** A table of an add-on, by the add-on's key and the table's own short name. */
393
+ export const addOnTableSchema = z
394
+ .object({
395
+ addOn: z.string().regex(/^[a-z][a-z0-9-]{1,79}$/, 'an add-on key'),
396
+ table: refSchema,
397
+ })
398
+ .strict();
386
399
  export const columnRulesSchema = z
387
400
  .object({
388
401
  options: z
@@ -512,7 +525,8 @@ export const columnRulesSchema = z
512
525
  lookup: z
513
526
  .object({
514
527
  from: refSchema,
515
- table: refSchema,
528
+ /** One of the manifest's own tables, or a table of an add-on it names (the column then carries the same `addOnLink`). */
529
+ table: z.union([refSchema, addOnTableSchema]),
516
530
  column: refSchema,
517
531
  where: codeWhereSchema.optional(),
518
532
  scope: z
@@ -547,6 +561,8 @@ export const columnRulesSchema = z
547
561
  .optional(),
548
562
  /** A child write that would take the balance below zero is refused. */
549
563
  cap: z.literal(true).optional(),
564
+ /** With `cap`: the cap is not judged for a row whose yes/no `column` is true (an item that may be sold below zero). */
565
+ capUnless: z.object({ column: refSchema }).strict().optional(),
550
566
  })
551
567
  .strict()
552
568
  .optional(),
@@ -604,6 +620,38 @@ export const columnRulesSchema = z
604
620
  * (`clientKey`), on a table no public entry creates rows of.
605
621
  */
606
622
  retryKey: z.literal(true).optional(),
623
+ /**
624
+ * A link into a table of an add-on the manifest names: the key of a row
625
+ * there. No foreign key is made, so the manifest installs whether or not
626
+ * the add-on is there; the link is read only while the add-on is
627
+ * installed, connected to this app and switched on.
628
+ */
629
+ addOnLink: addOnTableSchema.optional(),
630
+ /**
631
+ * A text column that holds a table's stored name (`<maker>:<table>`, or
632
+ * the table's own name when no app made it). Adminium rewrites it when
633
+ * the table is renamed.
634
+ */
635
+ tableRef: z.literal(true).optional(),
636
+ /**
637
+ * A formula column over a total of its own row whose change is told
638
+ * after the save commits (a stock level going low), so a rule or a
639
+ * screen may act on it. Up to four on a table.
640
+ */
641
+ announce: z.literal(true).optional(),
642
+ /**
643
+ * Text a person types that may hold no link and no address, on every
644
+ * write whoever makes it: `true` for a name (no digits, 80 characters),
645
+ * or how many digits and characters a note may hold.
646
+ */
647
+ plainText: z.union([z.literal(true), z.object({ digits: z.number().int().min(0).max(20).optional(), max: z.number().int().min(1).max(1000).optional() }).strict()]).optional(),
648
+ /**
649
+ * A keyed hash of the address in `of`, written by Adminium: what a
650
+ * customer is told apart by where the address itself must not travel.
651
+ */
652
+ customerKey: z.object({ of: refSchema }).strict().optional(),
653
+ /** The last four characters of the code in `of`, written by Adminium: what a list shows of a code it never shows. */
654
+ codeLast4: z.object({ of: refSchema }).strict().optional(),
607
655
  })
608
656
  .strict();
609
657
  /**
@@ -743,6 +791,8 @@ export const requiredColumnSchema = z
743
791
  if (issue !== null)
744
792
  ctx.addIssue({ code: 'custom', message: issue, path: ['default'] });
745
793
  });
794
+ /** A table's declared plain indexes: up to six sets of 1 to 4 columns, in index order. */
795
+ export const tableIndexesSchema = z.array(z.array(refSchema).min(1).max(4)).min(1).max(6);
746
796
  export const requiredTableSchema = z
747
797
  .object({
748
798
  ref: z.string().regex(/^[a-z][a-z0-9_]*$/, 'table ref must be a snake_case identifier'),
@@ -786,6 +836,15 @@ export const requiredTableSchema = z
786
836
  * never collides.
787
837
  */
788
838
  unique: z.array(z.array(refSchema).min(2).max(4)).min(1).max(8).optional(),
839
+ /**
840
+ * Plain indexes beside the ones Adminium makes for keys, links and unique
841
+ * sets: up to six sets of 1 to 4 columns a list is filtered or sorted by.
842
+ */
843
+ indexes: tableIndexesSchema.optional(),
844
+ /** What a row of this table hands to an add-on's ledger, and when (see `ledgers.ts`). */
845
+ postings: postingsSchema.optional(),
846
+ /** Where an add-on's offers and codes lower this order's price (see `adjust.ts`). */
847
+ adjust: adjustSchema.optional(),
789
848
  })
790
849
  .strict()
791
850
  .refine((t) => new Set(t.columns.map((c) => c.ref)).size === t.columns.length, { message: 'duplicate column ref in table', path: ['columns'] })
@@ -812,7 +871,46 @@ export const requiredTableSchema = z
812
871
  .superRefine((t, ctx) => {
813
872
  for (const issue of uniqueSetIssues(t))
814
873
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
874
+ for (const issue of indexSetIssues(t))
875
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
815
876
  });
877
+ /**
878
+ * Everything wrong with a table's declared indexes: a column it lacks, a
879
+ * column named twice, a set given twice, a column no index can hold, and a
880
+ * set the database indexes already (a unique set, or one unique or key
881
+ * column).
882
+ */
883
+ export function indexSetIssues(t) {
884
+ const out = [];
885
+ const keyOf = (set) => set.join('\u0000');
886
+ const uniques = new Set((t.unique ?? []).map((set) => keyOf([...set].sort())));
887
+ const seen = new Set();
888
+ (t.indexes ?? []).forEach((set, k) => {
889
+ const path = ['indexes', k];
890
+ if (new Set(set).size !== set.length)
891
+ out.push({ path, message: 'an index names each column once' });
892
+ // The order of an index's columns is part of it: (a, b) and (b, a) are two indexes.
893
+ if (seen.has(keyOf(set)))
894
+ out.push({ path, message: 'the same columns are indexed twice' });
895
+ seen.add(keyOf(set));
896
+ if (uniques.has(keyOf([...set].sort())))
897
+ out.push({ path, message: 'indexed already: these columns are a unique set' });
898
+ for (const ref of set) {
899
+ const column = t.columns.find((c) => c.ref === ref);
900
+ if (column === undefined) {
901
+ out.push({ path, message: `no column "${ref}" to index` });
902
+ continue;
903
+ }
904
+ const indexable = column.type !== 'json' && column.type !== 'blob' && (column.type !== 'text' || column.maxLength !== undefined || column.rules?.code !== undefined);
905
+ if (!indexable)
906
+ out.push({ path, message: `an index needs columns that can be indexed: not json or blob, text with maxLength ("${ref}")` });
907
+ if (set.length === 1 && (column.role === 'pk' || column.unique === true || column.rules?.code !== undefined)) {
908
+ out.push({ path, message: `indexed already: "${ref}" is unique` });
909
+ }
910
+ }
911
+ });
912
+ return out;
913
+ }
816
914
  /**
817
915
  * Everything wrong with a table's unique sets: a column it lacks, a column
818
916
  * named twice, a set given twice, a column no index can hold, a set its own
@@ -878,11 +976,18 @@ export const shapePartSchema = z
878
976
  .object({
879
977
  columns: z.array(requiredColumnSchema).min(1).max(60),
880
978
  states: statesSchema.optional(),
979
+ indexes: tableIndexesSchema.optional(),
980
+ postings: postingsSchema.optional(),
981
+ adjust: adjustSchema.optional(),
881
982
  })
882
983
  .strict()
883
984
  .refine((p) => new Set(p.columns.map((c) => c.ref)).size === p.columns.length, {
884
985
  message: 'duplicate column ref in part',
885
986
  path: ['columns'],
987
+ })
988
+ .superRefine((p, ctx) => {
989
+ for (const issue of indexSetIssues(p))
990
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
886
991
  });
887
992
  /**
888
993
  * A shape an add-on defines for apps to build their tables on (see
@@ -1001,6 +1106,8 @@ export const roleSchema = z
1001
1106
  screensOnly: z.boolean().optional(),
1002
1107
  /** Per table, what the role's update there may write (roles.ts). */
1003
1108
  limits: roleLimitsSchema.optional(),
1109
+ /** Grants on tables of add-ons the app names (roles.ts): live while that add-on is connected to the app. */
1110
+ tables: roleAddOnTablesSchema.optional(),
1004
1111
  })
1005
1112
  .strict();
1006
1113
  // ── settings ─────────────────────────────────────────────────────────────────
@@ -1158,6 +1265,14 @@ export const sampleDataSchema = z
1158
1265
  })
1159
1266
  .strict()
1160
1267
  .optional(),
1268
+ /**
1269
+ * Rows for an add-on the app names, by the add-on's key: a second sample
1270
+ * file in the app's package (see `sample.ts`). Loaded while that add-on
1271
+ * is connected to the app, and removed with the app's sample data.
1272
+ */
1273
+ addOns: z
1274
+ .record(z.string().regex(/^[a-z][a-z0-9-]{1,79}$/, 'an add-on key'), z.object({ file: z.string().regex(/^seeds\/[a-z0-9][a-z0-9._-]*\.json$/, 'a file in seeds/, ending .json') }).strict())
1275
+ .optional(),
1161
1276
  })
1162
1277
  .strict();
1163
1278
  /** Hosted-only and offline-required are mutually exclusive. */
@@ -1734,7 +1849,9 @@ shapeOf) {
1734
1849
  out.push({ path: here('requiredWhen'), message: 'a column required only sometimes may be empty the rest of the time, so make it nullable' });
1735
1850
  if (rules.required === true)
1736
1851
  out.push({ path: here('requiredWhen'), message: 'a column is required always, or only when another column says so, not both' });
1737
- if (deciders.length > 0)
1852
+ // A copy that only fills what a write leaves out still leaves a person to ask when there is nothing to copy.
1853
+ const fillsWhatIsLeftOut = deciders.length === 1 && deciders[0] === 'copy' && (rules.copy?.mode ?? 'default') === 'default' && rules.copy?.follow !== true;
1854
+ if (deciders.length > 0 && !fillsWhatIsLeftOut)
1738
1855
  out.push({ path: here('requiredWhen'), message: 'Adminium fills this column, so nobody is asked for it' });
1739
1856
  }
1740
1857
  if (rules.default !== undefined) {
@@ -1891,6 +2008,30 @@ shapeOf) {
1891
2008
  if (!capped)
1892
2009
  out.push({ path: here('rollup', 'cap'), message: 'a cap needs a balance: declare one here, or subtract this total in one' });
1893
2010
  }
2011
+ if (r.capUnless !== undefined) {
2012
+ const flag = index.column(table.ref, r.capUnless.column);
2013
+ if (r.cap !== true)
2014
+ out.push({ path: here('rollup', 'capUnless'), message: 'capUnless lifts a cap: say "cap": true beside it' });
2015
+ if (flag === undefined)
2016
+ out.push({ path: here('rollup', 'capUnless', 'column'), message: `"${table.ref}" has no column "${r.capUnless.column}"` });
2017
+ else if (flag.type !== 'bool' || flag.nullable === true)
2018
+ out.push({ path: here('rollup', 'capUnless', 'column'), message: `"${table.ref}.${flag.ref}" says yes or no for every row: a bool that is not nullable` });
2019
+ }
2020
+ // One scale: a balance, what it starts from, what it takes away and what is summed are all the same decimals.
2021
+ if (r.balance !== undefined && child !== undefined && r.sum !== undefined) {
2022
+ const scaleOf = (found) => (found === undefined || !['decimal', 'money'].includes(found.type) ? undefined : (found.scale ?? 'default'));
2023
+ const parts = [
2024
+ [`${table.ref}.${r.balance.column}`, scaleOf(index.column(table.ref, r.balance.column))],
2025
+ [`${table.ref}.${r.balance.of}`, scaleOf(index.column(table.ref, r.balance.of))],
2026
+ ...(r.balance.minus ?? []).map((ref) => [`${table.ref}.${ref}`, scaleOf(index.column(table.ref, ref))]),
2027
+ [`${table.ref}.${column.ref}`, scaleOf(column)],
2028
+ [`${r.from}.${r.sum}`, scaleOf(index.column(r.from, r.sum))],
2029
+ ];
2030
+ const scales = new Set(parts.map(([, scale]) => scale).filter((scale) => scale !== undefined));
2031
+ if (scales.size > 1 && r.times === undefined) {
2032
+ out.push({ path: here('rollup', 'balance'), message: `a balance and its parts keep one scale: ${parts.filter(([, scale]) => scale !== undefined).map(([name, scale]) => `${name} ${scale === 'default' ? '(none said)' : String(scale)}`).join(', ')}` });
2033
+ }
2034
+ }
1894
2035
  out.push(...rollupCountIssues(r, column.type, here));
1895
2036
  }
1896
2037
  if (rules.stamp !== undefined) {
@@ -2080,10 +2221,139 @@ shapeOf) {
2080
2221
  if (rules.lookup !== undefined) {
2081
2222
  out.push(...codeLookupIssues(table, column, rules.lookup, here('lookup'), { index, tables, shareCodes: (ref) => shareCodeColumns(m.publicAccess ?? [], ref) }));
2082
2223
  }
2224
+ if (rules.addOnLink !== undefined) {
2225
+ const link = rules.addOnLink;
2226
+ if (!['int', 'bigint', 'text'].includes(column.type) || column.nullable !== true || column.references !== undefined) {
2227
+ out.push({ path: here('addOnLink'), message: `a link into an add-on is a nullable int, bigint or text column with no "references": the add-on may not be there` });
2228
+ }
2229
+ const others = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'perNight'].filter((name) => rules[name] !== undefined);
2230
+ if (others.length > 0)
2231
+ out.push({ path: here('addOnLink'), message: `a link into an add-on is filled by a person or a lookup, not by ${others.join(', ')}` });
2232
+ if (shapeOf !== undefined) {
2233
+ if (link.addOn !== shapeOf.addOn)
2234
+ out.push({ path: here('addOnLink', 'addOn'), message: `a shape links only into its own add-on's tables, not "${link.addOn}"` });
2235
+ }
2236
+ else if (link.addOn === m.key) {
2237
+ out.push({ path: here('addOnLink', 'addOn'), message: 'a link into its own table is a foreign key: use type "fk" and "references"' });
2238
+ }
2239
+ else if (![...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].some((need) => need.key === link.addOn)) {
2240
+ out.push({ path: here('addOnLink', 'addOn'), message: `"${link.addOn}" is not an add-on this manifest names: add it to addOns.requires or addOns.suggests` });
2241
+ }
2242
+ }
2243
+ if (rules.tableRef !== undefined && (column.type !== 'text' || column.maxLength === undefined)) {
2244
+ out.push({ path: here('tableRef'), message: 'a table name is kept in a text column with maxLength' });
2245
+ }
2246
+ if (rules.plainText !== undefined && column.type !== 'text') {
2247
+ out.push({ path: here('plainText'), message: 'plain text is a rule of a text column' });
2248
+ }
2249
+ if (rules.announce !== undefined) {
2250
+ // Told from the settle, which has the row before and after only for a formula over one of its own totals.
2251
+ const reads = rules.formula === undefined ? [] : formulaColumns(rules.formula);
2252
+ const total = reads.some((ref) => {
2253
+ const read = index.column(table.ref, ref);
2254
+ return read?.rules?.rollup !== undefined || table.columns.some((x) => x.rules?.rollup?.balance?.column === ref);
2255
+ });
2256
+ if (rules.formula === undefined || !total) {
2257
+ out.push({ path: here('announce'), message: 'a change is announced of a formula column that reads a total of its own row (a rollup, or its balance)' });
2258
+ }
2259
+ if (column.type === 'money')
2260
+ out.push({ path: here('announce'), message: 'a money total is not announced: announce a flag or a state worked out from it (an int)' });
2261
+ }
2262
+ if (rules.customerKey !== undefined) {
2263
+ const of = index.column(table.ref, rules.customerKey.of);
2264
+ if (column.type !== 'text' || column.maxLength !== 64 || column.nullable !== true) {
2265
+ out.push({ path: here('customerKey'), message: 'a customer key is a nullable text column of 64 characters' });
2266
+ }
2267
+ if (of === undefined)
2268
+ out.push({ path: here('customerKey', 'of'), message: `"${table.ref}" has no column "${rules.customerKey.of}"` });
2269
+ else if (of.type !== 'text' || of.ref === column.ref)
2270
+ out.push({ path: here('customerKey', 'of'), message: `"${table.ref}.${of.ref}" is not a text column holding an address` });
2271
+ const others = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup', 'perNight'].filter((name) => rules[name] !== undefined);
2272
+ if (others.length > 0)
2273
+ out.push({ path: here('customerKey'), message: `a customer key is worked out from its address, not also decided by ${others.join(', ')}` });
2274
+ decide(table.ref, column.ref);
2275
+ }
2276
+ if (rules.codeLast4 !== undefined) {
2277
+ const of = index.column(table.ref, rules.codeLast4.of);
2278
+ if (column.type !== 'text' || column.nullable !== true)
2279
+ out.push({ path: here('codeLast4'), message: 'the last four of a code are kept in a nullable text column' });
2280
+ if (of === undefined)
2281
+ out.push({ path: here('codeLast4', 'of'), message: `"${table.ref}" has no column "${rules.codeLast4.of}"` });
2282
+ else if (of.rules?.code === undefined || of.ref === column.ref)
2283
+ out.push({ path: here('codeLast4', 'of'), message: `"${table.ref}.${of.ref}" is not a code Adminium makes (rules.code)` });
2284
+ const others = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup', 'perNight'].filter((name) => rules[name] !== undefined);
2285
+ if (others.length > 0)
2286
+ out.push({ path: here('codeLast4'), message: `the last four of a code are copied from it, not also decided by ${others.join(', ')}` });
2287
+ decide(table.ref, column.ref);
2288
+ }
2083
2289
  });
2084
2290
  if (table.columns.filter((column) => column.rules?.lookup !== undefined).length > 2) {
2085
2291
  out.push({ path: at('columns'), message: 'a table resolves at most two typed codes' });
2086
2292
  }
2293
+ if (table.columns.filter((column) => column.rules?.announce !== undefined).length > 4) {
2294
+ out.push({ path: at('columns'), message: 'a table announces at most four columns' });
2295
+ }
2296
+ if (table.adjust !== undefined) {
2297
+ // The reductions, who gave one, whether the customer was proved, the links a typed code fills: all Adminium's.
2298
+ for (const [of, columns] of adjustDecidedColumns(table.ref, table.adjust))
2299
+ for (const ref of columns)
2300
+ decide(of, ref);
2301
+ const named = new Set([...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key));
2302
+ out.push(...adjustIssues(table, table.adjust, {
2303
+ key: shapeOf?.addOn ?? m.key,
2304
+ kind: shapeOf !== undefined || m.ledgers !== undefined ? 'add-on' : 'app',
2305
+ tables: tables,
2306
+ named,
2307
+ features: new Map((m.addOns?.features ?? []).map((feature) => [feature.id, feature.requires])),
2308
+ formulaReads: (of, ref) => {
2309
+ const formula = index.column(of, ref)?.rules?.formula;
2310
+ return formula === undefined ? null : formulaColumns(formula);
2311
+ },
2312
+ publicWritable: (of) => new Set((m.publicAccess ?? []).filter((entry) => entry.table === of).flatMap((entry) => entry.writable ?? [])),
2313
+ }, at));
2314
+ }
2315
+ if (table.postings !== undefined) {
2316
+ // What a posting makes Adminium's own: how long a hold lasts, and an amount its ledger decides — on the row, or on the parent its lines belong to.
2317
+ for (const [p, posting] of table.postings.entries()) {
2318
+ const parentRef = posting.via === undefined ? undefined : table.columns.find((x) => x.ref === posting.via)?.references;
2319
+ // A receipt names its parent row, its line and its rule, not the lines' table: two tables of lines under one parent need two rule ids.
2320
+ if (parentRef !== undefined) {
2321
+ const twin = [...tables.values()].find((other) => other.ref < table.ref && (other.postings ?? []).some((theirs) => theirs.id === posting.id && theirs.via !== undefined && other.columns.find((x) => x.ref === theirs.via)?.references === parentRef));
2322
+ if (twin !== undefined)
2323
+ out.push({ path: at('postings', p, 'id'), message: `"${twin.ref}" has a posting "${posting.id}" for lines of "${parentRef}" too: give the two different ids` });
2324
+ }
2325
+ const own = (mapping) => {
2326
+ if (typeof mapping === 'string')
2327
+ decide(table.ref, mapping);
2328
+ else if (parentRef !== undefined && typeof mapping === 'object' && mapping !== null && 'parent' in mapping)
2329
+ decide(parentRef, String(mapping.parent));
2330
+ };
2331
+ if (posting.heldUntil !== undefined)
2332
+ own(posting.heldUntil);
2333
+ const action = (m.ledgers ?? []).find((ledger) => ledger.id === posting.into.ledger && posting.into.addOn === m.key)?.actions[posting.into.action];
2334
+ for (const rule of action?.decides ?? []) {
2335
+ const mapping = posting.map[rule.input];
2336
+ if (mapping === undefined)
2337
+ continue;
2338
+ own(mapping);
2339
+ // Adminium writes the amount there: never over the row's key, nor over the state its moves are judged by.
2340
+ if (typeof mapping !== 'string')
2341
+ continue;
2342
+ if (table.columns.find((x) => x.ref === mapping)?.role === 'pk')
2343
+ out.push({ path: at('postings', p, 'map', rule.input), message: `"${rule.input}" is decided by Adminium and written to "${table.ref}.${mapping}", which is the row's key` });
2344
+ else if (table.states?.column === mapping)
2345
+ out.push({ path: at('postings', p, 'map', rule.input), message: `"${rule.input}" is decided by Adminium and written to "${table.ref}.${mapping}", which keeps the row's state` });
2346
+ }
2347
+ }
2348
+ out.push(...postingIssues(table, {
2349
+ key: shapeOf?.addOn ?? m.key,
2350
+ kind: shapeOf !== undefined || m.ledgers !== undefined ? 'add-on' : 'app',
2351
+ tables: tables,
2352
+ named: new Set([...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key)),
2353
+ features: new Map((m.addOns?.features ?? []).map((feature) => [feature.id, feature.requires])),
2354
+ ledgers: m.ledgers ?? [],
2355
+ }, at));
2356
+ }
2087
2357
  if (table.capacity !== undefined)
2088
2358
  out.push(...capacityIssues(table, table.capacity, index, at));
2089
2359
  if (table.booking !== undefined) {
@@ -2105,6 +2375,8 @@ shapeOf) {
2105
2375
  bookedOf: (ref) => tables.get(ref)?.booking !== undefined,
2106
2376
  lineOf: (ref) => [...tables.values()].find((other) => other.states?.children?.[ref] !== undefined)?.ref,
2107
2377
  outboxTable: m.outbox?.table,
2378
+ pages: new Set((m.pages ?? []).flatMap((page) => ('ref' in page && typeof page.ref === 'string' ? [page.ref] : []))),
2379
+ codePages: new Set(m.codePages ?? []),
2108
2380
  }, (...rest) => at('states', ...rest)));
2109
2381
  // A late move's flag is Adminium's to set, as a booking's is.
2110
2382
  for (const late of table.states.late ?? [])
@@ -2123,6 +2395,7 @@ shapeOf) {
2123
2395
  index,
2124
2396
  decided: (table) => decided.get(table) ?? new Set(),
2125
2397
  answersAvailability: (table) => tables.get(table)?.capacity !== undefined || tables.get(table)?.booking !== undefined,
2398
+ addOns: new Set([...(m.kind === 'add-on' ? [m.key] : []), ...[...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key)]),
2126
2399
  capacityOf: (table) => tables.get(table)?.capacity,
2127
2400
  figures: figureColumns(m.requiredSchema.tables),
2128
2401
  mailsOnCreate: (table) => (m.outbox?.producers ?? []).some((producer) => 'onCreate' in producer && producer.onCreate.table === table),
@@ -2135,12 +2408,36 @@ shapeOf) {
2135
2408
  out.push(...slotPartyIssues(m));
2136
2409
  out.push(...outboxIssues(m, index));
2137
2410
  out.push(...roleLimitIssues(m.roles ?? [], index));
2411
+ // A role's grants on an add-on's tables: an app's to give, on the add-ons it names.
2412
+ if ((m.roles ?? []).some((role) => role.tables !== undefined)) {
2413
+ const roles = (m.roles ?? []);
2414
+ if (m.kind === 'add-on') {
2415
+ roles.forEach((role, r) => {
2416
+ if (role.tables !== undefined)
2417
+ out.push({ path: ['roles', r, 'tables'], message: 'an add-on\'s role grants its own tables (permissions): "tables" is how an app\'s role reaches an add-on\'s' });
2418
+ });
2419
+ }
2420
+ else {
2421
+ out.push(...roleAddOnTableIssues(roles, new Set([...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key))));
2422
+ }
2423
+ }
2424
+ // The rules it ships: about its own tables, roles and templates, and only what a rule can do.
2425
+ if (m.automations !== undefined) {
2426
+ out.push(...automationIssues({
2427
+ automations: m.automations,
2428
+ tables: m.requiredSchema.tables,
2429
+ roles: (m.roles ?? []).map((role) => role.key),
2430
+ templates: m.emailTemplates ?? [],
2431
+ decided: (table) => decided.get(table) ?? new Set(),
2432
+ }));
2433
+ }
2138
2434
  if (shapeOf === undefined)
2139
2435
  out.push(...addOnNeedsIssues(m));
2140
2436
  if (m.documents !== undefined) {
2141
2437
  out.push(...appDocumentIssues(m.documents, {
2142
2438
  index,
2143
- addOns: new Set([...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key)),
2439
+ // An add-on draws its own documents itself, and another add-on's only when it suggests that one.
2440
+ addOns: new Set([...(m.kind === 'add-on' ? [m.key] : []), ...[...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key)]),
2144
2441
  features: new Set((m.addOns?.features ?? []).map((feature) => feature.id)),
2145
2442
  perNight: (ref) => new Map((tables.get(ref)?.columns ?? []).flatMap((column) => column.rules?.perNight === undefined ? [] : [[column.ref, { rateVia: column.rules.perNight.rate.via }]])),
2146
2443
  unlisted: (table, column) => unlistedColumn(m, table, column),
@@ -2209,7 +2506,8 @@ function settingTableIssues(m) {
2209
2506
  const tables = m.requiredSchema.tables;
2210
2507
  const settingsTable = m.outbox?.settings?.table;
2211
2508
  const oneRow = (ref) => {
2212
- if (ref === settingsTable)
2509
+ // An add-on's settings table is one row by its own word: the install makes the row, and it may link to others.
2510
+ if (ref === settingsTable || ref === m.addOn?.settingsTable)
2213
2511
  return true;
2214
2512
  const table = tables.find((t) => t.ref === ref);
2215
2513
  if (table === undefined)
@@ -2412,8 +2710,19 @@ function codeRenewIssues(table, column, renew, path) {
2412
2710
  */
2413
2711
  function codeLookupIssues(table, column, lookup, path, ctx) {
2414
2712
  const out = [];
2415
- if (column.type !== 'fk' || column.nullable !== true || column.references !== lookup.table) {
2416
- out.push({ path: [...path, 'table'], message: `a lookup fills this table's link to "${lookup.table}": the column is a nullable foreign key to it` });
2713
+ const into = lookup.table;
2714
+ if (typeof into !== 'string') {
2715
+ // The other table is an add-on's: this manifest cannot see it, so the link says where it goes.
2716
+ const link = column.rules?.addOnLink;
2717
+ if (link === undefined || link.addOn !== into.addOn || link.table !== into.table) {
2718
+ out.push({
2719
+ path: [...path, 'table'],
2720
+ message: `a lookup into "${into.addOn}"'s table "${into.table}" fills a column that links there: give "${table.ref}.${column.ref}" rules.addOnLink {"addOn": "${into.addOn}", "table": "${into.table}"}`,
2721
+ });
2722
+ }
2723
+ }
2724
+ else if (column.type !== 'fk' || column.nullable !== true || column.references !== into) {
2725
+ out.push({ path: [...path, 'table'], message: `a lookup fills this table's link to "${into}": the column is a nullable foreign key to it` });
2417
2726
  }
2418
2727
  const typed = table.columns.find((c) => c.ref === lookup.from);
2419
2728
  if (typed === undefined) {
@@ -2427,15 +2736,23 @@ function codeLookupIssues(table, column, lookup, path, ctx) {
2427
2736
  decidedByRules(typed.rules)) {
2428
2737
  out.push({ path: [...path, 'from'], message: 'the code is typed into a nullable text column of up to 64 characters' });
2429
2738
  }
2430
- const target = ctx.tables.get(lookup.table);
2739
+ if (typeof into !== 'string') {
2740
+ // What the code is found by is the add-on's to keep; only this table's side of a scope is checked here.
2741
+ (lookup.scope ?? []).forEach((scope, k) => {
2742
+ if (ctx.index.column(table.ref, scope.equals) === undefined)
2743
+ out.push({ path: [...path, 'scope', k, 'equals'], message: `"${table.ref}" has no column "${scope.equals}"` });
2744
+ });
2745
+ return out;
2746
+ }
2747
+ const target = ctx.tables.get(into);
2431
2748
  if (target === undefined) {
2432
- out.push({ path: [...path, 'table'], message: `"${lookup.table}" is not a table of this app` });
2749
+ out.push({ path: [...path, 'table'], message: `"${into}" is not a table of this app` });
2433
2750
  return out;
2434
2751
  }
2435
2752
  const found = target.columns.find((c) => c.ref === lookup.column);
2436
2753
  const scopeColumns = (lookup.scope ?? []).map((s) => s.column);
2437
2754
  if (found === undefined) {
2438
- out.push({ path: [...path, 'column'], message: `"${lookup.table}" has no column "${lookup.column}"` });
2755
+ out.push({ path: [...path, 'column'], message: `"${into}" has no column "${lookup.column}"` });
2439
2756
  }
2440
2757
  else {
2441
2758
  // A set of columns unique together counts when its other columns are the scope's.
@@ -2445,46 +2762,79 @@ function codeLookupIssues(table, column, lookup, path, ctx) {
2445
2762
  return others.length === scopeColumns.length && others.every((ref) => scopeColumns.includes(ref));
2446
2763
  });
2447
2764
  if (found.type !== 'text' || (found.unique !== true && found.rules?.code === undefined && !scoped)) {
2448
- out.push({ path: [...path, 'column'], message: `a code finds one row: make "${lookup.table}.${found.ref}" unique (or unique with its scope)` });
2765
+ out.push({ path: [...path, 'column'], message: `a code finds one row: make "${into}.${found.ref}" unique (or unique with its scope)` });
2449
2766
  }
2450
2767
  if (found.rules?.code === undefined && found.rules?.normalize !== 'code') {
2451
- out.push({ path: [...path, 'column'], message: `"${lookup.table}.${found.ref}" is compared as a code: give it normalize "code"` });
2768
+ out.push({ path: [...path, 'column'], message: `"${into}.${found.ref}" is compared as a code: give it normalize "code"` });
2452
2769
  }
2453
- if (ctx.shareCodes(lookup.table).includes(found.ref)) {
2770
+ if (ctx.shareCodes(into).includes(found.ref)) {
2454
2771
  out.push({ path: [...path, 'column'], message: "a shared link's code is never looked up" });
2455
2772
  }
2456
2773
  }
2457
2774
  (lookup.where ?? []).forEach((condition, k) => {
2458
2775
  const at = [...path, 'where', k];
2459
- const filter = ctx.index.column(lookup.table, condition.column);
2776
+ const filter = ctx.index.column(into, condition.column);
2460
2777
  if (filter === undefined) {
2461
- out.push({ path: [...at, 'column'], message: `"${lookup.table}" has no column "${condition.column}"` });
2778
+ out.push({ path: [...at, 'column'], message: `"${into}" has no column "${condition.column}"` });
2462
2779
  }
2463
2780
  else if ('eq' in condition) {
2464
2781
  if (!valueFits(filter, condition.eq))
2465
- out.push({ path: [...at, 'eq'], message: `${JSON.stringify(condition.eq)} is not a value of "${lookup.table}.${filter.ref}"` });
2782
+ out.push({ path: [...at, 'eq'], message: `${JSON.stringify(condition.eq)} is not a value of "${into}.${filter.ref}"` });
2466
2783
  }
2467
2784
  else if (filter.type !== 'date' && filter.type !== 'timestamptz') {
2468
- out.push({ path: [...at, 'column'], message: `"${lookup.table}.${filter.ref}" is not a date` });
2785
+ out.push({ path: [...at, 'column'], message: `"${into}.${filter.ref}" is not a date` });
2469
2786
  }
2470
2787
  });
2471
2788
  (lookup.scope ?? []).forEach((scope, k) => {
2472
2789
  const at = [...path, 'scope', k];
2473
- const theirs = ctx.index.column(lookup.table, scope.column);
2790
+ const theirs = ctx.index.column(into, scope.column);
2474
2791
  const ours = ctx.index.column(table.ref, scope.equals);
2475
2792
  if (theirs === undefined)
2476
- out.push({ path: [...at, 'column'], message: `"${lookup.table}" has no column "${scope.column}"` });
2793
+ out.push({ path: [...at, 'column'], message: `"${into}" has no column "${scope.column}"` });
2477
2794
  if (ours === undefined)
2478
2795
  out.push({ path: [...at, 'equals'], message: `"${table.ref}" has no column "${scope.equals}"` });
2479
2796
  if (theirs !== undefined && ours !== undefined && (theirs.type !== ours.type || theirs.references !== ours.references)) {
2480
- out.push({ path: at, message: `"${table.ref}.${ours.ref}" and "${lookup.table}.${theirs.ref}" hold different things` });
2797
+ out.push({ path: at, message: `"${table.ref}.${ours.ref}" and "${into}.${theirs.ref}" hold different things` });
2481
2798
  }
2482
2799
  if (scope.orEmpty === true && theirs !== undefined && theirs.nullable !== true) {
2483
- out.push({ path: [...at, 'orEmpty'], message: `"${lookup.table}.${theirs.ref}" is never empty` });
2800
+ out.push({ path: [...at, 'orEmpty'], message: `"${into}.${theirs.ref}" is never empty` });
2484
2801
  }
2485
2802
  });
2486
2803
  return out;
2487
2804
  }
2805
+ /**
2806
+ * The version of Adminium that first installs an add-on the way it installs
2807
+ * an app: with pages, roles, rules on its own tables and the rest of
2808
+ * `installBlocksShape`. A manifest that uses one of those words declares at
2809
+ * least this in `compatibility.minAdminiumVersion`, so an older server
2810
+ * answers "needs a newer Adminium" and never "unrecognized key".
2811
+ */
2812
+ export const ADD_ON_INSTALL_FLOOR = '0.3.18';
2813
+ /**
2814
+ * The blocks an app and an add-on declare in the same words: what Adminium
2815
+ * makes at install beside the tables. Both branches spread it, so a block
2816
+ * added here is read the same way from either kind.
2817
+ */
2818
+ const installBlocksShape = {
2819
+ roles: z.array(roleSchema).optional(),
2820
+ /** Rows a table starts with: written at install, only into a table that holds none. */
2821
+ seeds: z.array(seedSchema).optional(),
2822
+ navGroups: z.array(navGroupSchema).max(12).optional(),
2823
+ /** Keyed by a kebab-case name; a column names one with `options: {list: name}`. */
2824
+ optionLists: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/, 'a list name is kebab-case'), optionListSchema).optional(),
2825
+ publicAccess: z.array(publicAccessSchema).max(64).optional(),
2826
+ /** Browser keys besides the app's own `customer` key (see `public-access.ts`). */
2827
+ publicKeys: publicKeysSchema.optional(),
2828
+ /** The emails: an outbox table and what queues rows in it (see `outbox.ts`). */
2829
+ outbox: outboxSchema.optional(),
2830
+ /** The templates the outbox sends, in each language shipped. */
2831
+ emailTemplates: z.array(emailTemplateSchema).max(32).optional(),
2832
+ sampleData: sampleDataSchema.optional(),
2833
+ /** Document profiles on the manifest's own tables, drawn by an add-on (see `documents.ts`). */
2834
+ documents: z.array(appDocumentSchema).max(16).optional(),
2835
+ /** Rules the manifest ships: installed switched as it says, the owner's to switch or copy (see `automations.ts`). */
2836
+ automations: manifestAutomationsSchema.optional(),
2837
+ };
2488
2838
  export const appManifestSchema = z
2489
2839
  .object({
2490
2840
  kind: z.literal('app'),
@@ -2493,9 +2843,8 @@ export const appManifestSchema = z
2493
2843
  compatibility: compatibilitySchema,
2494
2844
  requiredSchema: requiredSchemaSchema,
2495
2845
  pages: z.array(pageSchema).min(1),
2496
- roles: z.array(roleSchema).optional(),
2497
2846
  settings: z.array(settingSchema).optional(),
2498
- seeds: z.array(seedSchema).optional(),
2847
+ ...installBlocksShape,
2499
2848
  widgets: z.array(manifestWidgetSchema).optional(),
2500
2849
  capabilities: z.array(capabilitySchema).optional(),
2501
2850
  /**
@@ -2508,27 +2857,16 @@ export const appManifestSchema = z
2508
2857
  * required-singular shape however their `requiredSchema` was repaired.
2509
2858
  */
2510
2859
  frontends: z.array(frontendSchema).min(1),
2511
- navGroups: z.array(navGroupSchema).max(12).optional(),
2512
- /** Keyed by a kebab-case name; a column names one with `options: {list: name}`. */
2513
- optionLists: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/, 'a list name is kebab-case'), optionListSchema).optional(),
2514
- publicAccess: z.array(publicAccessSchema).max(64).optional(),
2515
- /** Browser keys besides the app's own `customer` key (see `public-access.ts`). */
2516
- publicKeys: publicKeysSchema.optional(),
2517
- /** The app's emails: its outbox table and what queues rows in it (see `outbox.ts`). */
2518
- outbox: outboxSchema.optional(),
2519
- /** The templates the outbox sends, in each language the app ships. */
2520
- emailTemplates: z.array(emailTemplateSchema).max(32).optional(),
2521
- sampleData: sampleDataSchema.optional(),
2522
2860
  /** The add-ons the app needs, suggests, or needs for a feature (see `add-ons.ts`). */
2523
2861
  addOns: addOnsSchema.optional(),
2524
- /** Document profiles on the app's own tables, drawn by an add-on (see `documents.ts`). */
2525
- documents: z.array(appDocumentSchema).max(16).optional(),
2526
2862
  })
2527
2863
  .strict()
2528
2864
  .refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
2529
2865
  .refine(compatibilityWindowOrdered, { ...WINDOW_MESSAGE, path: [...WINDOW_MESSAGE.path] })
2530
2866
  .refine(sidesAreDistinct, { ...SIDES_MESSAGE, path: [...SIDES_MESSAGE.path] })
2531
2867
  .superRefine((m, ctx) => {
2868
+ for (const issue of installFloorIssues(m))
2869
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
2532
2870
  for (const issue of appReferenceIssues(m)) {
2533
2871
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
2534
2872
  }
@@ -2536,22 +2874,28 @@ export const appManifestSchema = z
2536
2874
  for (const issue of pageCalendarIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
2537
2875
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
2538
2876
  }
2877
+ // A records page's tab words, filters and bulk actions, against the app's own tables.
2878
+ for (const issue of pageConfigIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
2879
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
2880
+ }
2539
2881
  });
2540
2882
  /**
2541
- * `pages`, `roles` and `frontends` are absent from this branch on purpose, and
2542
- * leaving the fields off a `.strict()` schema entirely is a stronger guarantee
2543
- * than a lint rule.
2883
+ * WHAT AN ADD-ON MAY DECLARE, AND WHAT IT STILL MAY NOT.
2884
+ *
2885
+ * An add-on that keeps tables of its own is installed the way an app is: it
2886
+ * may declare generated `pages` over those tables, `roles`, rules on its
2887
+ * columns, option lists, emails, documents, sample data and public entries —
2888
+ * the blocks of `installBlocksShape`, read by the same code as an app's.
2544
2889
  *
2545
- * WHAT CHANGED, AND WHAT DID NOT. An add-on may now own a dashboard page — but
2546
- * it declares that page as CODE, inside `addOn.pages`, never as a `pages` entry
2547
- * up here. The two are
2548
- * different things wearing one word: a `pages` row is a generated page, a
2549
- * `template` the engine renders with `bindings` and `config`, and an add-on
2550
- * still cannot install one. Roles and frontends remain refused outright.
2890
+ * Two kinds of page now live in one add-on document, and they are different
2891
+ * things: a top-level `pages` row is a GENERATED page, a `template` the engine
2892
+ * renders with `bindings` and `config`; an `addOn.pages` row is CODE, a module
2893
+ * of the add-on's own bundle. Both are addressed under the add-on's key.
2551
2894
  *
2552
- * So the absence of `pages` from this object is no longer "an add-on has no
2553
- * pages". It is "an add-on's pages are not the engine's page templates", which
2554
- * is a narrower promise and the one this shape actually keeps.
2895
+ * `frontends` is absent on purpose: an add-on has no screens outside the
2896
+ * dashboard, and leaving the field off a `.strict()` schema is a stronger
2897
+ * guarantee than a lint rule. So are `addOns.requires` and `addOns.features`:
2898
+ * an add-on may suggest another, never need one.
2555
2899
  */
2556
2900
  export const addOnManifestSchema = z
2557
2901
  .object({
@@ -2566,13 +2910,33 @@ export const addOnManifestSchema = z
2566
2910
  settings: z.array(settingSchema).optional(),
2567
2911
  capabilities: z.array(capabilitySchema).optional(),
2568
2912
  widgets: z.array(manifestWidgetSchema).optional(),
2913
+ /** Generated pages over the add-on's own tables. */
2914
+ pages: z.array(pageSchema).min(1).optional(),
2915
+ ...installBlocksShape,
2916
+ /** Other add-ons this one works with when they are there. Never one it needs. */
2917
+ addOns: addOnsSchema.pick({ suggests: true }).strict().optional(),
2569
2918
  })
2570
2919
  .strict()
2571
2920
  .refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
2572
2921
  .refine(compatibilityWindowOrdered, { ...WINDOW_MESSAGE, path: [...WINDOW_MESSAGE.path] })
2573
- .refine((m) => m.requiredSchema?.prefixed !== true, {
2574
- message: 'an add-on uses its host app\'s tables, so its own cannot be prefixed',
2575
- path: ['requiredSchema', 'prefixed'],
2922
+ .superRefine((m, ctx) => {
2923
+ for (const issue of installFloorIssues(m))
2924
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
2925
+ // Add-ons released before the floor keep the page refs they shipped with.
2926
+ if (m.pages !== undefined || compareSemver(m.compatibility.minAdminiumVersion, ADD_ON_INSTALL_FLOOR) >= 0) {
2927
+ for (const issue of addOnPageRefIssues(m))
2928
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
2929
+ }
2930
+ // From the floor on a page's code is handed out behind the page's own permission; a slot's is handed to everybody signed in.
2931
+ if (compareSemver(m.compatibility.minAdminiumVersion, ADD_ON_INSTALL_FLOOR) >= 0) {
2932
+ for (const issue of sharedPageFileIssues(m))
2933
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
2934
+ }
2935
+ if (!installsLikeAnApp(m))
2936
+ return;
2937
+ // An issue with a code of its own carries it in `params`, where the validator reads it back.
2938
+ for (const issue of addOnInstallIssues(m))
2939
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path, ...(issue.code === undefined ? {} : { params: { code: issue.code } }) });
2576
2940
  })
2577
2941
  .superRefine((m, ctx) => {
2578
2942
  // `addOn.shapes` is typed loosely in the contracts package (it cannot see
@@ -2600,6 +2964,341 @@ export const addOnManifestSchema = z
2600
2964
  export const manifestSchema = z.preprocess((v) => typeof v === 'object' && v !== null && !Array.isArray(v) && v.kind === undefined
2601
2965
  ? { ...v, kind: 'app' }
2602
2966
  : v, z.discriminatedUnion('kind', [appManifestSchema, addOnManifestSchema]));
2967
+ /**
2968
+ * A word only a newer Adminium reads, under a floor that admits an older one:
2969
+ * that server would refuse the manifest as an unknown key, so the floor is
2970
+ * raised instead and it answers "needs a newer Adminium".
2971
+ */
2972
+ function installFloorIssues(m) {
2973
+ const floor = m.compatibility.minAdminiumVersion;
2974
+ if (compareSemver(floor, ADD_ON_INSTALL_FLOOR) >= 0)
2975
+ return [];
2976
+ return installFloorWords(m).map((found) => ({
2977
+ path: found.path.split('.').map((part) => (/^\d+$/.test(part) ? Number(part) : part)),
2978
+ message: `"${found.word}" is read by Adminium ${ADD_ON_INSTALL_FLOOR} and later, and compatibility.minAdminiumVersion is ${floor}: set it to ${ADD_ON_INSTALL_FLOOR} or later`,
2979
+ }));
2980
+ }
2981
+ /** The top-level blocks an add-on declares in an app's words. */
2982
+ const ADD_ON_BLOCKS = ['pages', 'roles', 'seeds', 'navGroups', 'optionLists', 'publicAccess', 'publicKeys', 'outbox', 'emailTemplates', 'sampleData', 'documents', 'automations', 'addOns'];
2983
+ /**
2984
+ * Whether an add-on is installed the way an app is: it declares a block an
2985
+ * app declares, its tables are prefixed, or one of its tables carries a rule
2986
+ * (a column rule, states, a limit, a booking rule, a unique set, an index).
2987
+ * An add-on that only keeps plain tables, or none, is not.
2988
+ */
2989
+ export function installsLikeAnApp(m) {
2990
+ if (m.kind !== 'add-on')
2991
+ return false;
2992
+ if (ADD_ON_BLOCKS.some((block) => m[block] !== undefined))
2993
+ return true;
2994
+ if (m.requiredSchema?.prefixed === true || m.addOn.settingsTable !== undefined || m.addOn.ledgers !== undefined || m.addOn.adjuster !== undefined || m.addOn.words !== undefined || m.addOn.recordTabs !== undefined)
2995
+ return true;
2996
+ return (m.requiredSchema?.tables ?? []).some((table) => table.states !== undefined ||
2997
+ table.capacity !== undefined ||
2998
+ table.booking !== undefined ||
2999
+ table.unique !== undefined ||
3000
+ table.indexes !== undefined ||
3001
+ table.postings !== undefined ||
3002
+ table.adjust !== undefined ||
3003
+ table.columns.some((column) => column.rules !== undefined));
3004
+ }
3005
+ /** The directives a seed row's value may be: the installing person's language, or an earlier seed row. */
3006
+ const SEED_DIRECTIVES = new Set(['@t', '@ref']);
3007
+ /** The most rows one table is seeded with. */
3008
+ export const MAX_SEED_ROWS = 200;
3009
+ /**
3010
+ * Generated pages and code pages share one address space on a connection, so
3011
+ * each of an add-on's is named under its key, and once.
3012
+ */
3013
+ function addOnPageRefIssues(m) {
3014
+ const out = [];
3015
+ const refs = new Set();
3016
+ const check = (ref, path) => {
3017
+ if (ref !== m.key && !ref.startsWith(`${m.key}-`)) {
3018
+ out.push({ path, message: `a page of "${m.key}" is addressed under its key: name it "${m.key}" or "${m.key}-…", not "${ref}"` });
3019
+ }
3020
+ if (refs.has(ref))
3021
+ out.push({ path, message: `the page ref "${ref}" is used twice` });
3022
+ refs.add(ref);
3023
+ };
3024
+ (m.pages ?? []).forEach((page, p) => check(page.ref, ['pages', p, 'ref']));
3025
+ (m.addOn.pages ?? []).forEach((page, p) => check(page.ref, ['addOn', 'pages', p, 'ref']));
3026
+ return out;
3027
+ }
3028
+ /**
3029
+ * A page's code is the page: it is served only to who may open the page. A
3030
+ * slot's code is served to every signed-in person. One file named as both
3031
+ * would be the page handed out with no permission asked, so a page has a
3032
+ * file no slot names.
3033
+ */
3034
+ function sharedPageFileIssues(m) {
3035
+ const open = new Set((m.addOn.slots ?? []).map((slot) => slot.client));
3036
+ return (m.addOn.pages ?? []).flatMap((page, p) => open.has(page.client) ? [{ path: ['addOn', 'pages', p, 'client'], message: `the page "${page.ref}" is built into "${page.client}", which a slot loads too: a page's code is served only to who may open the page, so give the page a file of its own` }] : []);
3037
+ }
3038
+ /** An add-on's ledgers as the manifest's own schema reads them; what does not parse is reported and left out. */
3039
+ function parsedLedgers(raw, out) {
3040
+ const parsed = ledgersSchema.safeParse(raw);
3041
+ if (parsed.success)
3042
+ return parsed.data;
3043
+ for (const issue of parsed.error.issues) {
3044
+ out.push({ path: ['addOn', 'ledgers', ...issue.path.map((part) => (typeof part === 'symbol' ? String(part) : part))], message: issue.message });
3045
+ }
3046
+ return [];
3047
+ }
3048
+ /** An add-on's stock words, typed. Empty for an app, and for an add-on that declares none. */
3049
+ export function wordsOf(m) {
3050
+ if (m.kind !== 'add-on' || m.addOn.words === undefined)
3051
+ return [];
3052
+ const parsed = wordsListSchema.safeParse(m.addOn.words);
3053
+ return parsed.success ? parsed.data : [];
3054
+ }
3055
+ /** What an add-on lets a typed code find, typed; null for an app, and for an add-on that declares none. */
3056
+ export function lookUpOf(m) {
3057
+ if (m.kind !== 'add-on' || m.addOn.lookUp === undefined)
3058
+ return null;
3059
+ const parsed = lookUpSchema.safeParse(m.addOn.lookUp);
3060
+ return parsed.success ? parsed.data : null;
3061
+ }
3062
+ /** An add-on's adjuster, typed; null for an app, and for an add-on that declares none. */
3063
+ export function adjusterOf(m) {
3064
+ if (m.kind !== 'add-on' || m.addOn.adjuster === undefined)
3065
+ return null;
3066
+ const parsed = adjusterSchema.safeParse(m.addOn.adjuster);
3067
+ return parsed.success ? parsed.data : null;
3068
+ }
3069
+ /** An add-on's ledgers, typed. Empty for an app, and for an add-on that declares none. */
3070
+ export function ledgersOf(m) {
3071
+ if (m.kind !== 'add-on' || m.addOn.ledgers === undefined)
3072
+ return [];
3073
+ const parsed = ledgersSchema.safeParse(m.addOn.ledgers);
3074
+ return parsed.success ? parsed.data : [];
3075
+ }
3076
+ /** Everything wrong with an add-on that installs like an app, beyond what its blocks say of themselves. */
3077
+ function addOnInstallIssues(m) {
3078
+ const out = [];
3079
+ const tables = m.requiredSchema?.tables ?? [];
3080
+ const byRef = new Map(tables.map((table) => [table.ref, table]));
3081
+ // `addOn.ledgers` is typed loosely in the contracts package (it cannot see these words); it is checked in full here.
3082
+ const ledgers = m.addOn.ledgers === undefined ? [] : parsedLedgers(m.addOn.ledgers, out);
3083
+ out.push(...appReferenceIssues({ ...m, requiredSchema: m.requiredSchema ?? { tables: [] }, ledgers, codePages: (m.addOn.pages ?? []).map((page) => page.ref) }));
3084
+ if (m.pages !== undefined)
3085
+ out.push(...pageCalendarIssues(m.pages, tableIndex(tables)), ...pageConfigIssues(m.pages, tableIndex(tables)));
3086
+ // The look-up is typed loosely in the contracts package: its tables and columns are checked here.
3087
+ if (m.addOn.lookUp !== undefined) {
3088
+ const parsed = lookUpSchema.safeParse(m.addOn.lookUp);
3089
+ if (!parsed.success) {
3090
+ for (const issue of parsed.error.issues)
3091
+ out.push({ path: ['addOn', 'lookUp', ...issue.path.map((part) => (typeof part === 'symbol' ? String(part) : part))], message: issue.message });
3092
+ }
3093
+ else {
3094
+ out.push(...lookUpIssues(parsed.data, tables));
3095
+ }
3096
+ }
3097
+ // Stock words: a question asked of one of the add-on's own ledgers.
3098
+ if (m.addOn.words !== undefined) {
3099
+ const parsed = wordsListSchema.safeParse(m.addOn.words);
3100
+ if (!parsed.success) {
3101
+ for (const issue of parsed.error.issues)
3102
+ out.push({ path: ['addOn', 'words', ...issue.path.map((part) => (typeof part === 'symbol' ? String(part) : part))], message: issue.message });
3103
+ }
3104
+ else {
3105
+ out.push(...wordsIssues({ words: parsed.data, ledgers, tables: tables, settingsTable: m.addOn.settingsTable }));
3106
+ }
3107
+ }
3108
+ // Tabs that list the add-on's rows on other tables' record pages: its own tables, columns and words.
3109
+ if (m.addOn.recordTabs !== undefined) {
3110
+ out.push(...recordTabIssues({ tabs: m.addOn.recordTabs, tables: tables, words: wordsOf(m).map((words) => words.id) }));
3111
+ }
3112
+ // The adjuster is typed loosely in the contracts package too: its words are checked here.
3113
+ if (m.addOn.adjuster !== undefined) {
3114
+ const parsed = adjusterSchema.safeParse(m.addOn.adjuster);
3115
+ if (!parsed.success) {
3116
+ for (const issue of parsed.error.issues)
3117
+ out.push({ path: ['addOn', 'adjuster', ...issue.path.map((part) => (typeof part === 'symbol' ? String(part) : part))], message: issue.message });
3118
+ }
3119
+ else {
3120
+ out.push(...adjusterIssues(parsed.data, byRef));
3121
+ }
3122
+ const providers = (m.addOn.provides ?? []).filter((entry) => entry.contract === 'price-adjust');
3123
+ if (providers.length !== 1)
3124
+ out.push({ path: ['addOn', 'provides'], message: 'an add-on with an adjuster provides the contract "price-adjust" exactly once' });
3125
+ }
3126
+ // A ledger is served by the add-on's one `posting-rows` provider, and its receipt table is the one Adminium writes.
3127
+ if (m.addOn.ledgers !== undefined) {
3128
+ const providers = (m.addOn.provides ?? []).filter((entry) => entry.contract === 'posting-rows');
3129
+ if (providers.length !== 1)
3130
+ out.push({ path: ['addOn', 'provides'], message: 'an add-on with ledgers provides the contract "posting-rows" exactly once' });
3131
+ for (const ledger of ledgers) {
3132
+ const receipts = byRef.get(ledger.receipts);
3133
+ if (receipts !== undefined)
3134
+ out.push(...receiptTableIssues(receipts, (...rest) => ['requiredSchema', 'tables', tables.indexOf(receipts), ...rest]));
3135
+ }
3136
+ // A ledger stays inside its own tables: each issue carries its code.
3137
+ for (const issue of ledgerIssues({ tables: tables, ledgers, settingsTable: m.addOn.settingsTable })) {
3138
+ out.push({ path: issue.path, message: issue.message, code: issue.code });
3139
+ }
3140
+ }
3141
+ // Blocks that name tables need tables to name.
3142
+ if (m.requiredSchema === undefined) {
3143
+ for (const block of ['pages', 'outbox', 'publicAccess', 'sampleData', 'documents', 'seeds']) {
3144
+ if (m[block] !== undefined)
3145
+ out.push({ path: [block], message: `"${block}" names tables, and this add-on declares none (requiredSchema)` });
3146
+ }
3147
+ }
3148
+ // An entry's stored ref is its table's real name: only a prefixed add-on's can never meet an app's.
3149
+ if (m.publicAccess !== undefined && m.requiredSchema?.prefixed !== true) {
3150
+ out.push({ path: ['publicAccess'], message: 'an add-on with public entries prefixes its tables: set requiredSchema.prefixed to true' });
3151
+ }
3152
+ // Seeds: rows of an own table, in its own columns, with the two directives a seed takes.
3153
+ (m.seeds ?? []).forEach((seed, i) => {
3154
+ const table = byRef.get(seed.table);
3155
+ if (table === undefined) {
3156
+ if (m.requiredSchema !== undefined)
3157
+ out.push({ path: ['seeds', i, 'table'], message: `"${seed.table}" is not one of this add-on's tables` });
3158
+ return;
3159
+ }
3160
+ const columns = new Set(table.columns.map((column) => column.ref));
3161
+ // History is the sample's business, never a seed's: a ledger's rows come from postings.
3162
+ const ledger = ledgers.find((candidate) => candidate.receipts === seed.table || candidate.writes[seed.table] !== undefined);
3163
+ if (ledger !== undefined) {
3164
+ out.push({ path: ['seeds', i, 'table'], message: `"${seed.table}" is ${ledger.receipts === seed.table ? 'the receipt table' : 'a table'} of the ledger "${ledger.id}", which only postings write: a seed never fills it` });
3165
+ }
3166
+ if ((seed.rows?.length ?? 0) > MAX_SEED_ROWS)
3167
+ out.push({ path: ['seeds', i, 'rows'], message: `a table is seeded with at most ${String(MAX_SEED_ROWS)} rows` });
3168
+ (seed.rows ?? []).forEach((row, r) => {
3169
+ for (const [name, value] of Object.entries(row)) {
3170
+ const at = ['seeds', i, 'rows', r, name];
3171
+ if (name === '@label') {
3172
+ if (typeof value !== 'string')
3173
+ out.push({ path: at, message: 'a label is text' });
3174
+ continue;
3175
+ }
3176
+ if (name.startsWith('@')) {
3177
+ out.push({ path: at, message: `a seed row takes "@label" and no other row directive, not "${name}"` });
3178
+ continue;
3179
+ }
3180
+ if (!columns.has(name))
3181
+ out.push({ path: at, message: `"${seed.table}" has no column "${name}"` });
3182
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
3183
+ const directive = Object.keys(value).find((key) => key.startsWith('@'));
3184
+ if (directive !== undefined && !SEED_DIRECTIVES.has(directive)) {
3185
+ out.push({ path: at, message: `a seed value is a plain value, {"@t": {…}} or {"@ref": "<label>"}, not "${directive}"` });
3186
+ }
3187
+ }
3188
+ }
3189
+ });
3190
+ });
3191
+ // The settings table: one row, made from defaults, so every column must have one or may be empty.
3192
+ const settings = m.addOn.settingsTable;
3193
+ if (settings !== undefined) {
3194
+ const table = byRef.get(settings);
3195
+ if (table === undefined) {
3196
+ out.push({ path: ['addOn', 'settingsTable'], message: `"${settings}" is not one of this add-on's tables` });
3197
+ }
3198
+ else {
3199
+ table.columns.forEach((column, c) => {
3200
+ if (column.role !== undefined || column.nullable === true || column.default !== undefined || decidedByRules(column.rules))
3201
+ return;
3202
+ out.push({
3203
+ path: ['requiredSchema', 'tables', tables.indexOf(table), 'columns', c],
3204
+ message: `the settings row is made from defaults at install: give "${settings}.${column.ref}" a default, or make it nullable`,
3205
+ });
3206
+ });
3207
+ }
3208
+ }
3209
+ // An add-on has no `customer` key of its own: its entries ride its app's key, or its one link key.
3210
+ const keyNames = Object.keys(m.publicKeys ?? {});
3211
+ if (keyNames.length > 1)
3212
+ out.push({ path: ['publicKeys'], message: 'an add-on declares at most one key: a link that opens one row' });
3213
+ const linkKey = keyNames[0];
3214
+ if (linkKey !== undefined) {
3215
+ // `customer` is an app's key: an add-on's entries reach it through the app, never through a key of the add-on's own.
3216
+ if (linkKey === 'customer')
3217
+ out.push({ path: ['publicKeys', linkKey], message: 'an add-on has no "customer" key: its one key is a link\'s, under a name of its own' });
3218
+ const key = (m.publicKeys ?? {})[linkKey];
3219
+ for (const field of ['requiresStaff', 'enabledBy', 'peak']) {
3220
+ if (key?.[field] !== undefined)
3221
+ out.push({ path: ['publicKeys', linkKey, field], message: `an add-on's key opens one row by its link and only reads: it takes no "${field}"` });
3222
+ }
3223
+ }
3224
+ (m.publicAccess ?? []).forEach((entry, e) => {
3225
+ if (entry.key === undefined)
3226
+ return;
3227
+ if (entry.key !== linkKey) {
3228
+ out.push({ path: ['publicAccess', e, 'key'], message: `an add-on's entry is served through its app's key, or through the add-on's own link key${linkKey === undefined ? '' : ` "${linkKey}"`}: "${entry.key}" is neither` });
3229
+ return;
3230
+ }
3231
+ if (entry.methods.some((method) => method !== 'GET'))
3232
+ out.push({ path: ['publicAccess', e, 'methods'], message: `"${linkKey}" opens a row to whoever holds its link, so it only reads` });
3233
+ const claim = entry.claim;
3234
+ if (claim === undefined) {
3235
+ // Whoever holds a link reads that one row and what hangs under it: nothing on the key is open to everyone.
3236
+ if (entry.visibleWith === undefined && entry.claimedBy === undefined) {
3237
+ out.push({ path: ['publicAccess', e], message: `"${linkKey}" opens one row by its link: an entry on it claims by token, or is read with the row a link opened (visibleWith, claimedBy)` });
3238
+ }
3239
+ return;
3240
+ }
3241
+ if (!('by' in claim) || claim.by !== 'token' || claim.own === true) {
3242
+ out.push({ path: ['publicAccess', e, 'claim'], message: 'an add-on\'s link key claims by token, and never as the row\'s own link that may change it' });
3243
+ return;
3244
+ }
3245
+ const token = byRef.get(entry.table)?.columns.find((column) => column.ref === claim.column);
3246
+ const code = token?.rules?.code;
3247
+ if (code === undefined || code.length < 16 || code.hiddenFromStaff !== true) {
3248
+ out.push({ path: ['publicAccess', e, 'claim', 'column'], message: `the link's token is a code of 16 characters that staff never see: give "${entry.table}.${claim.column}" rules.code {"length": 16, "hiddenFromStaff": true}` });
3249
+ }
3250
+ });
3251
+ // Roles: what an add-on's role may open is its own tables, its own pages and its settings.
3252
+ const generated = new Set((m.pages ?? []).map((page) => page.ref));
3253
+ const code = new Set((m.addOn.pages ?? []).map((page) => page.ref));
3254
+ const settingsOf = new Set([m.key, ...(m.addOns?.suggests ?? []).map((need) => need.key)]);
3255
+ (m.roles ?? []).forEach((role, r) => {
3256
+ if (role.screensOnly !== undefined)
3257
+ out.push({ path: ['roles', r, 'screensOnly'], message: 'an add-on has no screens outside the dashboard, so its roles are never screensOnly' });
3258
+ (role.permissions ?? []).forEach((grant, g) => {
3259
+ const at = ['roles', r, 'permissions', g];
3260
+ const [resource, ref, action] = grant.split(':');
3261
+ if (resource === 'table' && ref?.startsWith('@') === true) {
3262
+ if (m.requiredSchema === undefined)
3263
+ out.push({ path: at, message: `"${grant}" names a table, and this add-on declares none (requiredSchema)` });
3264
+ return; // the table itself is checked with the app's own grants
3265
+ }
3266
+ if (resource === 'page' && ref?.startsWith('@') === true) {
3267
+ const page = ref.slice(1);
3268
+ if (action === 'view' && (generated.has(page) || code.has(page)))
3269
+ return;
3270
+ if (action === 'edit' && generated.has(page))
3271
+ return;
3272
+ out.push({ path: at, message: `"${grant}": a role opens one of this add-on's own pages (view), or edits one of its generated pages` });
3273
+ return;
3274
+ }
3275
+ if (resource === 'addOn' && action === 'settings' && ref !== undefined && settingsOf.has(ref))
3276
+ return;
3277
+ out.push({ path: at, message: `"${grant}": an add-on's role grants its own tables (table:@<table>:<action>), its own pages (page:@<page>:view) and its settings (addOn:${m.key}:settings)` });
3278
+ });
3279
+ });
3280
+ if (m.sampleData?.skipWhenShared !== undefined) {
3281
+ out.push({ path: ['sampleData', 'skipWhenShared'], message: 'an add-on shares no table with another app, so its sample data skips nothing' });
3282
+ }
3283
+ if (m.sampleData?.addOns !== undefined) {
3284
+ out.push({ path: ['sampleData', 'addOns'], message: 'rows for another add-on are an app\'s to ship: an add-on\'s sample data is its own file' });
3285
+ }
3286
+ // A document another add-on draws may not be there: an email that carries one must be able to go without it.
3287
+ const suggested = new Set((m.addOns?.suggests ?? []).map((need) => need.key));
3288
+ (m.emailTemplates ?? []).forEach((template, t) => {
3289
+ const attach = template.attach;
3290
+ if (attach === undefined || attach.optional === true)
3291
+ return;
3292
+ const drawnBy = (m.documents ?? []).find((document) => document.kind === attach.kind && suggested.has(document.addOn));
3293
+ if (drawnBy !== undefined) {
3294
+ out.push({
3295
+ path: ['emailTemplates', t, 'attach', 'optional'],
3296
+ message: `"${attach.kind}" is drawn by "${drawnBy.addOn}", which this add-on only suggests: write "optional": true, or the email could never be sent without it`,
3297
+ });
3298
+ }
3299
+ });
3300
+ return out;
3301
+ }
2603
3302
  /** Narrowing helper — the discriminant is the only thing worth branching on. */
2604
3303
  export function isAddOnManifest(m) {
2605
3304
  return m.kind === 'add-on';