@adminiumjs/manifest 0.3.27 → 0.3.28

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 (78) hide show
  1. package/dist/app-grants.d.ts +134 -0
  2. package/dist/app-grants.d.ts.map +1 -0
  3. package/dist/app-grants.js +263 -0
  4. package/dist/app-grants.js.map +1 -0
  5. package/dist/automations.d.ts +2172 -56
  6. package/dist/automations.d.ts.map +1 -1
  7. package/dist/automations.js +438 -25
  8. package/dist/automations.js.map +1 -1
  9. package/dist/compose.d.ts.map +1 -1
  10. package/dist/compose.js +3 -0
  11. package/dist/compose.js.map +1 -1
  12. package/dist/documents.d.ts +1 -0
  13. package/dist/documents.d.ts.map +1 -1
  14. package/dist/documents.js +2 -0
  15. package/dist/documents.js.map +1 -1
  16. package/dist/index.d.ts +11 -5
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +10 -4
  19. package/dist/index.js.map +1 -1
  20. package/dist/ledgers.d.ts.map +1 -1
  21. package/dist/ledgers.js +2 -0
  22. package/dist/ledgers.js.map +1 -1
  23. package/dist/modules.d.ts +93 -0
  24. package/dist/modules.d.ts.map +1 -0
  25. package/dist/modules.js +489 -0
  26. package/dist/modules.js.map +1 -0
  27. package/dist/occupies.d.ts +50 -0
  28. package/dist/occupies.d.ts.map +1 -0
  29. package/dist/occupies.js +123 -0
  30. package/dist/occupies.js.map +1 -0
  31. package/dist/outbox.d.ts +223 -17
  32. package/dist/outbox.d.ts.map +1 -1
  33. package/dist/outbox.js +125 -13
  34. package/dist/outbox.js.map +1 -1
  35. package/dist/page-board.d.ts +144 -0
  36. package/dist/page-board.d.ts.map +1 -0
  37. package/dist/page-board.js +280 -0
  38. package/dist/page-board.js.map +1 -0
  39. package/dist/public-access.d.ts +6 -0
  40. package/dist/public-access.d.ts.map +1 -1
  41. package/dist/public-access.js +74 -0
  42. package/dist/public-access.js.map +1 -1
  43. package/dist/rail.d.ts +50 -0
  44. package/dist/rail.d.ts.map +1 -0
  45. package/dist/rail.js +122 -0
  46. package/dist/rail.js.map +1 -0
  47. package/dist/roles.d.ts +647 -1
  48. package/dist/roles.d.ts.map +1 -1
  49. package/dist/roles.js +459 -3
  50. package/dist/roles.js.map +1 -1
  51. package/dist/sample.d.ts +62 -0
  52. package/dist/sample.d.ts.map +1 -1
  53. package/dist/sample.js +203 -4
  54. package/dist/sample.js.map +1 -1
  55. package/dist/schema.d.ts +4277 -169
  56. package/dist/schema.d.ts.map +1 -1
  57. package/dist/schema.js +539 -40
  58. package/dist/schema.js.map +1 -1
  59. package/dist/shapes.d.ts +3 -2
  60. package/dist/shapes.d.ts.map +1 -1
  61. package/dist/shapes.js +76 -7
  62. package/dist/shapes.js.map +1 -1
  63. package/dist/state-actions.d.ts +4 -0
  64. package/dist/state-actions.d.ts.map +1 -1
  65. package/dist/state-actions.js +7 -0
  66. package/dist/state-actions.js.map +1 -1
  67. package/dist/states.d.ts +20 -1
  68. package/dist/states.d.ts.map +1 -1
  69. package/dist/states.js +47 -5
  70. package/dist/states.js.map +1 -1
  71. package/dist/validate.d.ts.map +1 -1
  72. package/dist/validate.js +41 -0
  73. package/dist/validate.js.map +1 -1
  74. package/dist/words.d.ts +1 -1
  75. package/dist/words.d.ts.map +1 -1
  76. package/dist/words.js +328 -1
  77. package/dist/words.js.map +1 -1
  78. package/package.json +2 -2
package/dist/schema.js CHANGED
@@ -11,16 +11,21 @@
11
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
+ import { NAV_LINK_SCREENS, RECORD_ADDRESS_MESSAGE, SIDE_PATH_MESSAGE, isRecordAddress, isSidePath, railIssues } from './rail.js';
15
+ import { addOnModuleIssues, moduleIdSchema, moduleIssues, modulesSchema, optionalShapeAddOns, roleNamesSchema, shapeIsOptional, withoutModules } from './modules.js';
14
16
  import { bookingIssues, bookingSchema } from './booking.js';
17
+ import { occupiesIssues, occupiesSchema } from './occupies.js';
15
18
  import { capacityIssues, capacitySchema, isLegacyCapacity, kindOf, rulesOf, viaIndexIssues } from './capacity.js';
16
19
  import { appDocumentIssues, appDocumentSchema, mappingIssues } from './documents.js';
17
20
  import { formulaColumns, formulaExprSchema, tableFormulaIssues } from './formula.js';
18
21
  import { lookUpIssues, lookUpSchema } from './look-up.js';
22
+ import { pageBoardIssues } from './page-board.js';
19
23
  import { pageCalendarIssues } from './page-calendar.js';
20
24
  import { pageConfigIssues } from './page-config.js';
21
25
  import { emailTemplateSchema, outboxIssues, outboxProducerSchema, outboxSchema } from './outbox.js';
22
26
  import { codeWhereSchema, personalColumn, publicAccessIssues, publicAccessSchema, publicKeysSchema, shareCodeColumns, unlistedColumn } from './public-access.js';
23
- import { roleAddOnTableIssues, roleAddOnTablesSchema, roleLimitIssues, roleLimitsSchema } from './roles.js';
27
+ import { appGrantsIssues, appGrantsSchema, roleTableHold } from './app-grants.js';
28
+ import { belongsIssues, belongsSchema, privateTableIssues, roleAddOnTableIssues, roleAddOnTablesSchema, roleLimitIssues, roleLimitsSchema, rowTablesOf, privateShapeIssues, storedTotalIssues } from './roles.js';
24
29
  import { conditionIssues, stateConditionSchema, statesIssues, statesSchema } from './states.js';
25
30
  import { MOMENT_LIMITS, clockTimeSchema, momentIssues, momentSchema, settingRefSchema } from './refs.js';
26
31
  import { NUMERIC_TYPES, labelsSchema, refSchema, scalarSchema, settingSourceSchema, tableIndex, textOrLabels, valueFits, } from './refs.js';
@@ -207,6 +212,18 @@ export const COLUMN_SEMANTICS = [
207
212
  'geo-lat',
208
213
  'geo-lng',
209
214
  ];
215
+ /**
216
+ * The kinds of file a file column may take (`rules.file.accept`), by the
217
+ * names the server's content check gives them: what a file's bytes are, never
218
+ * what its name or its sender says. Two of the server's kinds are never taken
219
+ * by a column: SVG is a document that can carry script, and a HEIC picture is
220
+ * one whose camera notes (the place, the device) cannot be taken out of it.
221
+ */
222
+ export const FILE_COLUMN_KINDS = ['png', 'jpeg', 'webp', 'gif', 'pdf', 'office', 'zip', 'csv', 'text', 'markdown', 'json', 'mp4', 'webm', 'mp3', 'wav', 'ogg'];
223
+ /** The most a file column's `maxBytes` may say: 25 MiB. */
224
+ export const FILE_COLUMN_MAX_BYTES = 25 * 1024 * 1024;
225
+ /** The most files a json file column may hold. */
226
+ export const FILE_COLUMN_MAX_COUNT = 20;
210
227
  /** Structural role markers. */
211
228
  export const COLUMN_ROLES = ['pk', 'created_at', 'updated_at'];
212
229
  export { labelsSchema };
