@adminiumjs/manifest 0.3.0-rc.3 → 0.3.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 (57) hide show
  1. package/dist/booking.d.ts +100 -0
  2. package/dist/booking.d.ts.map +1 -0
  3. package/dist/booking.js +237 -0
  4. package/dist/booking.js.map +1 -0
  5. package/dist/index.d.ts +8 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +8 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/outbox.d.ts +198 -0
  10. package/dist/outbox.d.ts.map +1 -0
  11. package/dist/outbox.js +355 -0
  12. package/dist/outbox.js.map +1 -0
  13. package/dist/page-calendar.d.ts +42 -0
  14. package/dist/page-calendar.d.ts.map +1 -0
  15. package/dist/page-calendar.js +95 -0
  16. package/dist/page-calendar.js.map +1 -0
  17. package/dist/plan-context.d.ts +51 -0
  18. package/dist/plan-context.d.ts.map +1 -0
  19. package/dist/plan-context.js +356 -0
  20. package/dist/plan-context.js.map +1 -0
  21. package/dist/plan-model.d.ts +197 -0
  22. package/dist/plan-model.d.ts.map +1 -0
  23. package/dist/plan-model.js +11 -0
  24. package/dist/plan-model.js.map +1 -0
  25. package/dist/plan-types.d.ts +12 -0
  26. package/dist/plan-types.d.ts.map +1 -0
  27. package/dist/plan-types.js +71 -0
  28. package/dist/plan-types.js.map +1 -0
  29. package/dist/plan.d.ts +9 -90
  30. package/dist/plan.d.ts.map +1 -1
  31. package/dist/plan.js +48 -1
  32. package/dist/plan.js.map +1 -1
  33. package/dist/public-access.d.ts +167 -0
  34. package/dist/public-access.d.ts.map +1 -0
  35. package/dist/public-access.js +475 -0
  36. package/dist/public-access.js.map +1 -0
  37. package/dist/refs.d.ts +74 -0
  38. package/dist/refs.d.ts.map +1 -0
  39. package/dist/refs.js +70 -0
  40. package/dist/refs.js.map +1 -0
  41. package/dist/roles.d.ts +44 -0
  42. package/dist/roles.d.ts.map +1 -0
  43. package/dist/roles.js +75 -0
  44. package/dist/roles.js.map +1 -0
  45. package/dist/sample.d.ts +200 -0
  46. package/dist/sample.d.ts.map +1 -0
  47. package/dist/sample.js +208 -0
  48. package/dist/sample.js.map +1 -0
  49. package/dist/schema.d.ts +2031 -51
  50. package/dist/schema.d.ts.map +1 -1
  51. package/dist/schema.js +559 -18
  52. package/dist/schema.js.map +1 -1
  53. package/dist/semver.d.ts +28 -0
  54. package/dist/semver.d.ts.map +1 -0
  55. package/dist/semver.js +85 -0
  56. package/dist/semver.js.map +1 -0
  57. package/package.json +2 -2
package/dist/schema.js CHANGED
@@ -11,6 +11,14 @@
11
11
  */
12
12
  import { addOnBlockSchema, addOnCategorySchema, i18nMessageSchema, isSlotId, } from '@adminium/add-on-contracts';
13
13
  import { z } from 'zod';
14
+ import { bookingIssues, bookingSchema } from './booking.js';
15
+ import { pageCalendarIssues } from './page-calendar.js';
16
+ import { emailTemplateSchema, outboxIssues, outboxSchema } from './outbox.js';
17
+ import { publicAccessIssues, publicAccessSchema, publicKeysSchema } from './public-access.js';
18
+ import { roleLimitIssues, roleLimitsSchema } from './roles.js';
19
+ import { NUMERIC_TYPES, labelsSchema, numberOrSetting, refSchema, scalarSchema, tableIndex, textOrLabels, valueFits, } from './refs.js';
20
+ import { compareSemver, parseSemverRange } from './semver.js';
21
+ export { compareSemver };
14
22
  /** Integer spec version, frozen at 1 for Adminium 1.x. */
15
23
  export const MANIFEST_VERSION = 1;
16
24
  /**
@@ -134,6 +142,21 @@ export const compatibilitySchema = z
134
142
  */
135
143
  engines: z.array(z.enum(MANIFEST_ENGINES)).min(1).optional(),
136
144
  requires: z.array(capabilitySchema).optional(),
145
+ /**
146
+ * The installed versions this release can update in place, as a semver
147
+ * range (`>=0.2.0`). An install outside it is not offered the update and
148
+ * is refused one: a release whose tables changed shape says so here, and
149
+ * the operator is told to uninstall first rather than left with an update
150
+ * that fails half-way. Absent means any older version.
151
+ */
152
+ updatesFrom: z
153
+ .string()
154
+ .min(1)
155
+ .max(120)
156
+ .refine((range) => parseSemverRange(range) !== null, {
157
+ message: 'a semver range such as ">=0.2.0", "^0.2.0" or ">=0.2.0 <1.0.0"',
158
+ })
159
+ .optional(),
137
160
  })
138
161
  .strict();
139
162
  // ── requiredSchema (create-or-map) ───────────────────────────────────────────
@@ -167,6 +190,198 @@ export const COLUMN_SEMANTICS = [
167
190
  ];
168
191
  /** Structural role markers. */
169
192
  export const COLUMN_ROLES = ['pk', 'created_at', 'updated_at'];
193
+ export { labelsSchema };
194
+ /**
195
+ * The rules an app asks Adminium to keep on a column (written as column-rule
196
+ * overrides at install). The first five are the rules an operator can set in
197
+ * the column inspector; the rest are the ones Adminium DECIDES for a public
198
+ * write, so a browser never picks a price, a number or a code:
199
+ *
200
+ * - `copy`: take the value from the linked row (`via` is this table's
201
+ * foreign-key column, `from` a column of the row it points at).
202
+ * - `sequence`: the next number in this column's own counter.
203
+ * - `code`: a short random code (Crockford base 32), unique in the column.
204
+ * - `rollup`: a total over child rows, kept in step as they change.
205
+ * - `venueLocal`: a wall time with no zone is read in the venue's zone.
206
+ * - `personal`: whether the column is personal data, overriding the guess
207
+ * Adminium makes from its name.
208
+ */
209
+ export const columnRulesSchema = z
210
+ .object({
211
+ options: z
212
+ .union([
213
+ z.object({ list: z.string().min(1).max(120) }).strict(),
214
+ z
215
+ .object({
216
+ values: z
217
+ .array(z
218
+ .object({ value: z.string().min(1).max(256), label: textOrLabels.optional(), tone: z.string().max(32).optional() })
219
+ .strict())
220
+ .min(1)
221
+ .max(500),
222
+ })
223
+ .strict(),
224
+ ])
225
+ .optional(),
226
+ enumLabels: z
227
+ .object({
228
+ labels: z.record(z.string().min(1), textOrLabels),
229
+ tones: z.record(z.string().min(1), z.string().max(32)).optional(),
230
+ })
231
+ .strict()
232
+ .optional(),
233
+ required: z.literal(true).optional(),
234
+ validation: z
235
+ .object({
236
+ format: z.enum(['email', 'url', 'phone']).optional(),
237
+ min: z.number().optional(),
238
+ max: z.number().optional(),
239
+ minLength: z.number().int().nonnegative().optional(),
240
+ maxLength: z.number().int().positive().optional(),
241
+ })
242
+ .strict()
243
+ .optional(),
244
+ copy: z
245
+ .object({ via: refSchema, from: refSchema, mode: z.enum(['default', 'always']).optional() })
246
+ .strict()
247
+ .optional(),
248
+ sequence: z.object({ start: z.number().int().min(1).optional() }).strict().optional(),
249
+ code: z
250
+ .object({
251
+ prefix: z.string().regex(/^[A-Z][A-Z0-9]{0,5}-?$/, 'an upper-case prefix, e.g. MR-').optional(),
252
+ length: z.number().int().min(4).max(12),
253
+ })
254
+ .strict()
255
+ .optional(),
256
+ rollup: z
257
+ .object({
258
+ /** The child table, its foreign key back to this row, and what to add up. */
259
+ from: refSchema,
260
+ via: refSchema,
261
+ sum: refSchema,
262
+ /** Multiplied into `sum` per child row, e.g. `qty`. */
263
+ times: refSchema.optional(),
264
+ /** A child row whose column holds a value is left out — a voided line (`voided_at`). */
265
+ unlessSet: refSchema.optional(),
266
+ /** Only child rows whose column equals the value are added up (`voided = false`). */
267
+ where: z.object({ column: refSchema, eq: scalarSchema }).strict().optional(),
268
+ /**
269
+ * A second column of this row kept in step: `of − Σminus − total`
270
+ * (`balance = fee − waived − paid`). Adminium writes it; nobody else may.
271
+ */
272
+ balance: z
273
+ .object({ column: refSchema, of: refSchema, minus: z.array(refSchema).max(4).optional() })
274
+ .strict()
275
+ .optional(),
276
+ /** A child write that would take the balance below zero is refused. */
277
+ cap: z.literal(true).optional(),
278
+ })
279
+ .strict()
280
+ .optional(),
281
+ /**
282
+ * A value Adminium writes when something happens: the moment, or who did
283
+ * it, on a create or when another column changes to one of `values`
284
+ * (`checked_in_at` when `status` becomes `checked_in`). `byOrigin` writes
285
+ * one value for a public write and another for staff. A public write never
286
+ * stamps a person: a browser key is nobody.
287
+ */
288
+ stamp: z
289
+ .object({
290
+ set: z.union([
291
+ z.enum(['now', 'user-name', 'user-id']),
292
+ z.object({ byOrigin: z.object({ public: z.string().min(1), staff: z.string().min(1) }).strict() }).strict(),
293
+ ]),
294
+ on: z.union([
295
+ z.literal('create'),
296
+ z.object({ column: refSchema, values: z.array(scalarSchema).min(1).max(16) }).strict(),
297
+ ]),
298
+ })
299
+ .strict()
300
+ .optional(),
301
+ venueLocal: z.literal(true).optional(),
302
+ /**
303
+ * Whether the column holds personal data, when the app knows better than
304
+ * a guess from its name: a venue's own `phone` and `address` are a
305
+ * business's, and its guest page may show them. The operator can still
306
+ * mark it otherwise.
307
+ */
308
+ personal: z.boolean().optional(),
309
+ })
310
+ .strict();
311
+ /**
312
+ * A limit on how much of a slot rows may take — the booking guard. Only rows
313
+ * whose `countWhere` column holds one of its values count (a cancelled booking
314
+ * holds no seats).
315
+ */
316
+ export const capacitySchema = z
317
+ .object({
318
+ slot: refSchema,
319
+ amount: refSchema,
320
+ perSlot: numberOrSetting,
321
+ countWhere: z.object({ column: refSchema, values: z.array(z.string().min(1)).min(1) }).strict().optional(),
322
+ slotMinutes: numberOrSetting,
323
+ windowDays: numberOrSetting.optional(),
324
+ opens: z.union([z.string().regex(/^\d{2}:\d{2}$/), z.object({ table: refSchema, column: refSchema }).strict()]).optional(),
325
+ closes: z.union([z.string().regex(/^\d{2}:\d{2}$/), z.object({ table: refSchema, column: refSchema }).strict()]).optional(),
326
+ /** A table, a room: the limit applies per value of this column too. */
327
+ resource: refSchema.optional(),
328
+ /**
329
+ * How many hours before its time a guest may still cancel through the
330
+ * public API; later, only the venue can. Staff are never held to it.
331
+ */
332
+ cancelHours: numberOrSetting.optional(),
333
+ })
334
+ .strict();
335
+ /** The longest `maxLength` a text column may ask for (see `maxLength` below). */
336
+ export const MAX_TEXT_LENGTH = 1000;
337
+ /**
338
+ * Why a column's `default` cannot be created, or `null` when it can.
339
+ *
340
+ * The rules are the ones every one of the three databases can honour with the
341
+ * same meaning, so a manifest that validates installs the same everywhere:
342
+ *
343
+ * - `now` only on a `timestamptz`, and a timestamp takes nothing else. A
344
+ * literal timestamp default is a fixed moment, which is never what a
345
+ * manifest author means.
346
+ * - `text` needs `maxLength`: MySQL gives an unbounded TEXT column no literal
347
+ * default, and a manifest must not install on two engines and fail on the
348
+ * third.
349
+ * - `json`, `blob`, `date`, keys and foreign keys take none.
350
+ * - an enum's default is one of its values, a number is a number, a boolean a
351
+ * boolean.
352
+ */
353
+ export function defaultIssue(c) {
354
+ const value = c.default;
355
+ if (value === undefined)
356
+ return null;
357
+ if (c.role === 'pk')
358
+ return 'a primary key takes no default; an int key numbers itself';
359
+ switch (c.type) {
360
+ case 'timestamptz':
361
+ return value === 'now' ? null : 'a timestamptz default must be "now"';
362
+ case 'text':
363
+ if (typeof value !== 'string')
364
+ return 'a text default must be a string';
365
+ if (c.maxLength === undefined)
366
+ return 'a text default needs maxLength (MySQL gives TEXT no default)';
367
+ return value.length <= c.maxLength ? null : 'the default is longer than maxLength';
368
+ case 'enum':
369
+ return typeof value === 'string' && (c.enum ?? []).includes(value)
370
+ ? null
371
+ : 'an enum default must be one of its values';
372
+ case 'int':
373
+ case 'bigint':
374
+ return typeof value === 'number' && Number.isInteger(value) ? null : 'an integer default must be a whole number';
375
+ case 'decimal':
376
+ case 'money':
377
+ case 'float':
378
+ return typeof value === 'number' ? null : 'a numeric default must be a number';
379
+ case 'bool':
380
+ return typeof value === 'boolean' ? null : 'a bool default must be true or false';
381
+ default:
382
+ return `a ${c.type} column takes no default`;
383
+ }
384
+ }
170
385
  export const requiredColumnSchema = z