@@ -463,12 +480,16 @@ export const columnRulesSchema = z
463
480
  * A running number. `gapless` numbers are taken inside the write that
464
481
  * creates the row, so the series never skips or repeats (an invoice's);
465
482
  * `scope` numbers per parent row (a deliverable's versions v1, v2 …);
466
- * `startSetting` reads the first number from a setting.
483
+ * `startSetting` reads the first number from a setting. `sample` lets the
484
+ * app's sample rows take numbers in the series (a job's J-01): the next
485
+ * real row follows them. Never a tax document's series, which no sample
486
+ * row may leave a hole in.
467
487
  */
468
488
  sequence: z
469
489
  .object({
470
490
  start: z.number().int().min(1).optional(),
471
491
  gapless: z.literal(true).optional(),
492
+ sample: z.literal(true).optional(),
472
493
  startSetting: settingSourceSchema.optional(),
473
494
  scope: refSchema.optional(),
474
495
  })
@@ -557,6 +578,15 @@ export const columnRulesSchema = z
557
578
  /** The child column to add up; or, instead, `count` the child rows. */
558
579
  sum: refSchema.optional(),
559
580
  count: z.literal(true).optional(),
581
+ /**
582
+ * Or, instead of a total, the newest child row's value: the column
583
+ * `of` of the row whose `by` (a date, a time or a number) is the
584
+ * greatest — a deal's amount is its last sent quote's total. A row
585
+ * whose `by` is empty is left out; of two rows with the same `by`,
586
+ * the one with the greater key. Empty while no row qualifies. With
587
+ * `row`, a plain column of this table (not a link) holds that row's key.
588
+ */
589
+ latest: z.object({ of: refSchema, by: refSchema, row: refSchema.optional() }).strict().optional(),
560
590
  /** Multiplied into `sum` per child row, e.g. `qty`. */
561
591
  times: refSchema.optional(),
562
592
  /** A child row whose column holds a value is left out — a voided line (`voided_at`). */
@@ -645,6 +675,36 @@ export const columnRulesSchema = z
645
675
  * the table is renamed.
646
676
  */
647
677
  tableRef: z.literal(true).optional(),
678
+ /**
679
+ * A text column that holds a staff member: the id of a person who signs
680
+ * in to Adminium, or nothing. The only kind of column a table's `belongs`
681
+ * reads a person from, so a role's rows can be "the ones this person
682
+ * owns". A sample row names a sample person, never an id.
683
+ */
684
+ staff: z.literal(true).optional(),
685
+ /**
686
+ * A text column in which people are mentioned: `@[Dana Whitlock](user:<id>)`
687
+ * names a staff member, who is told that they were mentioned, by whom and
688
+ * on which record — never the text. A save that names somebody who is not
689
+ * a person here is refused, and the text is kept. `about` is a link column
690
+ * of the same table: the notice then names the row it links to (a comment
691
+ * on a deal says the deal).
692
+ */
693
+ mentions: z.union([z.literal(true), z.object({ about: refSchema }).strict()]).optional(),
694
+ /**
695
+ * A json column that holds a position on the map: `{ lat, lng, by }`,
696
+ * where `by` says how it is known. `"town"` is the middle of a town from
697
+ * Adminium's own list of places (`placeId`), filled in by the server, so
698
+ * it is never more than "somewhere in this town"; `"exact"` is a point a
699
+ * person gave (typed, pasted, or dropped on the map). Every write is read
700
+ * and checked; a value that is not a position is refused.
701
+ */
702
+ place: z.literal(true).optional(),
703
+ /**
704
+ * A json column that holds an area on the map: a GeoJSON `Polygon` or
705
+ * `MultiPolygon`, with closed rings, at most 2,000 points and 64 KB.
706
+ */
707
+ area: z.literal(true).optional(),
648
708
  /**
649
709
  * A formula column over a total of its own row whose change is told
650
710
  * after the save commits (a stock level going low), so a rule or a
@@ -664,6 +724,45 @@ export const columnRulesSchema = z
664
724
  customerKey: z.object({ of: refSchema }).strict().optional(),
665
725
  /** The last four characters of the code in `of`, written by Adminium: what a list shows of a code it never shows. */
666
726
  codeLast4: z.object({ of: refSchema }).strict().optional(),
727
+ /**
728
+ * A foreign key that may name only a row meeting these conditions (a
729
+ * price-list line still offered): judged when a new row names one and
730
+ * when a change moves the link, whoever writes. A row already naming one
731
+ * that later stops meeting them keeps it while its other columns change.
732
+ * `same`: a column of the named row holds what this row holds in `as`, a
733
+ * column of its own or one of the row a link of it names (a visit's
734
+ * contact works for the company of the visit's deal); judged also when a
735
+ * change moves what `as` reads.
736
+ */
737
+ linkable: z
738
+ .object({
739
+ where: z.array(stateConditionSchema).min(1).max(8).optional(),
740
+ same: z
741
+ .array(z.object({ column: refSchema, as: z.string().regex(/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/, 'a column, or <link column>.<column>') }).strict())
742
+ .min(1)
743
+ .max(4)
744
+ .optional(),
745
+ })
746
+ .strict()
747
+ .refine((rule) => rule.where !== undefined || rule.same !== undefined, { message: 'a link says which rows it may name: "where", "same", or both' })
748
+ .optional(),
749
+ /**
750
+ * A column that holds files a signed-in person uploaded for it: a text
751
+ * column one, a json column a list of up to `maxCount`. What it holds is
752
+ * Adminium's own file id (`file_…`), never a link. Only the kinds in
753
+ * `accept`, judged by the file's bytes, each at most `maxBytes`; a picture
754
+ * loses what a camera writes beside it (the place, the device) as it is
755
+ * uploaded. A write may name only a file its writer uploaded and no record
756
+ * holds yet; a file a record holds is changed only through the record.
757
+ */
758
+ file: z
759
+ .object({
760
+ accept: z.array(z.enum(FILE_COLUMN_KINDS)).min(1).max(FILE_COLUMN_KINDS.length),
761
+ maxBytes: z.number().int().min(1024).max(FILE_COLUMN_MAX_BYTES),
762
+ maxCount: z.number().int().min(1).max(FILE_COLUMN_MAX_COUNT).optional(),
763
+ })
764
+ .strict()
765
+ .optional(),
667
766
  })
668
767
  .strict();
669
768
  /**
@@ -817,6 +916,8 @@ export const requiredTableSchema = z
817
916
  capacity: capacitySchema.optional(),
818
917
  /** Booking people: no two counted rows of one resource may overlap (see `booking.ts`). */
819
918
  booking: bookingSchema.optional(),
919
+ /** A row takes a resource for a time: no two overlap, and it lies within the resource's hours (see `occupies.ts`). */
920
+ occupies: occupiesSchema.optional(),
820
921
  /** States, the moves between them, locks and no-delete (see `states.ts`). */
821
922
  states: statesSchema.optional(),
822
923
  /**
@@ -832,6 +933,8 @@ export const requiredTableSchema = z
832
933
  .regex(/^[a-z][a-z0-9-]{1,79}\/[a-z][a-z0-9-]*@[1-9]\d*$/, 'builtOn is <add-on key>/<shape name>@<version>')
833
934
  .optional(),
834
935
  part: z.string().regex(/^[a-z][a-z0-9_]*$/, 'a part of the shape').optional(),
936
+ /** The part of the app the table belongs to (see `modules.ts`): a role of that part loses its grant here while the part is off. */
937
+ module: moduleIdSchema.optional(),
835
938
  /**
836
939
  * What a person calls ONE row ("Category") and the table ("Categories"),
837
940
  * in every language the app speaks: a form's title and button, a page's
@@ -857,6 +960,12 @@ export const requiredTableSchema = z
857
960
  postings: postingsSchema.optional(),
858
961
  /** Where an add-on's offers and codes lower this order's price (see `adjust.ts`). */
859
962
  adjust: adjustSchema.optional(),