171
386
  .object({
172
387
  ref: z.string().regex(/^[a-z][a-z0-9_]*$/, 'column ref must be a snake_case identifier'),
@@ -177,6 +392,38 @@ export const requiredColumnSchema = z
177
392
  // enum values when `type: 'enum'`; fk target ref when `type: 'fk'`.
178
393
  enum: z.array(z.string().min(1)).optional(),
179
394
  references: z.string().optional(),
395
+ /**
396
+ * The value the DATABASE fills when an insert leaves the column out. A
397
+ * literal of the column's own type, or the string `now` on a
398
+ * `timestamptz` column. Without one, a NOT NULL column refuses every insert
399
+ * that omits it — which is every insert an operator makes from a form that
400
+ * does not show that column.
401
+ *
402
+ * Additive and optional: an older server refuses a manifest carrying it
403
+ * (every block is `.strict()`), so an app that uses it raises
404
+ * `minAdminiumVersion` to the release that reads it.
405
+ */
406
+ default: z.union([z.string(), z.number().finite(), z.boolean()]).optional(),
407
+ /**
408
+ * `text` only: a `varchar(n)` instead of unbounded `text`. Short text is
409
+ * what a form, a unique index and MySQL's key limit all want. At most 1000
410
+ * characters — MySQL counts four bytes per character against one 65,535-byte
411
+ * row, so a few long columns would refuse the table.
412
+ */
413
+ maxLength: z.number().int().min(1).max(MAX_TEXT_LENGTH).optional(),
414
+ /**
415
+ * No two rows may hold the same value (empty values excepted). A text
416
+ * column needs `maxLength`: MySQL indexes no unbounded text.
417
+ */
418
+ unique: z.literal(true).optional(),
419
+ /** Rules Adminium keeps on the column once installed (see `columnRulesSchema`). */
420
+ rules: columnRulesSchema.optional(),
421
+ /**
422
+ * What a person calls the column — a form's field, a list's heading — in
423
+ * every language the app speaks. Installed as the column's label, which
424
+ * the operator can rename like any other.
425
+ */
426
+ label: textOrLabels.optional(),
180
427
  })
181
428
  .strict()
182
429
  .refine((c) => c.type !== 'enum' || (c.enum !== undefined && c.enum.length > 0), {
@@ -186,17 +433,66 @@ export const requiredColumnSchema = z
186
433
  .refine((c) => c.type !== 'fk' || c.references !== undefined, {
187
434
  message: 'a fk column must name its references target',
188
435
  path: ['references'],
436
+ })
437
+ .refine((c) => c.maxLength === undefined || c.type === 'text', {
438
+ message: 'maxLength applies to a text column only',
439
+ path: ['maxLength'],
440
+ })
441
+ .refine((c) => c.unique === undefined || (c.role !== 'pk' && !['json', 'blob'].includes(c.type) && (c.type !== 'text' || c.maxLength !== undefined)), {
442
+ message: 'unique needs a column that can be indexed: not the key, not json or blob, text with maxLength',
443
+ path: ['unique'],
444
+ })
445
+ .superRefine((c, ctx) => {
446
+ const issue = defaultIssue(c);
447
+ if (issue !== null)
448
+ ctx.addIssue({ code: 'custom', message: issue, path: ['default'] });
189
449
  });
190
450
  export const requiredTableSchema = z
191
451
  .object({
192
452
  ref: z.string().regex(/^[a-z][a-z0-9_]*$/, 'table ref must be a snake_case identifier'),
193
453
  columns: z.array(requiredColumnSchema).min(1),
454
+ /**
455
+ * A shape other apps may share, `<name>@<version>` (`menu@1`). Two apps
456
+ * that declare the same shape can use one table between them.
457
+ */
458
+ shape: z.string().regex(/^[a-z][a-z0-9-]*@\d+$/, 'a shape is <name>@<version>').optional(),
459
+ capacity: capacitySchema.optional(),
460
+ /** Booking people: no two counted rows of one resource may overlap (see `booking.ts`). */
461
+ booking: bookingSchema.optional(),
462
+ /**
463
+ * What a person calls ONE row ("Category") and the table ("Categories"),
464
+ * in every language the app speaks: a form's title and button, a page's
465
+ * empty state, a link's field. Installed as the table's label. Without
466
+ * them Adminium names the table from its real name (`pos_menu_categories`).
467
+ */
468
+ label: textOrLabels.optional(),
469
+ labelPlural: textOrLabels.optional(),
470
+ /** The column that names a row wherever another table links to it (a category's `name`). */
471
+ keyField: z.string().regex(/^[a-z][a-z0-9_]*$/, 'a column ref').optional(),
194
472
  })
195
473
  .strict()
196
- .refine((t) => new Set(t.columns.map((c) => c.ref)).size === t.columns.length, { message: 'duplicate column ref in table', path: ['columns'] });
474
+ .refine((t) => new Set(t.columns.map((c) => c.ref)).size === t.columns.length, { message: 'duplicate column ref in table', path: ['columns'] })
475
+ .refine((t) => t.keyField === undefined || t.columns.some((c) => c.ref === t.keyField), {
476
+ message: 'keyField must name one of the table’s columns',
477
+ path: ['keyField'],
478
+ })
479
+ .refine((t) => t.labelPlural === undefined || t.label !== undefined, {
480
+ message: 'labelPlural needs a label',
481
+ path: ['labelPlural'],
482
+ })
483
+ .refine((t) => t.capacity === undefined || t.booking === undefined, {
484
+ message: 'a table has a capacity or a booking rule, not both',
485
+ path: ['booking'],
486
+ });
197
487
  export const requiredSchemaSchema = z
198
488
  .object({
199
489
  tables: z.array(requiredTableSchema).min(1),
490
+ /**
491
+ * Adminium names every table `<app key>_<ref>` (`pos_tickets`), and tells
492
+ * the app the real names at boot. Opt-in, because an app built before it
493
+ * hard-codes its table names. Apps only: an add-on uses its host's tables.
494
+ */
495
+ prefixed: z.literal(true).optional(),
200
496
  })
201
497
  .strict()
202
498
  .refine((s) => new Set(s.tables.map((t) => t.ref)).size === s.tables.length, {
@@ -220,6 +516,14 @@ export const pageSchema = z
220
516
  // page-local table ref → requiredSchema table ref (resolved at install).
221
517
  bindings: z.record(z.string(), z.string()).optional(),
222
518
  config: z.record(z.string(), z.unknown()).optional(),
519
+ /**
520
+ * The page's title in other languages, keyed by BCP 47 tag (`de-DE`)
521
+ *. `title.fallback` stays the English; the sidebar shows the
522
+ * operator's language until the operator renames the page.
523
+ */
524
+ titles: z
525
+ .record(z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, 'titles are keyed by BCP 47 tag'), z.string().min(1).max(120))
526
+ .optional(),
223
527
  })
224
528
  .strict();
225
529
  // ── roles ────────────────────────────────────────────────────────────────────
@@ -230,6 +534,10 @@ export const roleSchema = z
230
534
  cloneFrom: z.string().optional(),
231
535
  // grant strings validated against the RBAC grammar at install time.
232
536
  permissions: z.array(z.string().min(1)).optional(),
537
+ /** Opens the app's own screens and never the dashboard (a till cashier). */
538
+ screensOnly: z.boolean().optional(),
539
+ /** Per table, what the role's update there may write (roles.ts). */
540
+ limits: roleLimitsSchema.optional(),
233
541
  })