963
+ /** How a person belongs to a row, so a role's rows can be limited to their own or their team's (see `roles.ts`). */
964
+ belongs: belongsSchema.optional(),
965
+ /** Rows read by the people they belong to and by nobody and nothing else: no rule, public entry, document or export. */
966
+ private: z.literal(true).optional(),
967
+ /** An add-on's table: the most an app's role may be given of it (see `app-grants.ts`). */
968
+ appGrants: appGrantsSchema.optional(),
860
969
  })
861
970
  .strict()
862
971
  .refine((t) => new Set(t.columns.map((c) => c.ref)).size === t.columns.length, { message: 'duplicate column ref in table', path: ['columns'] })
@@ -1080,7 +1189,7 @@ export function shapeDefinitionIssues(addOn, settings, shapes) {
1080
1189
  out.push(...mappingIssues(profile.part, profile.mapping, index, at));
1081
1190
  });
1082
1191
  (shape.outbox?.producers ?? []).forEach((producer, p) => {
1083
- const source = 'onCreate' in producer ? producer.onCreate : 'onChange' in producer ? producer.onChange : producer.before;
1192
+ const source = 'onCreate' in producer ? producer.onCreate : 'onChange' in producer ? producer.onChange : 'onDelete' in producer ? producer.onDelete : producer.before;
1084
1193
  if (shape.parts[source.table] === undefined) {
1085
1194
  out.push({ path: ['addOn', 'shapes', s, 'outbox', 'producers', p], message: `"${id}" has no part "${source.table}"` });
1086
1195
  }
@@ -1094,13 +1203,25 @@ export function shapeDefinitionIssues(addOn, settings, shapes) {
1094
1203
  return out;
1095
1204
  }
1096
1205
  // ── pages ────────────────────────────────────────────────────────────────────
1097
- export const pageNavSchema = z
1206
+ /** Where an entry of the app's section sits: its group, its icon, its place in the order. */
1207
+ export const railPlaceSchema = z
1098
1208
  .object({
1099
1209
  group: z.string().min(1).max(80),
1100
1210
  icon: z.string().min(1).max(60),
1101
1211
  order: z.number().int(),
1102
1212
  })
1103
1213
  .strict();
1214
+ export const pageNavSchema = railPlaceSchema
1215
+ .extend({
1216
+ /**
1217
+ * The page is kept off the sidebar. It still exists: it opens by its
1218
+ * address for whoever may view it, and search, the command palette and
1219
+ * the assistant still find it for them. Somebody who may not view it
1220
+ * finds it nowhere, as with any page.
1221
+ */
1222
+ offRail: z.literal(true).optional(),
1223
+ })
1224
+ .strict();
1104
1225
  export const pageSchema = z
1105
1226
  .object({
1106
1227
  ref: z.string().regex(/^[a-z][a-z0-9-]*$/, 'page ref must be a kebab-case identifier'),
@@ -1120,6 +1241,8 @@ export const pageSchema = z
1120
1241
  .optional(),
1121
1242
  /** The page shows only while this feature's add-ons are there (see `addOns.features`). */
1122
1243
  feature: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a feature id').optional(),
1244
+ /** The part of the app the page belongs to: hidden and refused while it is off (see `modules.ts`). */
1245
+ module: moduleIdSchema.optional(),
1123
1246
  })
1124
1247
  .strict();
1125
1248
  // ── roles ────────────────────────────────────────────────────────────────────
@@ -1127,6 +1250,10 @@ export const roleSchema = z
1127
1250
  .object({
1128
1251
  key: z.string().regex(/^[a-z][a-z0-9-]*$/, 'role key must be a kebab-case identifier'),
1129
1252
  name: z.string().min(1).max(80),
1253
+ /** The name in other languages, keyed by BCP 47 tag; `name` stays the English. Shown until the owner renames the role. */
1254
+ names: roleNamesSchema.optional(),
1255
+ /** The parts of the app the role belongs to: offered while any of them is on (see `modules.ts`). */
1256
+ modules: z.array(moduleIdSchema).min(1).max(16).optional(),
1130
1257
  cloneFrom: z.string().optional(),
1131
1258
  // grant strings validated against the RBAC grammar at install time.
1132
1259
  permissions: z.array(z.string().min(1)).optional(),
@@ -1256,6 +1383,16 @@ export const frontendSchema = z
1256
1383
  placement: z.enum(['internal', 'external']).optional(),
1257
1384
  /** Whether the side starts switched on. Absent means on. */
1258
1385
  enabled: z.boolean().optional(),
1386
+ /**
1387
+ * Where a record opens on this side, by table: `{ "deals": "/deals/{id}" }`.
1388
+ * A notice about such a record, its e-mail's button and a search hit then
1389
+ * open the app's own screen for somebody who may open this side, and the
1390
+ * dashboard's record page for everybody else. The staff side's alone.
1391
+ */
1392
+ records: z
1393
+ .record(z.string().regex(/^[a-z][a-z0-9_]*$/, 'a table ref'), z.string().max(200).refine(isRecordAddress, RECORD_ADDRESS_MESSAGE))
1394
+ .refine((value) => Object.keys(value).length > 0, 'name at least one table')
1395
+ .optional(),
1259
1396
  })
1260
1397
  .strict();
1261
1398
  // ── the app's sidebar, public access and sample data ─────────────────────────
@@ -1283,6 +1420,26 @@ export const navGroupSchema = z
1283
1420
  order: z.number().int(),
1284
1421
  })
1285
1422
  .strict();
1423
+ /**
1424
+ * An entry of the app's sidebar section that is not a page. It opens one of a
1425
+ * closed list of places, never an address the manifest writes out: a screen of
1426
+ * the dashboard narrowed to this app, or a screen of the app's own staff side.
1427
+ */
1428
+ export const navLinkSchema = z
1429
+ .object({
1430
+ ref: z.string().regex(/^[a-z][a-z0-9-]*$/, 'a link ref is kebab-case').max(60),
1431
+ label: labelsSchema,
1432
+ nav: railPlaceSchema,
1433
+ to: z.union([
1434
+ /** A dashboard screen showing this app's own: its rules, its e-mails, its settings (parts, sample data). */
1435
+ z.object({ screen: z.enum(NAV_LINK_SCREENS) }).strict(),
1436
+ /** A screen of the app's staff side; no `path` is its first screen. */
1437
+ z.object({ side: z.literal('staff'), path: z.string().max(200).refine(isSidePath, SIDE_PATH_MESSAGE).optional() }).strict(),
1438
+ ]),
1439
+ /** The part of the app the entry belongs to: it leaves the sidebar while the part is off. */
1440
+ module: moduleIdSchema.optional(),
1441
+ })
1442
+ .strict();
1286
1443
  /** What the app's public screens may do (see `public-access.ts`). */
1287
1444
  export { publicAccessSchema, publicKeysSchema };
1288
1445
  /** The bundled sample data file, inside the package's `seeds/` folder. */
@@ -1310,6 +1467,40 @@ export const sampleDataSchema = z
1310
1467
  addOns: z
1311
1468
  .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())
1312
1469
  .optional(),