234
542
  .strict();
235
543
  // ── settings ─────────────────────────────────────────────────────────────────
@@ -334,6 +642,46 @@ export const frontendSchema = z
334
642
  * permanently uncheckable.
335
643
  */
336
644
  routes: z.record(z.string(), z.string()).optional(),
645
+ /**
646
+ * Where a STAFF side opens by default: inside the dashboard, or on its own
647
+ * address (a till). The operator can change it; absent means inside.
648
+ */
649
+ placement: z.enum(['internal', 'external']).optional(),
650
+ /** Whether the side starts switched on. Absent means on. */
651
+ enabled: z.boolean().optional(),
652
+ })
653
+ .strict();
654
+ // ── the app's sidebar, public access and sample data ─────────────────────────
655
+ /** A heading in the app's own sidebar section; pages name it in `nav.group`. */
656
+ /**
657
+ * A list of answers the app ships, for a column's `options: {list}`. Installed
658
+ * as the option list `<appKey>-<name>`, which the operator can then edit like
659
+ * any other; a list of that key they already made is left alone.
660
+ */
661
+ export const optionListSchema = z
662
+ .object({
663
+ label: textOrLabels,
664
+ values: z
665
+ .array(z
666
+ .object({ value: z.string().min(1).max(256), label: textOrLabels.optional(), tone: z.string().max(32).optional() })
667
+ .strict())
668
+ .min(1)
669
+ .max(500),
670
+ })
671
+ .strict();
672
+ export const navGroupSchema = z
673
+ .object({
674
+ key: z.string().regex(/^[a-z][a-z0-9-]*$/, 'a nav group key is kebab-case'),
675
+ label: labelsSchema,
676
+ order: z.number().int(),
677
+ })
678
+ .strict();
679
+ /** What the app's public screens may do (see `public-access.ts`). */
680
+ export { publicAccessSchema, publicKeysSchema };
681
+ /** The bundled sample data file, inside the package's `seeds/` folder. */
682
+ export const sampleDataSchema = z
683
+ .object({
684
+ file: z.string().regex(/^seeds\/[a-z0-9][a-z0-9._-]*\.json$/, 'a file in seeds/, ending .json'),
337
685
  })
338
686
  .strict();
339
687
  /** Hosted-only and offline-required are mutually exclusive. */
@@ -371,6 +719,190 @@ const SIDES_MESSAGE = {
371
719
  message: 'each side may appear at most once in `frontends`',
372
720
  path: ['frontends'],
373
721
  };
722
+ /** The prefix Adminium gives an app's tables: its key, `-` as `_`, then `_`. */
723
+ export function prefixFor(appKey) {
724
+ return `${appKey.replace(/-/g, '_')}_`;
725
+ }
726
+ /** The longest real table name every engine accepts (Postgres: 63 bytes). */
727
+ export const MAX_TABLE_NAME = 63;
728
+ /**
729
+ * Every name a manifest's own blocks use must name something the manifest
730
+ * declares: a rule's columns, a capacity's or a booking's columns and tables,
731
+ * a public table and the columns it reads and writes, the outbox and its
732
+ * templates. Checked here so an app's CI refuses a typo long before an
733
+ * operator's install would.
734
+ */
735
+ export function appReferenceIssues(m) {
736
+ const out = [];
737
+ const index = tableIndex(m.requiredSchema.tables);
738
+ const tables = new Map(m.requiredSchema.tables.map((t) => [t.ref, t]));
739
+ const has = index.has;
740
+ /** Per table, the columns Adminium decides and no writer may set. */
741
+ const decided = new Map();
742
+ const decide = (table, column) => {
743
+ const set = decided.get(table) ?? new Set();
744
+ set.add(column);
745
+ decided.set(table, set);
746
+ };
747
+ m.requiredSchema.tables.forEach((table, t) => {
748
+ const at = (...rest) => ['requiredSchema', 'tables', t, ...rest];
749
+ if (m.requiredSchema.prefixed === true && prefixFor(m.key).length + table.ref.length > MAX_TABLE_NAME) {
750
+ out.push({ path: at('ref'), message: `"${prefixFor(m.key)}${table.ref}" is longer than ${String(MAX_TABLE_NAME)} characters` });
751
+ }
752
+ const balances = new Map();
753
+ table.columns.forEach((column) => {
754
+ const balance = column.rules?.rollup?.balance;
755
+ if (balance !== undefined)
756
+ balances.set(balance.column, column.ref);
757
+ });
758
+ table.columns.forEach((column, c) => {
759
+ const rules = column.rules;
760
+ if (rules === undefined)
761
+ return;
762
+ const here = (...rest) => at('columns', c, 'rules', ...rest);
763
+ if (rules.copy !== undefined || rules.sequence !== undefined || rules.code !== undefined || rules.rollup !== undefined || rules.stamp !== undefined) {
764
+ decide(table.ref, column.ref);
765
+ }
766
+ // A list the app ships, or one Adminium has built in; nothing else exists
767
+ // on every install.
768
+ if (rules.options !== undefined && 'list' in rules.options) {
769
+ const list = rules.options.list;
770
+ if (!list.startsWith('builtin:') && m.optionLists?.[list] === undefined) {
771
+ out.push({ path: here('options', 'list'), message: `"${list}" is not one of the app's option lists` });
772
+ }
773
+ }
774
+ if (rules.copy !== undefined) {
775
+ const via = table.columns.find((x) => x.ref === rules.copy.via);
776
+ if (via?.type !== 'fk' || via.references === undefined) {
777
+ out.push({ path: here('copy', 'via'), message: `"${rules.copy.via}" is not a foreign key of "${table.ref}"` });
778
+ }
779
+ else if (!has(via.references, rules.copy.from)) {
780
+ out.push({ path: here('copy', 'from'), message: `"${via.references}" has no column "${rules.copy.from}"` });
781
+ }
782
+ }
783
+ if (rules.rollup !== undefined) {
784
+ const r = rules.rollup;
785
+ const child = tables.get(r.from);
786
+ const via = child?.columns.find((x) => x.ref === r.via);
787
+ if (child === undefined) {
788
+ out.push({ path: here('rollup', 'from'), message: `"${r.from}" is not a table of this app` });
789
+ }
790
+ else if (via?.type !== 'fk' || via.references !== table.ref) {
791
+ out.push({ path: here('rollup', 'via'), message: `"${r.from}.${r.via}" does not point at "${table.ref}"` });
792
+ }
793
+ else {
794
+ for (const name of [r.sum, ...(r.times === undefined ? [] : [r.times]), ...(r.unlessSet === undefined ? [] : [r.unlessSet])]) {
795
+ if (!has(r.from, name))
796
+ out.push({ path: here('rollup'), message: `"${r.from}" has no column "${name}"` });
797
+ }
798
+ if (r.where !== undefined) {
799
+ const filter = index.column(r.from, r.where.column);
800
+ if (filter === undefined)
801
+ out.push({ path: here('rollup', 'where'), message: `"${r.from}" has no column "${r.where.column}"` });
802
+ else if (!valueFits(filter, r.where.eq))
803
+ out.push({ path: here('rollup', 'where'), message: `${JSON.stringify(r.where.eq)} is not a value of "${r.from}.${r.where.column}"` });
804
+ // An empty value equals nothing: a row left empty would drop out of the total unseen.
805
+ else if (filter.nullable === true)
806
+ out.push({ path: here('rollup', 'where'), message: `"${r.from}.${r.where.column}" may be empty, so a row could drop out of the total; make it not nullable` });
807
+ }
808
+ }
809
+ if (r.balance !== undefined) {
810
+ const b = r.balance;
811
+ decide(table.ref, b.column);
812
+ for (const [name, ref] of [['column', b.column], ['of', b.of], ...(b.minus ?? []).map((x) => ['minus', x])]) {
813
+ const found = index.column(table.ref, ref);
814
+ if (found === undefined)
815
+ out.push({ path: here('rollup', 'balance', name), message: `"${table.ref}" has no column "${ref}"` });
816
+ else if (!NUMERIC_TYPES.includes(found.type))
817
+ out.push({ path: here('rollup', 'balance', name), message: `"${table.ref}.${ref}" is not a number` });
818
+ }
819
+ if ([b.of, ...(b.minus ?? [])].includes(b.column) || b.column === column.ref) {
820
+ out.push({ path: here('rollup', 'balance', 'column'), message: 'the balance is a column of its own' });
821
+ }
822
+ if (index.column(table.ref, b.column)?.rules !== undefined) {
823
+ out.push({ path: here('rollup', 'balance', 'column'), message: `"${b.column}" is the balance Adminium keeps; it takes no rules of its own` });
824
+ }
825
+ }
826
+ // A cap holds a balance at zero: this rollup's, or the one that takes
827
+ // this total away (a write-off is capped by the balance it lowers).
828
+ if (r.cap === true && r.balance === undefined) {
829
+ const capped = table.columns.some((x) => x.rules?.rollup?.balance?.minus?.includes(column.ref) === true);
830
+ if (!capped)
831
+ out.push({ path: here('rollup', 'cap'), message: 'a cap needs a balance: declare one here, or subtract this total in one' });
832
+ }
833
+ }
834
+ if (rules.stamp !== undefined) {
835
+ const stamp = rules.stamp;
836
+ const set = stamp.set;
837
+ if (set === 'now' && column.type !== 'timestamptz') {
838
+ out.push({ path: here('stamp', 'set'), message: 'a "now" stamp needs a timestamptz column' });
839
+ }
840
+ else if ((set === 'user-name' || set === 'user-id') && column.type !== 'text') {
841
+ out.push({ path: here('stamp', 'set'), message: `a "${set}" stamp needs a text column` });
842
+ }
843
+ else if (typeof set === 'object') {
844
+ for (const value of [set.byOrigin.public, set.byOrigin.staff]) {
845
+ if (!valueFits(column, value))
846
+ out.push({ path: here('stamp', 'set'), message: `"${value}" is not a value of "${table.ref}.${column.ref}"` });
847
+ }
848
+ }
849
+ if (stamp.on !== 'create') {
850
+ const watched = index.column(table.ref, stamp.on.column);
851
+ if (watched === undefined) {
852
+ out.push({ path: here('stamp', 'on', 'column'), message: `"${table.ref}" has no column "${stamp.on.column}"` });
853
+ }
854
+ else if (watched.ref === column.ref) {
855
+ out.push({ path: here('stamp', 'on', 'column'), message: 'a stamp watches another column' });
856
+ }
857
+ else {
858
+ for (const value of stamp.on.values) {
859
+ if (!valueFits(watched, value))
860
+ out.push({ path: here('stamp', 'on', 'values'), message: `${JSON.stringify(value)} is not a value of "${table.ref}.${watched.ref}"` });
861
+ }
862
+ }
863
+ }
864
+ const others = ['copy', 'sequence', 'code', 'rollup'].filter((name) => rules[name] !== undefined);
865
+ if (others.length > 0)
866
+ out.push({ path: here('stamp'), message: `a stamped column is not also decided by ${others.join(', ')}` });
867
+ }
868
+ if ((rules.sequence !== undefined || rules.code !== undefined) && column.role === 'pk') {
869
+ out.push({ path: here(), message: 'a primary key numbers itself; it takes no sequence or code rule' });
870
+ }
871
+ });
872
+ const cap = table.capacity;
873
+ if (cap !== undefined) {
874
+ for (const [name, value] of [['slot', cap.slot], ['amount', cap.amount], ['resource', cap.resource], ['countWhere', cap.countWhere?.column]]) {
875
+ if (value !== undefined && !has(table.ref, value)) {
876
+ out.push({ path: at('capacity', name), message: `"${table.ref}" has no column "${value}"` });
877
+ }
878
+ }
879
+ for (const [name, value] of Object.entries(cap)) {
880
+ if (typeof value === 'object' && value !== null && 'table' in value && 'column' in value) {
881
+ const setting = value;
882
+ if (!has(setting.table, setting.column)) {
883
+ out.push({ path: at('capacity', name), message: `"${setting.table}" has no column "${setting.column}"` });
884
+ }
885
+ }
886
+ }
887
+ }
888
+ if (table.booking !== undefined) {
889
+ out.push(...bookingIssues(table, table.booking, index, at));
890
+ // The late flag is Adminium's to set.
891
+ if (table.booking.cancel?.flag !== undefined)
892
+ decide(table.ref, table.booking.cancel.flag);
893
+ }
894
+ });
895
+ out.push(...publicAccessIssues(m.publicAccess ?? [], {
896
+ index,
897
+ decided: (table) => decided.get(table) ?? new Set(),
898
+ answersAvailability: (table) => tables.get(table)?.capacity !== undefined || tables.get(table)?.booking !== undefined,
899
+ publicKeys: m.publicKeys,
900
+ roles: m.roles ?? [],
901
+ }));
902
+ out.push(...outboxIssues(m, index));
903
+ out.push(...roleLimitIssues(m.roles ?? [], index));
904
+ return out;
905
+ }
374
906
  export const appManifestSchema = z