1470
+ /**
1471
+ * The people of the sample business: a rep per pipeline, a crew per van.
1472
+ * "Add sample data" makes each a staff member who cannot sign in and is
1473
+ * marked a sample person, with one of the app's own roles; a sample row
1474
+ * names one by key (`{"@person": "dana"}`, see `sample.ts`), and "Remove
1475
+ * sample data" takes them away with their rows. A name is a person's
1476
+ * name: data, written once, in no language.
1477
+ */
1478
+ people: z
1479
+ .array(z
1480
+ .object({
1481
+ key: z.string().regex(/^[a-z][a-z0-9_-]{0,39}$/, 'a short key: lower-case letters, digits, _ and -'),
1482
+ name: z.string().min(1).max(120),
1483
+ /** Shown beside the name; never mailed. Left out, or taken already, Adminium gives the person an address of its own. */
1484
+ email: z.string().max(200).regex(/^[^@\s]+@[^@\s]+$/, 'an email address').optional(),
1485
+ /** One of this app's roles, by its key. */
1486
+ role: z.string().min(1).max(64).optional(),
1487
+ })
1488
+ .strict())
1489
+ .min(1)
1490
+ .max(200)
1491
+ .optional(),
1492
+ /** Teams of sample people: `lead` reaches the rows of the `members` (and is one of the team). */
1493
+ teams: z
1494
+ .array(z
1495
+ .object({
1496
+ name: z.string().min(1).max(60),
1497
+ lead: z.string().min(1).max(40).optional(),
1498
+ members: z.array(z.string().min(1).max(40)).min(1).max(200),
1499
+ })
1500
+ .strict())
1501
+ .min(1)
1502
+ .max(50)
1503
+ .optional(),
1313
1504
  })
1314
1505
  .strict();
1315
1506
  /** Hosted-only and offline-required are mutually exclusive. */