375
907
  .object({
376
908
  kind: z.literal('app'),
@@ -394,11 +926,31 @@ export const appManifestSchema = z
394
926
  * required-singular shape however their `requiredSchema` was repaired.
395
927
  */
396
928
  frontends: z.array(frontendSchema).min(1),
929
+ navGroups: z.array(navGroupSchema).max(12).optional(),
930
+ /** Keyed by a kebab-case name; a column names one with `options: {list: name}`. */
931
+ optionLists: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/, 'a list name is kebab-case'), optionListSchema).optional(),
932
+ publicAccess: z.array(publicAccessSchema).max(32).optional(),
933
+ /** Browser keys besides the app's own `customer` key (see `public-access.ts`). */
934
+ publicKeys: publicKeysSchema.optional(),
935
+ /** The app's emails: its outbox table and what queues rows in it (see `outbox.ts`). */
936
+ outbox: outboxSchema.optional(),
937
+ /** The templates the outbox sends, in each language the app ships. */
938
+ emailTemplates: z.array(emailTemplateSchema).max(16).optional(),
939
+ sampleData: sampleDataSchema.optional(),
397
940
  })
398
941
  .strict()
399
942
  .refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
400
943
  .refine(compatibilityWindowOrdered, { ...WINDOW_MESSAGE, path: [...WINDOW_MESSAGE.path] })