@@ -1376,14 +1567,72 @@ export function prefixFor(appKey) {
1376
1567
  export const MAX_TABLE_NAME = 63;
1377
1568
  /** Column types a count is kept in: a whole number. */
1378
1569
  const WHOLE_TYPES = ['int', 'bigint'];
1570
+ /** What "the newest" may be read by: a moment, a day, or a number that only grows. */
1571
+ const LATEST_BY_TYPES = ['timestamptz', 'timestamp', 'datetime', 'date', 'int', 'bigint', 'decimal'];
1572
+ /**
1573
+ * The newest child row's value (`rollup.latest`): `of` and `by` are the
1574
+ * child's columns, the value's column is this table's and may be empty (no
1575
+ * row qualifies yet), of the type it copies; `row` is a plain column of this
1576
+ * table that holds the child's key and nothing else decides.
1577
+ */
1578
+ function rollupLatestIssues(index, table, column, from, latest, decide, here) {
1579
+ const out = [];
1580
+ const of = index.column(from, latest.of);
1581
+ const by = index.column(from, latest.by);
1582
+ if (of === undefined)
1583
+ out.push({ path: here('rollup', 'latest', 'of'), message: `"${from}" has no column "${latest.of}"` });
1584
+ else if (of.type !== column.type)
1585
+ out.push({ path: here('rollup', 'latest', 'of'), message: `"${from}.${latest.of}" is ${of.type} and "${table.ref}.${column.ref}" is ${column.type}: the newest row's value is kept in a column of its own type` });
1586
+ if (by === undefined)
1587
+ out.push({ path: here('rollup', 'latest', 'by'), message: `"${from}" has no column "${latest.by}"` });
1588
+ else if (!LATEST_BY_TYPES.includes(by.type))
1589
+ out.push({ path: here('rollup', 'latest', 'by'), message: `"${from}.${latest.by}" is ${by.type}: the newest row is told by a date, a time or a number` });
1590
+ if (column.nullable !== true)
1591
+ out.push({ path: here('rollup', 'latest'), message: `"${table.ref}.${column.ref}" is empty while no row qualifies: make it nullable` });
1592
+ if (latest.row !== undefined) {
1593
+ const row = index.column(table.ref, latest.row);
1594
+ decide(table.ref, latest.row);
1595
+ if (row === undefined)
1596
+ out.push({ path: here('rollup', 'latest', 'row'), message: `"${table.ref}" has no column "${latest.row}"` });
1597
+ else {
1598
+ if (latest.row === column.ref)
1599
+ out.push({ path: here('rollup', 'latest', 'row'), message: 'the row\'s key is kept in a column of its own' });
1600
+ if (row.rules !== undefined)
1601
+ out.push({ path: here('rollup', 'latest', 'row'), message: `"${latest.row}" holds the key of the row Adminium took; it takes no rules of its own` });
1602
+ if (row.nullable !== true)
1603
+ out.push({ path: here('rollup', 'latest', 'row'), message: `"${table.ref}.${latest.row}" is empty while no row qualifies: make it nullable` });
1604
+ // Not a link: the database would refuse to delete the very row the column names, before Adminium could let go of it.
1605
+ if (row.type === 'fk') {
1606
+ out.push({ path: here('rollup', 'latest', 'row'), message: `"${table.ref}.${latest.row}" is a link: the key of the row Adminium took is kept as a plain column (the type of "${from}"'s key), so that row can still be deleted` });
1607
+ }
1608
+ else if (!WHOLE_TYPES.includes(row.type) && row.type !== 'text' && row.type !== 'uuid') {
1609
+ out.push({ path: here('rollup', 'latest', 'row'), message: `"${table.ref}.${latest.row}" holds a key of "${from}": a column of its key's type` });
1610
+ }
1611
+ else {
1612
+ // Held to the key it names: a whole number against a uuid is refused by the database on every write of the total.
1613
+ const key = index.pk(from);
1614
+ if (key === undefined) {
1615
+ out.push({ path: here('rollup', 'latest', 'row'), message: `"${from}" has no key of one column, so the row that was taken cannot be named: leave "row" out` });
1616
+ }
1617
+ else if (!(WHOLE_TYPES.includes(row.type) && WHOLE_TYPES.includes(key.type)) && row.type !== key.type) {
1618
+ out.push({ path: here('rollup', 'latest', 'row'), message: `"${table.ref}.${latest.row}" is ${row.type} and the key of "${from}" is ${key.type}: the key of the row Adminium took is kept in a column of that key's type` });
1619
+ }
1620
+ }
1621
+ }
1622
+ }
1623
+ return out;
1624
+ }
1379
1625
  /**
1380
1626
  * A total adds a column up (`sum`) or counts the rows (`count: true`), and a
1381
1627
  * count is a whole number with nothing multiplied into it and no balance.
1382
1628
  */
1383
1629
  function rollupCountIssues(r, type, here) {
1384
1630
  const out = [];
1385
- if ((r.sum === undefined) === (r.count === undefined)) {
1386
- out.push({ path: here('rollup'), message: 'a total adds up `sum` or counts rows (`count: true`), not both' });
1631
+ if ([r.sum, r.count, r.latest].filter((kind) => kind !== undefined).length !== 1) {
1632
+ out.push({ path: here('rollup'), message: 'a total adds up `sum`, counts rows (`count: true`) or takes the newest row\'s value (`latest`): one of the three' });
1633
+ }
1634
+ if (r.latest !== undefined && (r.times !== undefined || r.balance !== undefined || r.cap !== undefined || r.capUnless !== undefined)) {
1635
+ out.push({ path: here('rollup', 'latest'), message: 'the newest row\'s value takes no `times`, `balance` or `cap`' });
1387
1636
  }
1388
1637
  if (r.count === true) {
1389
1638
  if (r.times !== undefined || r.balance !== undefined || r.cap !== undefined) {
@@ -1408,6 +1657,8 @@ function totalsOf(table) {
1408
1657
  out.set(column.ref, new Set([column.ref]));
1409
1658
  if (r.balance !== undefined)
1410
1659
  out.set(r.balance.column, new Set([column.ref]));
1660
+ if (r.latest?.row !== undefined)
1661
+ out.set(r.latest.row, new Set([column.ref]));
1411
1662
  }
1412
1663
  const formulas = table.columns.filter((column) => column.rules?.formula !== undefined);
1413
1664
  for (let changed = true; changed;) {
@@ -1442,7 +1693,7 @@ function rollupChainIssues(tables) {
1442
1693
  const r = column.rules?.rollup;
1443
1694
  if (r === undefined)
1444
1695
  return;
1445
- const read = [r.sum, r.times, r.unlessSet, r.where?.column].filter((ref) => ref !== undefined);
1696
+ const read = [r.sum, r.times, r.unlessSet, r.where?.column, r.latest?.of, r.latest?.by].filter((ref) => ref !== undefined);
1446
1697
  const childTotals = totals.get(r.from) ?? new Map();
1447
1698
  const reads = new Set();
1448
1699
  for (const ref of read)
@@ -1578,7 +1829,7 @@ function followIssues(tables, table, own, copy, here) {
1578
1829
  if (rollup?.from !== table.ref)
1579
1830
  return false;
1580
1831
  // The link a total groups its rows by counts too: a copy that moves a row to another parent moves it between that table's totals.
1581
- return [rollup.via, rollup.sum, rollup.times, rollup.unlessSet, rollup.where?.column].some((ref) => ref !== undefined && written.has(ref));
1832
+ return [rollup.via, rollup.sum, rollup.times, rollup.unlessSet, rollup.where?.column, rollup.latest?.of, rollup.latest?.by].some((ref) => ref !== undefined && written.has(ref));
1582
1833
  });
1583
1834
  if (reads) {
1584
1835
  out.push({ path: here('copy', 'follow'), message: `"${table.ref}" totals into "${other.ref}" too, so its copies cannot follow "${parent.ref}": a total "${other.ref}" keeps reads what the follow writes` });
@@ -1609,7 +1860,7 @@ function figureColumns(tables) {
1609
1860
  mark(table.ref, input);
1610
1861
  const r = rules?.rollup;
1611
1862
  if (r !== undefined)
1612
- for (const input of [r.sum, r.times])
1863
+ for (const input of [r.sum, r.times, r.latest?.of])
1613
1864
  if (input !== undefined)
1614
1865
  mark(r.from, input);
1615
1866
  }
@@ -1759,6 +2010,13 @@ shapeOf) {
1759
2010
  const index = tableIndex(m.requiredSchema.tables);
1760
2011
  const tables = new Map(m.requiredSchema.tables.map((t) => [t.ref, t]));
1761
2012
  const has = index.has;
2013
+ /** Whether the rule at `path` is of a table that sits in a part whose feature needs `addOn`: the table is there only while the add-on is. */
2014
+ const inPartOf = (path, addOn) => {
2015
+ if (path[0] !== 'requiredSchema' || path[1] !== 'tables' || typeof path[2] !== 'number')
2016
+ return false;
2017
+ const table = m.requiredSchema.tables[path[2]];
2018
+ return table !== undefined && shapeIsOptional(m, table, addOn);
2019
+ };
1762
2020
  /** A setting a rule reads must be there when the rule runs. */
1763
2021
  const settingIssue = (source, path) => {
1764
2022
  if ('addOn' in source) {
@@ -1768,7 +2026,7 @@ shapeOf) {
1768
2026
  else if (!shapeOf.settings.includes(source.setting))
1769
2027
  out.push({ path, message: `"${source.addOn}" has no setting "${source.setting}"` });
1770
2028
  }
1771
- else if (!requiresAddOn(m.addOns, source.addOn)) {
2029
+ else if (!requiresAddOn(m.addOns, source.addOn) && !inPartOf(path, source.addOn)) {
1772
2030
  out.push({ path, message: `"${source.addOn}" is not required by the app (addOns.requires), so its setting may not be there` });
1773
2031
  }
1774
2032
  }
@@ -1889,6 +2147,56 @@ shapeOf) {
1889
2147
  if (Array.isArray(bound.strict))
1890
2148
  bound.strict.forEach((condition, w) => out.push(...conditionIssues(table.ref, condition, index, here(side, 'strict', w))));
1891
2149
  }
2150
+ if (rules.linkable !== undefined) {
2151
+ // The conditions read the row the link names, so the column is a link and they name that table's columns.
2152
+ if (column.type !== 'fk' || column.references === undefined) {
2153
+ out.push({ path: here('linkable'), message: 'only a foreign key names a row, so only one takes linkable' });
2154
+ }
2155
+ else {
2156
+ const linked = column.references;
2157
+ (rules.linkable.where ?? []).forEach((condition, w) => {
2158
+ out.push(...conditionIssues(linked, condition, index, here('linkable', 'where', w)));
2159
+ // A refusal says whether the named row meets the condition: never asked of a value kept from every reader.
2160
+ if (tables.get(linked)?.columns.find((x) => x.ref === condition.column)?.rules?.secret === true) {
2161
+ out.push({ path: here('linkable', 'where', w, 'column'), message: `"${linked}.${condition.column}" is a secret, and a link is never judged by one` });
2162
+ }
2163
+ });
2164
+ // What a column holds as a value to compare: a link holds the key of the row it names.
2165
+ const held = (of) => (of.type === 'fk' && of.references !== undefined ? (index.pk(of.references)?.type ?? 'fk') : of.type).replace(/^bigint$/, 'int');
2166
+ (rules.linkable.same ?? []).forEach((pair, p) => {
2167
+ const at = (...rest) => here('linkable', 'same', p, ...rest);
2168
+ const theirs = index.column(linked, pair.column);
2169
+ if (theirs === undefined)
2170
+ out.push({ path: at('column'), message: `"${linked}" has no column "${pair.column}"` });
2171
+ else if (theirs.rules?.secret === true)
2172
+ out.push({ path: at('column'), message: `"${linked}.${pair.column}" is a secret, and a link is never judged by one` });
2173
+ // This row's side: a column of its own, or one of the row a link of it names.
2174
+ const [first, second] = pair.as.split('.');
2175
+ const own = index.column(table.ref, first);
2176
+ let ours;
2177
+ if (own === undefined)
2178
+ out.push({ path: at('as'), message: `"${table.ref}" has no column "${first}"` });
2179
+ else if (second === undefined) {
2180
+ if (own.ref === column.ref)
2181
+ out.push({ path: at('as'), message: 'a link is compared with another column, not with itself' });
2182
+ else
2183
+ ours = own;
2184
+ }
2185
+ else if (own.type !== 'fk' || own.references === undefined)
2186
+ out.push({ path: at('as'), message: `"${table.ref}.${first}" is not a foreign key, so it names no row to read "${second}" of` });
2187
+ else {
2188
+ ours = index.column(own.references, second);
2189
+ if (ours === undefined)
2190
+ out.push({ path: at('as'), message: `"${own.references}" has no column "${second}"` });
2191
+ else if (ours.rules?.secret === true)
2192
+ out.push({ path: at('as'), message: `"${own.references}.${second}" is a secret, and a link is never judged by one` });
2193
+ }
2194
+ if (theirs !== undefined && ours !== undefined && held(theirs) !== held(ours)) {
2195
+ out.push({ path: at('as'), message: `"${linked}.${pair.column}" holds ${held(theirs)} and "${pair.as}" holds ${held(ours)}: they never hold the same value` });
2196
+ }
2197
+ });
2198
+ }
2199
+ }
1892
2200
  if (rules.requiredWhen !== undefined) {
1893
2201
  const when = rules.requiredWhen;
1894
2202
  const other = index.column(table.ref, when.column);
@@ -1939,6 +2247,13 @@ shapeOf) {
1939
2247
  else if (seq.scope !== undefined || seq.startSetting !== undefined) {
1940
2248
  out.push({ path: here('sequence'), message: 'scope and startSetting number without gaps: add gapless' });
1941
2249
  }
2250
+ if (seq.sample === true) {
2251
+ if (seq.gapless !== true)
2252
+ out.push({ path: here('sequence', 'sample'), message: 'only a number without gaps is kept from sample rows: a plain running number needs no "sample"' });
2253
+ // A document built on an add-on's shape is numbered for the tax office: a sample row removed later would leave a hole in it.
2254
+ if (table.builtOn !== undefined)
2255
+ out.push({ path: here('sequence', 'sample'), message: `"${table.ref}" is built on an add-on's document: its numbers are never a sample row's` });
2256
+ }
1942
2257
  if (seq.startSetting !== undefined)
1943
2258
  settingIssue(seq.startSetting, here('sequence', 'startSetting'));
1944
2259
  if (seq.scope !== undefined) {
@@ -2031,6 +2346,8 @@ shapeOf) {
2031
2346
  if (!has(r.from, name))
2032
2347
  out.push({ path: here('rollup'), message: `"${r.from}" has no column "${name}"` });
2033
2348
  }
2349
+ if (r.latest !== undefined)
2350
+ out.push(...rollupLatestIssues(index, table, column, r.from, r.latest, decide, here));
2034
2351
  if (r.where !== undefined) {
2035
2352
  const filter = index.column(r.from, r.where.column);
2036
2353
  if (filter === undefined)
@@ -2326,6 +2643,56 @@ shapeOf) {
2326
2643
  if (rules.tableRef !== undefined && (column.type !== 'text' || column.maxLength === undefined)) {
2327
2644
  out.push({ path: here('tableRef'), message: 'a table name is kept in a text column with maxLength' });
2328
2645
  }
2646
+ // A person's id is text on every database: a number or a uuid column would compare with nothing.
2647
+ if (rules.mentions !== undefined) {
2648
+ if (column.type !== 'text')
2649
+ out.push({ path: here('mentions'), message: `people are mentioned in a text column, and "${table.ref}.${column.ref}" is ${column.type}` });
2650
+ if (table.private === true)
2651
+ out.push({ path: here('mentions'), message: 'a private table tells nobody of what is written in it: no mentions' });
2652
+ const about = typeof rules.mentions === 'object' ? rules.mentions.about : undefined;
2653
+ const link = about === undefined ? undefined : table.columns.find((candidate) => candidate.ref === about);
2654
+ if (about !== undefined && (link === undefined || link.type !== 'fk'))
2655
+ out.push({ path: here('mentions', 'about'), message: `"${table.ref}.${about}" is not a link of this table` });
2656
+ }
2657
+ if (rules.staff !== undefined && column.type !== 'text') {
2658
+ out.push({ path: here('staff'), message: 'a staff member is kept in a text column' });
2659
+ }
2660
+ // A position and an area are kept whole, as JSON: two number columns or a text column are not one.
2661
+ if (rules.place !== undefined && column.type !== 'json') {
2662
+ out.push({ path: here('place'), message: `a place is kept in a json column, and "${table.ref}.${column.ref}" is ${column.type}` });
2663
+ }
2664
+ if (rules.area !== undefined && column.type !== 'json') {
2665
+ out.push({ path: here('area'), message: `an area is kept in a json column, and "${table.ref}.${column.ref}" is ${column.type}` });
2666
+ }
2667
+ if (rules.place !== undefined && rules.area !== undefined) {
2668
+ out.push({ path: here('area'), message: 'a column holds a place or an area, not both' });
2669
+ }
2670
+ if (rules.file !== undefined) {
2671
+ // One file is its id in a text column; a list of them is kept whole, as JSON.
2672
+ if (column.type === 'text') {
2673
+ if (rules.file.maxCount !== undefined)
2674
+ out.push({ path: here('file', 'maxCount'), message: 'a text column holds one file; keep a list of them in a json column' });
2675
+ if (column.maxLength !== undefined && column.maxLength < 31)
2676
+ out.push({ path: here('file'), message: "a file's id is 31 characters, so maxLength is at least 31" });
2677
+ }
2678
+ else if (column.type === 'json') {
2679
+ if (rules.file.maxCount === undefined)
2680
+ out.push({ path: here('file', 'maxCount'), message: 'say how many files the list may hold (maxCount)' });
2681
+ }
2682
+ else {
2683
+ out.push({ path: here('file'), message: `a file is kept in a text column, a list of files in a json column, and "${table.ref}.${column.ref}" is ${column.type}` });
2684
+ }
2685
+ if (new Set(rules.file.accept).size !== rules.file.accept.length)
2686
+ out.push({ path: here('file', 'accept'), message: 'each kind of file is named once' });
2687
+ // What the column holds is a file a person uploaded: no rule of Adminium's makes one, and no two rows share one.
2688
+ const others = Object.keys(rules).filter((name) => !['file', 'personal', 'required', 'requiredWhen'].includes(name));
2689
+ if (others.length > 0)
2690
+ out.push({ path: here('file'), message: `a file column holds what a person uploaded, not also ${others.join(', ')}` });
2691
+ if (column.unique === true)
2692
+ out.push({ path: at('columns', c, 'unique'), message: 'a file column holds a file nobody else holds already: it is not also unique' });
2693
+ if (column.default !== undefined)
2694
+ out.push({ path: at('columns', c, 'default'), message: 'a file column takes no default: a file is uploaded' });
2695
+ }
2329
2696
  if (rules.plainText !== undefined && column.type !== 'text') {
2330
2697
  out.push({ path: here('plainText'), message: 'plain text is a rule of a text column' });
2331
2698
  }
@@ -2449,6 +2816,14 @@ shapeOf) {
2449
2816
  if (table.booking.cancel?.flag !== undefined)
2450
2817
  decide(table.ref, table.booking.cancel.flag);
2451
2818
  }
2819
+ if (table.occupies !== undefined) {
2820
+ out.push(...occupiesIssues(table, table.occupies, index, at));
2821
+ // One lock a write: a table's rows are placed by one of the three rules, never two.
2822
+ if (table.booking !== undefined)
2823
+ out.push({ path: at('occupies'), message: 'a table carries "booking" or "occupies", not both' });
2824
+ if (table.capacity !== undefined)
2825
+ out.push({ path: at('occupies'), message: 'a table carries "capacity" or "occupies", not both' });
2826
+ }
2452
2827
  out.push(...tableFormulaIssues(table.columns, (c, ...rest) => at('columns', c, ...rest)));
2453
2828
  if (table.states !== undefined) {
2454
2829
  out.push(...statesIssues(table.ref, table.states, {
@@ -2459,7 +2834,7 @@ shapeOf) {
2459
2834
  decided: (column) => decided.get(table.ref)?.has(column) === true,
2460
2835
  bookingCancel: table.booking?.cancel === undefined ? undefined : table.booking.cancel.when,
2461
2836
  statesOf: (ref) => tables.get(ref)?.states,
2462
- bookedOf: (ref) => tables.get(ref)?.booking !== undefined,
2837
+ bookedOf: (ref) => tables.get(ref)?.booking !== undefined || tables.get(ref)?.occupies !== undefined,
2463
2838
  lineOf: (ref) => [...tables.values()].find((other) => other.states?.children?.[ref] !== undefined)?.ref,
2464
2839
  outboxTable: m.outbox?.table,
2465
2840
  pages: new Set((m.pages ?? []).flatMap((page) => ('ref' in page && typeof page.ref === 'string' ? [page.ref] : []))),
@@ -2472,8 +2847,11 @@ shapeOf) {
2472
2847
  }
2473
2848
  if (table.builtOn !== undefined && shapeOf === undefined) {
2474
2849
  const addOn = table.builtOn.split('/')[0];
2475
- if (!requiresAddOn(m.addOns, addOn)) {
2476
- out.push({ path: at('builtOn'), message: `"${addOn}" defines the shape, so the app requires it (addOns.requires)` });
2850
+ if (!requiresAddOn(m.addOns, addOn) && !shapeIsOptional(m, table, addOn)) {
2851
+ out.push({
2852
+ path: at('builtOn'),
2853
+ message: `"${addOn}" defines the shape, so the app requires it (addOns.requires), or the table sits in a module whose feature needs "${addOn}"`,
2854
+ });
2477
2855
  }
2478
2856
  }
2479
2857
  });
@@ -2494,7 +2872,34 @@ shapeOf) {
2494
2872
  out.push(...settingTableIssues(m));
2495
2873
  out.push(...slotPartyIssues(m));
2496
2874
  out.push(...outboxIssues(m, index));
2497
- out.push(...roleLimitIssues(m.roles ?? [], index));
2875
+ const staffColumn = (table, column) => tables.get(table)?.columns.find((c) => c.ref === column)?.rules?.staff === true;
2876
+ out.push(...belongsIssues(m.requiredSchema.tables, index, staffColumn));
2877
+ out.push(...roleLimitIssues(m.roles ?? [], index, rowTablesOf(m.requiredSchema.tables)));
2878
+ out.push(...privateTableIssues(m, m.requiredSchema.tables.filter((table) => table.private === true).map((table) => table.ref)));
2879
+ out.push(...storedTotalIssues(m.requiredSchema.tables, (m.roles ?? [])));
2880
+ out.push(...privateShapeIssues(m.requiredSchema.tables));
2881
+ // Whose a row is, is an app's to say for now: an add-on's tables are shared by every app that names it.
2882
+ if (m.kind === 'add-on') {
2883
+ m.requiredSchema.tables.forEach((table, t) => {
2884
+ for (const key of ['belongs', 'private']) {
2885
+ if (table[key] !== undefined)
2886
+ out.push({ path: ['requiredSchema', 'tables', t, key], message: `"${key}" is not read on an add-on's table yet` });
2887
+ }
2888
+ table.columns.forEach((column, c) => {
2889
+ if (column.rules?.staff !== undefined)
2890
+ out.push({ path: ['requiredSchema', 'tables', t, 'columns', c, 'rules', 'staff'], message: 'a staff column is not read on an add-on\'s table yet' });
2891
+ });
2892
+ });
2893
+ }
2894
+ // What an app's role may be given of an add-on's table: the add-on's to say, of its own tables.
2895
+ if (m.kind === 'add-on')
2896
+ out.push(...appGrantsIssues(m.requiredSchema.tables, m.ledgers ?? []));
2897
+ else {
2898
+ m.requiredSchema.tables.forEach((table, t) => {
2899
+ if (table.appGrants !== undefined)
2900
+ out.push({ path: ['requiredSchema', 'tables', t, 'appGrants'], message: 'an app\'s table is granted by its own roles: "appGrants" is how an add-on says what an app may be given of its tables' });
2901
+ });
2902
+ }
2498
2903
  // A role's grants on an add-on's tables: an app's to give, on the add-ons it names.
2499
2904
  if ((m.roles ?? []).some((role) => role.tables !== undefined)) {
2500
2905
  const roles = (m.roles ?? []);
@@ -2603,7 +3008,7 @@ function settingTableIssues(m) {
2603
3008
  const table = tables.find((t) => t.ref === ref);
2604
3009
  if (table === undefined)
2605
3010
  return true; // a table the manifest lacks is refused where the rule is checked
2606
- if (table.states !== undefined || table.capacity !== undefined || table.booking !== undefined)
3011
+ if (table.states !== undefined || table.capacity !== undefined || table.booking !== undefined || table.occupies !== undefined)
2607
3012
  return false;
2608
3013
  if (table.columns.some((column) => column.type === 'fk'))
2609
3014
  return false;
@@ -2948,8 +3353,18 @@ export const appManifestSchema = z
2948
3353
  * required-singular shape however their `requiredSchema` was repaired.
2949
3354
  */
2950
3355
  frontends: z.array(frontendSchema).min(1),
3356
+ /** Entries of the app's sidebar section that are not pages (see `rail.ts`). */
3357
+ navLinks: z.array(navLinkSchema).max(24).optional(),
2951
3358
  /** The add-ons the app needs, suggests, or needs for a feature (see `add-ons.ts`). */
2952
3359
  addOns: addOnsSchema.optional(),
3360
+ /** The parts of the app its owner switches on and off (see `modules.ts`). */
3361
+ modules: modulesSchema.optional(),
3362
+ /**
3363
+ * The app shows maps. Its install asks once, for the whole install,
3364
+ * whether maps are shown (a map's pictures come from a map server); the
3365
+ * first app that says this asks, and no later one asks again.
3366
+ */
3367
+ maps: z.literal(true).optional(),
2953
3368
  })
2954
3369
  .strict()
2955
3370
  .refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
@@ -2958,15 +3373,27 @@ export const appManifestSchema = z
2958
3373
  .superRefine((m, ctx) => {
2959
3374
  for (const issue of phoneSideIssues(m))
2960
3375
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
3376
+ for (const issue of railIssues(m))
3377
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
2961
3378
  for (const issue of installFloorIssues(m))
2962
3379
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
2963
3380
  for (const issue of appReferenceIssues(m)) {
2964
3381
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
2965
3382
  }
3383
+ for (const issue of appSeedIssues(m))
3384
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
3385
+ for (const issue of moduleIssues(m))
3386
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
3387
+ for (const issue of leftOutIssues(m))
3388
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
2966
3389
  // A calendar page's named columns, against the app's own tables.
2967
3390
  for (const issue of pageCalendarIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
2968
3391
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
2969
3392
  }
3393
+ // A board page's named columns, against the app's own tables.
3394
+ for (const issue of pageBoardIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
3395
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
3396
+ }
2970
3397
  // A records page's tab words, filters and bulk actions, against the app's own tables.
2971
3398
  for (const issue of pageConfigIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
2972
3399
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
@@ -3015,6 +3442,8 @@ export const addOnManifestSchema = z
3015
3442
  .superRefine((m, ctx) => {
3016
3443
  for (const issue of installFloorIssues(m))
3017
3444
  ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
3445
+ for (const issue of addOnModuleIssues(m))
3446
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
3018
3447
  // Add-ons released before the floor keep the page refs they shipped with.
3019
3448
  if (m.pages !== undefined || compareSemver(m.compatibility.minAdminiumVersion, ADD_ON_INSTALL_FLOOR) >= 0) {
3020
3449
  for (const issue of addOnPageRefIssues(m))
@@ -3057,6 +3486,32 @@ export const addOnManifestSchema = z
3057
3486
  export const manifestSchema = z.preprocess((v) => typeof v === 'object' && v !== null && !Array.isArray(v) && v.kind === undefined
3058
3487
  ? { ...v, kind: 'app' }
3059
3488
  : v, z.discriminatedUnion('kind', [appManifestSchema, addOnManifestSchema]));
3489
+ /**
3490
+ * An app that installs without an add-on one of its tables is built on is
3491
+ * installed with that table's module left out. What is left must be a whole
3492
+ * manifest of its own: each such add-on is taken away in turn and the rest
3493
+ * checked as the app is.
3494
+ */
3495
+ function leftOutIssues(m) {
3496
+ const out = [];
3497
+ const optional = [...optionalShapeAddOns(m)];
3498
+ // Each alone, and all of them at once: a role of two such parts is gone only when both are.
3499
+ const cases = optional.map(([addOn, modules]) => [`"${addOn}"`, modules]);
3500
+ if (optional.length > 1)
3501
+ cases.push([optional.map(([addOn]) => `"${addOn}"`).join(' and '), [...new Set(optional.flatMap(([, modules]) => modules))]]);
3502
+ for (const [without, modules] of cases) {
3503
+ const parsed = appManifestSchema.safeParse(withoutModules(m, modules));
3504
+ if (parsed.success)
3505
+ continue;
3506
+ for (const issue of parsed.error.issues.slice(0, 20)) {
3507
+ out.push({
3508
+ path: ['modules'],
3509
+ message: `installed without ${without} (module ${modules.map((id) => `"${id}"`).join(', ')} left out): ${issue.path.map(String).join('.')}: ${issue.message}`,
3510
+ });
3511
+ }
3512
+ }
3513
+ return out;
3514
+ }
3060
3515
  /**
3061
3516
  * A word only a newer Adminium reads, under a floor that admits an older one:
3062
3517
  * that server would refuse the manifest as an unknown key, so the floor is
@@ -3098,6 +3553,7 @@ export function installsLikeAnApp(m) {
3098
3553
  return (m.requiredSchema?.tables ?? []).some((table) => table.states !== undefined ||
3099
3554
  table.capacity !== undefined ||
3100
3555
  table.booking !== undefined ||
3556
+ table.occupies !== undefined ||
3101
3557
  table.unique !== undefined ||
3102
3558
  table.indexes !== undefined ||
3103
3559
  table.postings !== undefined ||
@@ -3108,6 +3564,68 @@ export function installsLikeAnApp(m) {
3108
3564
  const SEED_DIRECTIVES = new Set(['@t', '@ref']);
3109
3565
  /** The most rows one table is seeded with. */
3110
3566
  export const MAX_SEED_ROWS = 200;
3567
+ /** One seed's rows: how many, the one row directive, own columns, the two value directives. */
3568
+ function seedRowIssues(seed, i, columns) {
3569
+ const out = [];
3570
+ if ((seed.rows?.length ?? 0) > MAX_SEED_ROWS)
3571
+ out.push({ path: ['seeds', i, 'rows'], message: `a table is seeded with at most ${String(MAX_SEED_ROWS)} rows` });
3572
+ (seed.rows ?? []).forEach((row, r) => {
3573
+ for (const [name, value] of Object.entries(row)) {
3574
+ const at = ['seeds', i, 'rows', r, name];
3575
+ if (name === '@label') {
3576
+ if (typeof value !== 'string')
3577
+ out.push({ path: at, message: 'a label is text' });
3578
+ continue;
3579
+ }
3580
+ if (name.startsWith('@')) {
3581
+ out.push({ path: at, message: `a seed row takes "@label" and no other row directive, not "${name}"` });
3582
+ continue;
3583
+ }
3584
+ if (!columns.has(name))
3585
+ out.push({ path: at, message: `"${seed.table}" has no column "${name}"` });
3586
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
3587
+ const directive = Object.keys(value).find((key) => key.startsWith('@'));
3588
+ if (directive !== undefined && !SEED_DIRECTIVES.has(directive)) {
3589
+ out.push({ path: at, message: `a seed value is a plain value, {"@t": {…}} or {"@ref": "<label>"}, not "${directive}"` });
3590
+ }
3591
+ }
3592
+ }
3593
+ });
3594
+ return out;
3595
+ }
3596
+ /**
3597
+ * An app's starting rows: rows of a table of its own, written once by its
3598
+ * install into a table that is still empty. Not of a table built on an
3599
+ * add-on's shape (the add-on numbers those), not of a private table (nothing
3600
+ * but its two people writes one), and never a staff member: who works here is
3601
+ * not known where a manifest is written.
3602
+ */
3603
+ export function appSeedIssues(m) {
3604
+ const out = [];
3605
+ if (m.seeds === undefined)
3606
+ return out;
3607
+ const byRef = new Map((m.requiredSchema?.tables ?? []).map((table) => [table.ref, table]));
3608
+ m.seeds.forEach((seed, i) => {
3609
+ const table = byRef.get(seed.table);
3610
+ if (table === undefined) {
3611
+ out.push({ path: ['seeds', i, 'table'], message: `"${seed.table}" is not one of this app's tables` });
3612
+ return;
3613
+ }
3614
+ if (table.builtOn !== undefined)
3615
+ out.push({ path: ['seeds', i, 'table'], message: `"${seed.table}" is built on an add-on's shape, which numbers its own rows: a seed never fills it` });
3616
+ if (table.private === true)
3617
+ out.push({ path: ['seeds', i, 'table'], message: `"${seed.table}" is private: only the people its rows belong to write it, so a seed never fills it` });
3618
+ out.push(...seedRowIssues(seed, i, new Set(table.columns.map((column) => column.ref))));
3619
+ const staff = new Set(table.columns.filter((column) => column.rules?.staff === true).map((column) => column.ref));
3620
+ (seed.rows ?? []).forEach((row, r) => {
3621
+ for (const [name, value] of Object.entries(row)) {
3622
+ if (staff.has(name) && value !== null)
3623
+ out.push({ path: ['seeds', i, 'rows', r, name], message: `"${name}" holds a staff member, and a seed names none: leave it out` });
3624
+ }
3625
+ });
3626
+ });
3627
+ return out;
3628
+ }
3111
3629
  /**
3112
3630
  * Generated pages and code pages share one address space on a connection, so
3113
3631
  * each of an add-on's is named under its key, and once.
@@ -3175,6 +3693,10 @@ export function ledgersOf(m) {
3175
3693
  const parsed = ledgersSchema.safeParse(m.addOn.ledgers);
3176
3694
  return parsed.success ? parsed.data : [];
3177
3695
  }
3696
+ /** What an app's grant on one of this add-on's tables comes to: its shape, its columns and its `appGrants` (see `app-grants.ts`). */
3697
+ export function addOnRoleTableHold(entry, addOn) {
3698
+ return roleTableHold(entry, { tables: (addOn.requiredSchema?.tables ?? []), ledgers: ledgersOf(addOn) });
3699
+ }
3178
3700
  /** Everything wrong with an add-on that installs like an app, beyond what its blocks say of themselves. */
3179
3701
  function addOnInstallIssues(m) {
3180
3702
  const out = [];
@@ -3184,7 +3706,7 @@ function addOnInstallIssues(m) {
3184
3706
  const ledgers = m.addOn.ledgers === undefined ? [] : parsedLedgers(m.addOn.ledgers, out);
3185
3707
  out.push(...appReferenceIssues({ ...m, requiredSchema: m.requiredSchema ?? { tables: [] }, ledgers, codePages: (m.addOn.pages ?? []).map((page) => page.ref) }));
3186
3708
  if (m.pages !== undefined)
3187
- out.push(...pageCalendarIssues(m.pages, tableIndex(tables)), ...pageConfigIssues(m.pages, tableIndex(tables)));
3709
+ out.push(...pageCalendarIssues(m.pages, tableIndex(tables)), ...pageBoardIssues(m.pages, tableIndex(tables)), ...pageConfigIssues(m.pages, tableIndex(tables)));
3188
3710
  // The look-up is typed loosely in the contracts package: its tables and columns are checked here.
3189
3711
  if (m.addOn.lookUp !== undefined) {
3190
3712
  const parsed = lookUpSchema.safeParse(m.addOn.lookUp);
@@ -3272,30 +3794,7 @@ function addOnInstallIssues(m) {
3272
3794
  if (ledger !== undefined) {
3273
3795
  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` });
3274
3796
  }
3275
- if ((seed.rows?.length ?? 0) > MAX_SEED_ROWS)
3276
- out.push({ path: ['seeds', i, 'rows'], message: `a table is seeded with at most ${String(MAX_SEED_ROWS)} rows` });
3277
- (seed.rows ?? []).forEach((row, r) => {
3278
- for (const [name, value] of Object.entries(row)) {
3279
- const at = ['seeds', i, 'rows', r, name];
3280
- if (name === '@label') {
3281
- if (typeof value !== 'string')
3282
- out.push({ path: at, message: 'a label is text' });
3283
- continue;
3284
- }
3285
- if (name.startsWith('@')) {
3286
- out.push({ path: at, message: `a seed row takes "@label" and no other row directive, not "${name}"` });
3287
- continue;
3288
- }
3289
- if (!columns.has(name))
3290
- out.push({ path: at, message: `"${seed.table}" has no column "${name}"` });
3291
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
3292
- const directive = Object.keys(value).find((key) => key.startsWith('@'));
3293
- if (directive !== undefined && !SEED_DIRECTIVES.has(directive)) {
3294
- out.push({ path: at, message: `a seed value is a plain value, {"@t": {…}} or {"@ref": "<label>"}, not "${directive}"` });
3295
- }
3296
- }
3297
- }
3298
- });
3797
+ out.push(...seedRowIssues(seed, i, columns));
3299
3798
  });
3300
3799
  // The settings table: one row, made from defaults, so every column must have one or may be empty.
3301
3800
  const settings = m.addOn.settingsTable;