401
- .refine(sidesAreDistinct, { ...SIDES_MESSAGE, path: [...SIDES_MESSAGE.path] });
944
+ .refine(sidesAreDistinct, { ...SIDES_MESSAGE, path: [...SIDES_MESSAGE.path] })
945
+ .superRefine((m, ctx) => {
946
+ for (const issue of appReferenceIssues(m)) {
947
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
948
+ }
949
+ // A calendar page's named columns, against the app's own tables.
950
+ for (const issue of pageCalendarIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
951
+ ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
952
+ }
953
+ });
402
954
  /**
403
955
  * `pages`, `roles` and `frontends` are absent from this branch on purpose, and
404
956
  * leaving the fields off a `.strict()` schema entirely is a stronger guarantee
@@ -431,7 +983,11 @@ export const addOnManifestSchema = z
431
983
  })
432
984
  .strict()
433
985
  .refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
434
- .refine(compatibilityWindowOrdered, { ...WINDOW_MESSAGE, path: [...WINDOW_MESSAGE.path] });
986
+ .refine(compatibilityWindowOrdered, { ...WINDOW_MESSAGE, path: [...WINDOW_MESSAGE.path] })
987
+ .refine((m) => m.requiredSchema?.prefixed !== true, {
988
+ message: 'an add-on uses its host app\'s tables, so its own cannot be prefixed',
989
+ path: ['requiredSchema', 'prefixed'],
990
+ });
435
991
  /**
436
992
  * The envelope. A document with no `kind` is treated as an app before the union
437
993
  * discriminates, which is what keeps every pre-wave-4 manifest valid.
@@ -544,19 +1100,4 @@ export function addOnIssues(m, ctx = {}) {
544
1100
  });
545
1101
  return out;
546
1102
  }
547
- /**
548
- * Numeric semver compare on the release triple (pre-release/build ignored —
549
- * enough for the compatibility-window and upgrade ordering checks). Returns
550
- * <0, 0, >0. Exported for the installer's upgrade rule.
551
- */
552
- export function compareSemver(a, b) {
553
- const triple = (v) => v
554
- .split('+')[0]
555
- .split('-')[0]
556
- .split('.')
557
- .map((n) => Number.parseInt(n, 10));
558
- const [a1 = 0, a2 = 0, a3 = 0] = triple(a);
559
- const [b1 = 0, b2 = 0, b3 = 0] = triple(b);
560
- return a1 - b1 || a2 - b2 || a3 - b3;
561
- }
562
1103
  //# sourceMappingURL=schema.js.map