@adminiumjs/manifest 0.3.5 → 0.3.7

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 (66) hide show
  1. package/dist/capacity.d.ts +786 -0
  2. package/dist/capacity.d.ts.map +1 -0
  3. package/dist/capacity.js +717 -0
  4. package/dist/capacity.js.map +1 -0
  5. package/dist/documents.d.ts +91 -5
  6. package/dist/documents.d.ts.map +1 -1
  7. package/dist/documents.js +129 -32
  8. package/dist/documents.js.map +1 -1
  9. package/dist/formula.d.ts +114 -2
  10. package/dist/formula.d.ts.map +1 -1
  11. package/dist/formula.js +291 -5
  12. package/dist/formula.js.map +1 -1
  13. package/dist/index.d.ts +12 -6
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +12 -6
  16. package/dist/index.js.map +1 -1
  17. package/dist/marketplace-wire.d.ts +230 -0
  18. package/dist/marketplace-wire.d.ts.map +1 -0
  19. package/dist/marketplace-wire.js +138 -0
  20. package/dist/marketplace-wire.js.map +1 -0
  21. package/dist/outbox.d.ts +242 -27
  22. package/dist/outbox.d.ts.map +1 -1
  23. package/dist/outbox.js +553 -27
  24. package/dist/outbox.js.map +1 -1
  25. package/dist/plan-context.d.ts +28 -3
  26. package/dist/plan-context.d.ts.map +1 -1
  27. package/dist/plan-context.js +291 -14
  28. package/dist/plan-context.js.map +1 -1
  29. package/dist/plan-model.d.ts +58 -3
  30. package/dist/plan-model.d.ts.map +1 -1
  31. package/dist/public-access.d.ts +637 -0
  32. package/dist/public-access.d.ts.map +1 -1
  33. package/dist/public-access.js +1593 -12
  34. package/dist/public-access.js.map +1 -1
  35. package/dist/refs.d.ts +86 -0
  36. package/dist/refs.d.ts.map +1 -1
  37. package/dist/refs.js +186 -0
  38. package/dist/refs.js.map +1 -1
  39. package/dist/roles.d.ts +10 -2
  40. package/dist/roles.d.ts.map +1 -1
  41. package/dist/roles.js +27 -5
  42. package/dist/roles.js.map +1 -1
  43. package/dist/sample.d.ts +218 -2
  44. package/dist/sample.d.ts.map +1 -1
  45. package/dist/sample.js +145 -7
  46. package/dist/sample.js.map +1 -1
  47. package/dist/schema.d.ts +2860 -360
  48. package/dist/schema.d.ts.map +1 -1
  49. package/dist/schema.js +1002 -54
  50. package/dist/schema.js.map +1 -1
  51. package/dist/shapes.d.ts +5 -1
  52. package/dist/shapes.d.ts.map +1 -1
  53. package/dist/shapes.js +13 -3
  54. package/dist/shapes.js.map +1 -1
  55. package/dist/states.d.ts +342 -2
  56. package/dist/states.d.ts.map +1 -1
  57. package/dist/states.js +566 -7
  58. package/dist/states.js.map +1 -1
  59. package/dist/table-shapes.d.ts +47 -0
  60. package/dist/table-shapes.d.ts.map +1 -0
  61. package/dist/table-shapes.js +159 -0
  62. package/dist/table-shapes.js.map +1 -0
  63. package/dist/validate.d.ts.map +1 -1
  64. package/dist/validate.js +80 -2
  65. package/dist/validate.js.map +1 -1
  66. package/package.json +2 -2
package/dist/outbox.js CHANGED
@@ -26,6 +26,8 @@
26
26
  */
27
27
  import { z } from 'zod';
28
28
  import { formulaColumns } from './formula.js';
29
+ import { personalColumn, unlistedColumn } from './public-access.js';
30
+ import { reachedOnlyByUndo } from './states.js';
29
31
  import { bcp47TagSchema, refSchema, scalarSchema, settingRefSchema, settingSourceSchema, textOrLabels, valueFits, } from './refs.js';
30
32
  /** The statuses an outbox table's status column must offer. */
31
33
  export const OUTBOX_STATUSES = ['queued', 'sent', 'failed', 'skipped'];
@@ -83,7 +85,16 @@ const producerBase = {
83
85
  * one, while that bool of the settings row is false (each of a studio's
84
86
  * notices has its own switch).
85
87
  */
86
- gate: z.union([z.literal('enabled'), z.object({ setting: settingRefSchema }).strict()]).optional(),
88
+ gate: z
89
+ .union([
90
+ z.literal('enabled'),
91
+ z.object({ setting: settingRefSchema }).strict(),
92
+ /** Only while a feature of the app is on (its add-ons attached): a receipt, with Invoices & Receipts. */
93
+ z.object({ feature: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a feature id') }).strict(),
94
+ /** Both at once: only while the feature is on AND that bool of the settings row is true (a receipt, when the manager wants one sent). */
95
+ z.object({ feature: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a feature id'), setting: settingRefSchema }).strict(),
96
+ ])
97
+ .optional(),
87
98
  /** Skipped for a recipient whose `recipient.optIn` column is false. */
88
99
  optIn: z.literal(true).optional(),
89
100
  /** Written `held`: sent only once someone approves it. */
@@ -101,10 +112,47 @@ const producerBase = {
101
112
  supersede: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a group name').optional(),
102
113
  /** While the source row meets one of these, its waiting messages are skipped, with the reason. */
103
114
  dropWhen: z.array(dropConditionSchema).min(1).max(4).optional(),
104
- /** Sent to an address a setting holds (the studio's own) instead of the outbox's recipient. */
105
- recipient: z.object({ setting: settingSourceSchema }).strict().optional(),
115
+ /**
116
+ * Sent instead of the outbox's recipient: to an address a setting holds
117
+ * (the studio's own), or to an address a text column of the producing row
118
+ * holds (a ticket's `send_to_email`, the friend it is offered to), with
119
+ * that row's `name` column as the name.
120
+ */
121
+ recipient: z
122
+ .union([
123
+ z.object({ setting: settingSourceSchema }).strict(),
124
+ /** `language`: a text column of the same row, the language the message is written in. */
125
+ z.object({ column: refSchema, name: refSchema.optional(), language: refSchema.optional() }).strict(),
126
+ ])
127
+ .optional(),
106
128
  /** One message per linked row in each window of this many minutes. */
107
129
  batchMinutes: z.number().int().min(1).max(240).optional(),
130
+ /**
131
+ * The message waits this many seconds before it may go, so a move taken
132
+ * back at once (an order marked ready by mistake) drops it by `dropWhen`
133
+ * before anyone is told. A message dropped so does not stop the next one.
134
+ */
135
+ holdSeconds: z.number().int().min(1).max(3600).optional(),
136
+ /**
137
+ * A column of the row the message is about: one message for each value it
138
+ * holds, not one for the row for ever — a ticket offered again, its link
139
+ * made afresh, is emailed again. Kept as a digest in the outbox's
140
+ * `repeatKey` column.
141
+ */
142
+ repeatBy: refSchema.optional(),
143
+ /**
144
+ * One message for each change it hears of, not one for the row for ever:
145
+ * "Resend tickets" twice is two messages, a stay's dates moved twice is two
146
+ * notices. On a producer that listens for changes (`onChange`).
147
+ */
148
+ repeat: z.literal(true).optional(),
149
+ /**
150
+ * The columns of the changed row as they were before the change (a stay's
151
+ * old dates, its old total), kept on the message in the outbox's `was`
152
+ * column and read by its template as `{{was.<column>}}`, in every form the
153
+ * column has. On a producer that listens for changes (`onChange`).
154
+ */
155
+ was: z.array(refSchema).min(1).max(8).optional(),
108
156
  /**
109
157
  * A change made once the message has gone, through the ordinary write: to
110
158
  * the source row, or (with `via`) the row its foreign key points at.
@@ -129,15 +177,27 @@ export const outboxProducerSchema = z.union([
129
177
  z
130
178
  .object({
131
179
  ...producerBase,
132
- onChange: z
133
- .object({
134
- table: refSchema,
135
- via: refSchema.optional(),
136
- column: refSchema,
137
- to: z.union([scalarSchema, z.array(scalarSchema).min(1).max(16)]),
138
- where: conditionSchema.optional(),
139
- })
140
- .strict(),
180
+ onChange: z.union([
181
+ z
182
+ .object({
183
+ table: refSchema,
184
+ via: refSchema.optional(),
185
+ column: refSchema,
186
+ to: z.union([scalarSchema, z.array(scalarSchema).min(1).max(16)]),
187
+ where: conditionSchema.optional(),
188
+ })
189
+ .strict(),
190
+ /** Any change of one of these columns, compared with the row as it was stored (a stay's dates, whatever they became). */
191
+ z
192
+ .object({
193
+ table: refSchema,
194
+ via: refSchema.optional(),
195
+ columns: z.array(refSchema).min(1).max(8),
196
+ changed: z.literal(true),
197
+ where: conditionSchema.optional(),
198
+ })
199
+ .strict(),
200
+ ]),
141
201
  })
142
202
  .strict(),
143
203
  z
@@ -157,6 +217,12 @@ export const outboxProducerSchema = z.union([
157
217
  fallback: settingRefSchema.optional(),
158
218
  /** The largest lead anyone may choose, in hours — how far ahead Adminium looks. */
159
219
  max: z.number().int().min(1).max(24 * 14),
220
+ /**
221
+ * Sent at this wall time on the venue's day the lead reaches,
222
+ * not at the hour itself: 24 hours before an 20:00 show, at
223
+ * 09:00, is 09:00 the day before.
224
+ */
225
+ at: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'a time such as 09:00').optional(),
160
226
  })
161
227
  .strict(),
162
228
  where: conditionSchema.optional(),
@@ -172,7 +238,12 @@ const recipientSchema = z
172
238
  table: refSchema,
173
239
  email: refSchema,
174
240
  name: refSchema.optional(),
175
- language: refSchema.optional(),
241
+ /**
242
+ * The language the message is written in: a text column of the person's
243
+ * row, or (`{column}`) of the row the message is about — an order placed
244
+ * in German is written to in German, whatever the person's own row says.
245
+ */
246
+ language: z.union([refSchema, z.object({ column: refSchema }).strict()]).optional(),
176
247
  /** A bool the person sets: false means no reminders. */
177
248
  optIn: refSchema.optional(),
178
249
  /**
@@ -207,6 +278,10 @@ export const outboxSchema = z
207
278
  /** When `onSent`'s change was made, or why it was refused. */
208
279
  effectAt: refSchema.optional(),
209
280
  effectError: refSchema.optional(),
281
+ /** A text column (at least 43 characters) Adminium fills with the digest of a producer's `repeatBy` value. */
282
+ repeatKey: refSchema.optional(),
283
+ /** A text column (unbounded, or at least 1000 characters) Adminium fills with a producer's `was` columns, as they were. */
284
+ was: refSchema.optional(),
210
285
  })
211
286
  .strict(),
212
287
  /**
@@ -222,7 +297,14 @@ export const outboxSchema = z
222
297
  * the one sent to an old address after a change of email.
223
298
  */
224
299
  settings: z
225
- .object({ table: refSchema, enabled: refSchema.optional(), name: refSchema.optional(), phone: refSchema.optional() })
300
+ .object({
301
+ table: refSchema,
302
+ enabled: refSchema.optional(),
303
+ name: refSchema.optional(),
304
+ phone: refSchema.optional(),
305
+ /** The address a reply to any of the app's messages goes to (the house's own): a Reply-To on each. Empty or not an address: none. */
306
+ replyTo: refSchema.optional(),
307
+ })
226
308
  .strict()
227
309
  .optional(),
228
310
  /**
@@ -286,7 +368,20 @@ export const emailTemplateSchema = z
286
368
  * else the print copy. A message whose document cannot be drawn fails
287
369
  * rather than going without it.
288
370
  */
289
- attach: z.object({ kind: z.string().regex(/^[a-z][a-z0-9-]*$/, 'a document kind'), link: z.string().min(1).max(40) }).strict().optional(),
371
+ attach: z
372
+ .object({
373
+ kind: z.string().regex(/^[a-z][a-z0-9-]*$/, 'a document kind'),
374
+ link: z.string().min(1).max(40),
375
+ /**
376
+ * Sent without the document when no add-on the app has draws its
377
+ * kind (a receipt, without Invoices & Receipts): the part is left out,
378
+ * and so is every block marked `data.withAttachment: true`. Without
379
+ * it, such a message fails rather than go without its document.
380
+ */
381
+ optional: z.literal(true).optional(),
382
+ })
383
+ .strict()
384
+ .optional(),
290
385
  /** The variables it reads, for the editor's list. */
291
386
  vars: z.array(templateVariableSchema).max(60).optional(),
292
387
  /** The template in each language it ships, US English always among them. */
@@ -296,9 +391,15 @@ export const emailTemplateSchema = z
296
391
  })
297
392
  .strict();
298
393
  /** The column rules that decide a value on the server (see `columnRulesSchema`). */
299
- const DECIDING_RULES = ['copy', 'default', 'sequence', 'format', 'code', 'rollup', 'formula', 'stamp'];
394
+ const DECIDING_RULES = ['copy', 'default', 'sequence', 'format', 'code', 'rollup', 'formula', 'stamp', 'perNight'];
300
395
  /** The column rules that refuse a value: Adminium's own writes to the column would be refused by them. */
301
396
  const REFUSING_RULES = ['options', 'validation', 'required', 'requiredWhen', 'notAfter', 'notBefore'];
397
+ /** How long a `repeatKey` is: a SHA-256 digest in base64url. */
398
+ export const REPEAT_KEY_LENGTH = 43;
399
+ /** The fewest characters a bounded `was` column holds: the columns a message keeps, as they were. */
400
+ export const WAS_MIN_LENGTH = 1000;
401
+ /** The mark on a template's block that is sent only with the document the template carries (`attach.optional`). */
402
+ export const WITH_ATTACHMENT = 'withAttachment';
302
403
  /** What the outbox writes, and which rules each refuses: everything, but a check of the address a person types. */
303
404
  export const OUTBOX_WRITTEN = {
304
405
  status: [...DECIDING_RULES, ...REFUSING_RULES],
@@ -308,6 +409,8 @@ export const OUTBOX_WRITTEN = {
308
409
  approvedBy: [...DECIDING_RULES, ...REFUSING_RULES],
309
410
  effectAt: [...DECIDING_RULES, ...REFUSING_RULES],
310
411
  effectError: [...DECIDING_RULES, ...REFUSING_RULES],
412
+ repeatKey: [...DECIDING_RULES, ...REFUSING_RULES],
413
+ was: [...DECIDING_RULES, ...REFUSING_RULES],
311
414
  // Left empty by a desk that asks Adminium to look the address up, and written when it sends.
312
415
  to: [...DECIDING_RULES, 'options', 'required', 'requiredWhen'],
313
416
  language: [...DECIDING_RULES, 'options', 'required', 'requiredWhen'],
@@ -346,6 +449,19 @@ export function outboxIssues(m, index) {
346
449
  if (template.attach !== undefined && m.outbox?.links?.[template.attach.link] === undefined) {
347
450
  out.push({ path: ['emailTemplates', i, 'attach', 'link'], message: `"${template.attach.link}" is not one of the outbox's links` });
348
451
  }
452
+ // A block sent only with the document: marked `true`, and only where the document may be left out.
453
+ for (const [locale, content] of Object.entries(template.locales)) {
454
+ content.blocks.forEach((block, b) => {
455
+ const mark = block.data?.[WITH_ATTACHMENT];
456
+ if (mark === undefined)
457
+ return;
458
+ const path = ['emailTemplates', i, 'locales', locale, 'blocks', b, 'data', WITH_ATTACHMENT];
459
+ if (mark !== true)
460
+ out.push({ path, message: `${WITH_ATTACHMENT} is true or absent` });
461
+ else if (template.attach?.optional !== true)
462
+ out.push({ path, message: 'a block is sent only with the document when the template may go without it (attach.optional)' });
463
+ });
464
+ }
349
465
  if (!template.key.startsWith(`${m.key}-`)) {
350
466
  out.push({ path: ['emailTemplates', i, 'key'], message: `an app's template key starts with "${m.key}-"` });
351
467
  }
@@ -425,6 +541,18 @@ export function outboxIssues(m, index) {
425
541
  }
426
542
  if (box.columns.effectAt !== undefined)
427
543
  col(box.table, box.columns.effectAt, ['timestamptz'], at('columns', 'effectAt'), 'a timestamptz');
544
+ if (box.columns.repeatKey !== undefined) {
545
+ const found = col(box.table, box.columns.repeatKey, ['text'], at('columns', 'repeatKey'), 'a text column');
546
+ if (found !== undefined && found.maxLength !== undefined && found.maxLength < REPEAT_KEY_LENGTH) {
547
+ out.push({ path: at('columns', 'repeatKey'), message: `"${box.table}.${found.ref}" must hold ${REPEAT_KEY_LENGTH} characters (a digest)` });
548
+ }
549
+ }
550
+ if (box.columns.was !== undefined) {
551
+ const found = col(box.table, box.columns.was, ['text'], at('columns', 'was'), 'a text column');
552
+ if (found !== undefined && found.maxLength !== undefined && found.maxLength < WAS_MIN_LENGTH) {
553
+ out.push({ path: at('columns', 'was'), message: `"${box.table}.${found.ref}" keeps a message's values from before a change: it holds ${WAS_MIN_LENGTH} characters or more` });
554
+ }
555
+ }
428
556
  /*
429
557
  * The columns Adminium writes as it sends and as a person approves or skips
430
558
  * a message — and the address and language it writes when it looks them up.
@@ -496,8 +624,11 @@ export function outboxIssues(m, index) {
496
624
  col(r.table, r.email, ['text'], at('recipient', 'email'), 'a text column');
497
625
  if (r.name !== undefined)
498
626
  col(r.table, r.name, ['text'], at('recipient', 'name'), 'a text column');
499
- if (r.language !== undefined)
627
+ if (typeof r.language === 'string')
500
628
  col(r.table, r.language, ['text'], at('recipient', 'language'), 'a text column');
629
+ else if (r.language !== undefined && box.columns.language === undefined) {
630
+ out.push({ path: at('recipient', 'language'), message: 'a message\'s language read from the row it is about is kept in the outbox\'s language column: name it' });
631
+ }
501
632
  if (r.optIn !== undefined)
502
633
  col(r.table, r.optIn, ['bool'], at('recipient', 'optIn'), 'a bool');
503
634
  if (r.fallback !== undefined) {
@@ -521,6 +652,8 @@ export function outboxIssues(m, index) {
521
652
  col(box.settings.table, box.settings.name, ['text'], at('settings', 'name'), 'a text column');
522
653
  if (box.settings.phone !== undefined)
523
654
  col(box.settings.table, box.settings.phone, ['text'], at('settings', 'phone'), 'a text column');
655
+ if (box.settings.replyTo !== undefined)
656
+ col(box.settings.table, box.settings.replyTo, ['text'], at('settings', 'replyTo'), 'a text column (an address)');
524
657
  }
525
658
  for (const [value, template] of Object.entries(box.kinds)) {
526
659
  if (kind !== undefined && !(kind.enum ?? []).includes(value)) {
@@ -529,6 +662,7 @@ export function outboxIssues(m, index) {
529
662
  if (!templates.has(template))
530
663
  out.push({ path: at('kinds', value), message: `"${template}" is not one of the app's emailTemplates` });
531
664
  }
665
+ const unlisted = (table, column) => unlistedColumn(m, table, column);
532
666
  (box.producers ?? []).forEach((producer, p) => {
533
667
  const here = (...rest) => at('producers', p, ...rest);
534
668
  if (box.kinds[producer.kind] === undefined)
@@ -537,7 +671,15 @@ export function outboxIssues(m, index) {
537
671
  out.push({ path: here('gate'), message: 'the outbox names no settings column to be gated by' });
538
672
  }
539
673
  else if (typeof producer.gate === 'object') {
540
- col(producer.gate.setting.table, producer.gate.setting.column, ['bool'], here('gate', 'setting'), 'a bool');
674
+ // Each half judged as it is alone: a feature the app declares, a bool of the settings row.
675
+ if ('feature' in producer.gate) {
676
+ const feature = producer.gate.feature;
677
+ if (!(m.addOns?.features ?? []).some((candidate) => candidate.id === feature)) {
678
+ out.push({ path: here('gate', 'feature'), message: `"${feature}" is not one of the app's addOns.features` });
679
+ }
680
+ }
681
+ if ('setting' in producer.gate)
682
+ col(producer.gate.setting.table, producer.gate.setting.column, ['bool'], here('gate', 'setting'), 'a bool');
541
683
  }
542
684
  if (producer.optIn === true && r.optIn === undefined) {
543
685
  out.push({ path: here('optIn'), message: 'the recipient names no opt-in column' });
@@ -599,11 +741,43 @@ export function outboxIssues(m, index) {
599
741
  out.push({ path: here('dropWhen', d), message: `${JSON.stringify(value)} is not a value of "${linked}.${drop.column}"` });
600
742
  }
601
743
  });
602
- if (producer.dropWhen !== undefined && producer.hold !== true && producer.due === undefined && producer.batchMinutes === undefined) {
744
+ if (producer.dropWhen !== undefined && producer.hold !== true && producer.due === undefined && producer.batchMinutes === undefined && producer.holdSeconds === undefined) {
603
745
  out.push({ path: here('dropWhen'), message: 'only a message that waits (held, or due later) can be dropped' });
604
746
  }
605
- if (producer.recipient !== undefined)
606
- setting(producer.recipient.setting, here('recipient', 'setting'));
747
+ // A short wait before it may go: kept in the due column, and the only wait it has.
748
+ if (producer.holdSeconds !== undefined) {
749
+ if (box.columns.due === undefined)
750
+ out.push({ path: here('holdSeconds'), message: 'a message waits in the outbox\'s due column, and none is named' });
751
+ const other = producer.hold === true ? 'hold' : producer.due !== undefined ? 'due' : producer.batchMinutes !== undefined ? 'batchMinutes' : 'before' in producer ? 'before' : null;
752
+ if (other !== null)
753
+ out.push({ path: here('holdSeconds'), message: `a message that waits a few seconds takes no ${other} as well` });
754
+ }
755
+ // One message per value of a column of the row it is about: kept as a digest in the outbox.
756
+ if (producer.repeatBy !== undefined) {
757
+ col(linked, producer.repeatBy, null, here('repeatBy'), '');
758
+ if (box.columns.repeatKey === undefined)
759
+ out.push({ path: here('repeatBy'), message: 'which value a message was sent for is kept in the outbox\'s repeatKey column, and none is named' });
760
+ const other = 'before' in producer ? 'before' : producer.batchMinutes !== undefined ? 'batchMinutes' : null;
761
+ if (other !== null)
762
+ out.push({ path: here('repeatBy'), message: `a message sent again for each value takes no ${other}: that is one per row or window already` });
763
+ }
764
+ if (producer.recipient !== undefined) {
765
+ if ('setting' in producer.recipient)
766
+ setting(producer.recipient.setting, here('recipient', 'setting'));
767
+ else {
768
+ col(linked, producer.recipient.column, ['text'], here('recipient', 'column'), 'a text column');
769
+ if (producer.recipient.name !== undefined)
770
+ col(linked, producer.recipient.name, ['text'], here('recipient', 'name'), 'a text column');
771
+ if (producer.recipient.language !== undefined) {
772
+ col(linked, producer.recipient.language, ['text'], here('recipient', 'language'), 'a text column');
773
+ if (box.columns.language === undefined)
774
+ out.push({ path: here('recipient', 'language'), message: 'a message\'s language is kept in the outbox\'s language column: name it' });
775
+ }
776
+ }
777
+ }
778
+ // The language read from the row a message is about: every producer's row has it.
779
+ if (typeof r.language === 'object')
780
+ col(linked, r.language.column, ['text'], here(), 'a text column (the message\'s language)');
607
781
  if (producer.batchMinutes !== undefined && 'before' in producer) {
608
782
  out.push({ path: here('batchMinutes'), message: 'a reminder before a moment is one per row already' });
609
783
  }
@@ -620,8 +794,13 @@ export function outboxIssues(m, index) {
620
794
  else {
621
795
  fk(linked, effect.via, effect.table, here('onSent', 'via'));
622
796
  }
797
+ const states = index.table(effect.table)?.states;
623
798
  for (const [ref, value] of Object.entries(effect.set)) {
624
799
  const found = col(effect.table, ref, null, here('onSent', 'set', ref), '');
800
+ // An email's change names no state it saw: a state only an undo reaches is never its to make.
801
+ if (states?.column === ref && value !== null && reachedOnlyByUndo(states, value)) {
802
+ out.push({ path: here('onSent', 'set', ref), message: `every move of "${effect.table}" to ${JSON.stringify(value)} is an undo, which only a person makes` });
803
+ }
625
804
  if (found !== undefined && value === null && found.nullable !== true) {
626
805
  out.push({ path: here('onSent', 'set', ref), message: `"${effect.table}.${ref}" is never empty` });
627
806
  }
@@ -633,12 +812,50 @@ export function outboxIssues(m, index) {
633
812
  }
634
813
  if ('onChange' in producer) {
635
814
  const change = producer.onChange;
636
- const watched = col(change.table, change.column, null, here('onChange', 'column'), '');
637
- if (watched !== undefined)
638
- for (const value of Array.isArray(change.to) ? change.to : [change.to]) {
639
- if (!valueFits(watched, value))
640
- out.push({ path: here('onChange', 'to'), message: `${JSON.stringify(value)} is not a value of "${change.table}.${change.column}"` });
641
- }
815
+ if ('columns' in change) {
816
+ change.columns.forEach((column, c) => {
817
+ col(change.table, column, null, here('onChange', 'columns', c), '');
818
+ if (change.columns.indexOf(column) !== c)
819
+ out.push({ path: here('onChange', 'columns', c), message: `"${column}" is listed twice` });
820
+ });
821
+ }
822
+ else {
823
+ const watched = col(change.table, change.column, null, here('onChange', 'column'), '');
824
+ if (watched !== undefined)
825
+ for (const value of Array.isArray(change.to) ? change.to : [change.to]) {
826
+ if (!valueFits(watched, value))
827
+ out.push({ path: here('onChange', 'to'), message: `${JSON.stringify(value)} is not a value of "${change.table}.${change.column}"` });
828
+ }
829
+ }
830
+ }
831
+ // One message per change it hears of: a producer that listens for changes, and repeats no other way.
832
+ if (producer.repeat === true) {
833
+ if (!('onChange' in producer))
834
+ out.push({ path: here('repeat'), message: 'a message is sent for each change by a producer that listens for changes (onChange)' });
835
+ const other = producer.repeatBy !== undefined ? 'repeatBy' : producer.batchMinutes !== undefined ? 'batchMinutes' : null;
836
+ if (other !== null)
837
+ out.push({ path: here('repeat'), message: `a message sent for each change takes no ${other}` });
838
+ }
839
+ // The changed row's columns as they were: kept on the message, read as {{was.<column>}}.
840
+ if (producer.was !== undefined) {
841
+ if (!('onChange' in producer))
842
+ out.push({ path: here('was'), message: 'a message keeps what a row was before a change on a producer that listens for changes (onChange)' });
843
+ else {
844
+ const table = producer.onChange.table;
845
+ producer.was.forEach((column, c) => {
846
+ const found = col(table, column, null, here('was', c), '');
847
+ if (producer.was.indexOf(column) !== c)
848
+ out.push({ path: here('was', c), message: `"${column}" is listed twice` });
849
+ // Personal data as the install tells it too: by its mark, or guessed by its name — a message keeps it empty.
850
+ const tableDoc = m.requiredSchema.tables.find((candidate) => candidate.ref === table);
851
+ const guessed = tableDoc !== undefined && personalColumn(tableDoc, column) === 'guessed' ? 'personal data' : null;
852
+ const kept = found === undefined ? null : (unlisted(table, column) ?? guessed);
853
+ if (kept !== null)
854
+ out.push({ path: here('was', c), message: `"${table}.${column}" is ${kept}, which a message never keeps` });
855
+ });
856
+ }
857
+ if (box.columns.was === undefined)
858
+ out.push({ path: here('was'), message: 'a message keeps what a row was in the outbox\'s was column, and none is named' });
642
859
  }
643
860
  if ('before' in producer) {
644
861
  const before = producer.before;
@@ -653,6 +870,315 @@ export function outboxIssues(m, index) {
653
870
  }
654
871
  }
655
872
  });
873
+ out.push(...emailBlockIssues(m.emailTemplates ?? [], box, index, (table, column) => unlistedColumn(m, table, column)));
874
+ return out;
875
+ }
876
+ // ── blocks that list rows, and QR codes ──────────────────────────────────────
877
+ /**
878
+ * `email.rows`: the rows of a child table that link to the row an outbox link
879
+ * names, one line each (an order's tickets, with a QR code each). `from` is
880
+ * the documents' collection spelling: the link, the child table, the child's
881
+ * link back, its order, a filter and a bool that leaves a row out, and at
882
+ * most 50 rows. `joins` gathers up to two child lists of each row into one
883
+ * text (a dish's options). `row` is each line's text, reading `{{row.<column>}}`
884
+ * (`{{row.<link>.<column>}}` through one of the row's own links, and a join by
885
+ * its name); `image` is a QR code of a code column (`{{row.code.qr}}`) and
886
+ * nothing else. `empty` is said when there are no rows; without it the block
887
+ * is left out.
888
+ */
889
+ export const emailRowsDataSchema = z
890
+ .object({
891
+ from: z
892
+ .object({
893
+ link: z.string().regex(/^[a-z][a-z_]*$/, 'a link name is snake_case'),
894
+ table: refSchema,
895
+ via: refSchema,
896
+ orderBy: refSchema.optional(),
897
+ where: z.object({ column: refSchema, in: z.array(scalarSchema).min(1).max(32) }).strict().optional(),
898
+ unless: refSchema.optional(),
899
+ limit: z.number().int().min(1).max(50).optional(),
900
+ })
901
+ .strict(),
902
+ joins: z
903
+ .record(refSchema, z
904
+ .object({ table: refSchema, via: refSchema, column: refSchema, orderBy: refSchema.optional(), separator: z.string().min(1).max(8).optional() })
905
+ .strict())
906
+ .refine((joins) => Object.keys(joins).length >= 1 && Object.keys(joins).length <= 2, { message: 'a row joins one or two lists' })
907
+ .optional(),
908
+ row: z
909
+ .object({
910
+ title: z.string().min(1).max(300).optional(),
911
+ meta: z.string().min(1).max(300).optional(),
912
+ amount: z.string().min(1).max(300).optional(),
913
+ note: z.string().min(1).max(300).optional(),
914
+ image: z.string().min(1).max(300).optional(),
915
+ })
916
+ .strict(),
917
+ empty: z.string().min(1).max(200).optional(),
918
+ })
919
+ .strict();
920
+ /** A QR code of a code column, as the whole value of an image: `{{ticket.code.qr}}`. */
921
+ const QR_VALUE = /^\{\{\s*([A-Za-z0-9_.-]+)\.qr\s*\}\}$/;
922
+ const PLACEHOLDERS = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
923
+ /** Every string inside a value, with the path it sits at. */
924
+ function stringsIn(value, path, out = []) {
925
+ if (typeof value === 'string')
926
+ out.push({ text: value, path });
927
+ else if (Array.isArray(value))
928
+ value.forEach((item, i) => stringsIn(item, [...path, i], out));
929
+ else if (typeof value === 'object' && value !== null)
930
+ for (const [key, item] of Object.entries(value))
931
+ stringsIn(item, [...path, key], out);
932
+ return out;
933
+ }
934
+ /**
935
+ * Everything wrong with the blocks of the app's templates that list rows or
936
+ * draw a QR code: the rows' link, table and columns; each `{{row.*}}` a row
937
+ * can fill; the same rows in every language; and a QR code only as a whole
938
+ * image value, of a code column.
939
+ */
940
+ function emailBlockIssues(templates, box, index, unlisted) {
941
+ const out = [];
942
+ const links = box.links ?? {};
943
+ /** The table a link names. */
944
+ const linkTable = (link) => {
945
+ const column = links[link];
946
+ return column === undefined ? undefined : index.column(box.table, column)?.references;
947
+ };
948
+ /** The table a template prefix reads (`ticket`, `order.event`), when the manifest can place it. */
949
+ const prefixTable = (prefix) => {
950
+ const [head, ...rest] = prefix.split('.');
951
+ let table = linkTable(head);
952
+ for (const base of rest) {
953
+ if (table === undefined)
954
+ return undefined;
955
+ table = index.column(table, `${base}_id`)?.references;
956
+ }
957
+ return table;
958
+ };
959
+ /** Why `{{<prefix>.<column>.qr}}` draws no QR code, or null. */
960
+ const qrIssue = (table, name) => {
961
+ const parts = name.split('.');
962
+ const column = parts.at(-1);
963
+ if (table === undefined)
964
+ return null;
965
+ const found = index.column(table, column);
966
+ if (found === undefined)
967
+ return `"${table}" has no column "${column}"`;
968
+ if (found.type !== 'text' || found.rules?.code === undefined)
969
+ return `"${table}.${column}" is not a code column, so it has no QR code`;
970
+ return null;
971
+ };
972
+ templates.forEach((template, t) => {
973
+ let rowsOf;
974
+ for (const [locale, content] of Object.entries(template.locales)) {
975
+ const here = (...rest) => ['emailTemplates', t, 'locales', locale, ...rest];
976
+ const rows = [];
977
+ content.blocks.forEach((block, b) => {
978
+ const at = (...rest) => here('blocks', b, ...rest);
979
+ const qrSlot = block.block === 'email.image' ? block.data?.['qr'] : undefined;
980
+ // A QR code is the whole value of an image, and nothing else.
981
+ for (const { text, path } of stringsIn({ subject: content.subject, preheader: content.preheader, footer: content.footer, data: block.data }, [])) {
982
+ if (!/\.qr\s*\}\}/.test(text))
983
+ continue;
984
+ const isSlot = (block.block === 'email.image' && path.join('.') === 'data.qr') || (block.block === 'email.rows' && path.join('.') === 'data.row.image');
985
+ if (!isSlot || !QR_VALUE.test(text))
986
+ out.push({ path: path[0] === 'data' ? at(...path) : here(path[0]), message: 'a QR code is the whole value of an image (email.image "qr", or a row\'s "image"), nowhere else' });
987
+ }
988
+ if (block.block === 'email.image' && qrSlot !== undefined) {
989
+ const match = typeof qrSlot === 'string' ? QR_VALUE.exec(qrSlot) : null;
990
+ if (match === null)
991
+ out.push({ path: at('data', 'qr'), message: 'an image\'s QR code is written {{<link>.<column>.qr}}' });
992
+ else if (match[1].startsWith('row.'))
993
+ out.push({ path: at('data', 'qr'), message: 'a row\'s QR code is drawn by an email.rows block' });
994
+ else {
995
+ const parts = match[1].split('.');
996
+ const issue = qrIssue(prefixTable(parts.slice(0, -1).join('.')), match[1]);
997
+ if (issue !== null)
998
+ out.push({ path: at('data', 'qr'), message: issue });
999
+ }
1000
+ const size = block.data?.['size'];
1001
+ if (size !== undefined && (typeof size !== 'number' || !Number.isInteger(size) || size < 80 || size > 200)) {
1002
+ out.push({ path: at('data', 'size'), message: 'a QR code is 80 to 200 pixels across' });
1003
+ }
1004
+ }
1005
+ if (block.block !== 'email.rows') {
1006
+ // Only a block that lists rows has a row to read.
1007
+ for (const { text, path } of stringsIn(block.data, [])) {
1008
+ if ([...text.matchAll(PLACEHOLDERS)].some((m) => m[1].startsWith('row.'))) {
1009
+ out.push({ path: at('data', ...path), message: '{{row.…}} is read only inside an email.rows block' });
1010
+ }
1011
+ }
1012
+ return;
1013
+ }
1014
+ const parsed = emailRowsDataSchema.safeParse(block.data ?? {});
1015
+ if (!parsed.success) {
1016
+ for (const issue of parsed.error.issues)
1017
+ out.push({ path: at('data', ...issue.path.map((p) => (typeof p === 'symbol' ? String(p) : p))), message: issue.message });
1018
+ return;
1019
+ }
1020
+ const data = parsed.data;
1021
+ rows.push(JSON.stringify({ from: data.from, joins: data.joins ?? null }));
1022
+ const from = data.from;
1023
+ const parent = linkTable(from.link);
1024
+ if (links[from.link] === undefined)
1025
+ out.push({ path: at('data', 'from', 'link'), message: `"${from.link}" is not one of the outbox's links` });
1026
+ if (index.table(from.table) === undefined) {
1027
+ out.push({ path: at('data', 'from', 'table'), message: `"${from.table}" is not a table of this app` });
1028
+ return;
1029
+ }
1030
+ const via = index.column(from.table, from.via);
1031
+ if (via?.type !== 'fk' || (parent !== undefined && via.references !== parent)) {
1032
+ out.push({ path: at('data', 'from', 'via'), message: `"${from.table}.${from.via}" does not point at the row "${from.link}" names` });
1033
+ }
1034
+ for (const [name, ref] of [['orderBy', from.orderBy], ['where', from.where?.column], ['unless', from.unless]]) {
1035
+ if (ref !== undefined && index.column(from.table, ref) === undefined)
1036
+ out.push({ path: at('data', 'from', name), message: `"${from.table}" has no column "${ref}"` });
1037
+ }
1038
+ if (from.unless !== undefined && index.column(from.table, from.unless) !== undefined && index.column(from.table, from.unless)?.type !== 'bool') {
1039
+ out.push({ path: at('data', 'from', 'unless'), message: `"${from.table}.${from.unless}" is not a bool` });
1040
+ }
1041
+ const where = from.where;
1042
+ const filter = where === undefined ? undefined : index.column(from.table, where.column);
1043
+ if (where !== undefined && filter !== undefined) {
1044
+ for (const value of where.in)
1045
+ if (!valueFits(filter, value))
1046
+ out.push({ path: at('data', 'from', 'where'), message: `${JSON.stringify(value)} is not a value of "${from.table}.${where.column}"` });
1047
+ }
1048
+ for (const [name, join] of Object.entries(data.joins ?? {})) {
1049
+ const path = at('data', 'joins', name);
1050
+ if (index.table(join.table) === undefined) {
1051
+ out.push({ path, message: `"${join.table}" is not a table of this app` });
1052
+ continue;
1053
+ }
1054
+ const back = index.column(join.table, join.via);
1055
+ if (back?.type !== 'fk' || back.references !== from.table)
1056
+ out.push({ path: [...path, 'via'], message: `"${join.table}.${join.via}" does not point at "${from.table}"` });
1057
+ const shown = index.column(join.table, join.column);
1058
+ if (shown === undefined)
1059
+ out.push({ path: [...path, 'column'], message: `"${join.table}" has no column "${join.column}"` });
1060
+ else if (shown.type !== 'text')
1061
+ out.push({ path: [...path, 'column'], message: `"${join.table}.${join.column}" is not a text column` });
1062
+ else {
1063
+ const kept = unlisted(join.table, join.column);
1064
+ if (kept !== null)
1065
+ out.push({ path: [...path, 'column'], message: `"${join.table}.${join.column}" is ${kept}, which an email never lists from another row` });
1066
+ }
1067
+ if (join.orderBy !== undefined && index.column(join.table, join.orderBy) === undefined)
1068
+ out.push({ path: [...path, 'orderBy'], message: `"${join.table}" has no column "${join.orderBy}"` });
1069
+ }
1070
+ // Each {{row.*}} names something a row fills, in a form its type has.
1071
+ for (const { text, path } of stringsIn({ row: data.row, empty: data.empty }, [])) {
1072
+ for (const m of text.matchAll(PLACEHOLDERS)) {
1073
+ const name = m[1];
1074
+ if (!name.startsWith('row.'))
1075
+ continue;
1076
+ const parts = name.split('.').slice(1);
1077
+ const where = at('data', ...path);
1078
+ if (parts.length === 1 && data.joins?.[parts[0]] !== undefined)
1079
+ continue;
1080
+ const qr = parts.at(-1) === 'qr';
1081
+ const bare = qr ? parts.slice(0, -1) : parts;
1082
+ // `row.col`, `row.col.form`, and one link of the row's own: `row.base.col`, `row.base.col.form` (through `base_id`).
1083
+ let table = from.table;
1084
+ let column = bare[0];
1085
+ let form = bare[1];
1086
+ if (index.column(from.table, column) === undefined) {
1087
+ const link = index.column(from.table, `${column}_id`);
1088
+ if (link?.type !== 'fk' || link.references === undefined) {
1089
+ out.push({ path: where, message: `{{${name}}}: "${from.table}" has no column "${column}", no link "${column}_id" and no join "${column}"` });
1090
+ continue;
1091
+ }
1092
+ table = link.references;
1093
+ column = bare[1] ?? '';
1094
+ form = bare[2];
1095
+ }
1096
+ const found = index.column(table, column);
1097
+ if (found === undefined) {
1098
+ out.push({ path: where, message: `{{${name}}}: "${table}" has no column "${column}"` });
1099
+ continue;
1100
+ }
1101
+ if (qr) {
1102
+ if (path.join('.') !== 'row.image')
1103
+ continue; // said above
1104
+ const issue = qrIssue(table, `${table}.${column}`);
1105
+ if (issue !== null)
1106
+ out.push({ path: where, message: issue });
1107
+ }
1108
+ else if (form !== undefined && !rowFormsOf(found).includes(form)) {
1109
+ out.push({
1110
+ path: where,
1111
+ message: form === 'time' && found.type === 'text'
1112
+ ? `{{${name}}}: "${table}.${column}" is not a time of day kept as text (a text column of at most 8 characters), so it has no "time" form`
1113
+ : `{{${name}}}: "${table}.${column}" is a ${found.type} column, which has no "${form}" form`,
1114
+ });
1115
+ }
1116
+ }
1117
+ }
1118
+ if (data.row.image !== undefined && !QR_VALUE.test(data.row.image)) {
1119
+ out.push({ path: at('data', 'row', 'image'), message: 'a row\'s image is a QR code of a code column: {{row.<column>.qr}}' });
1120
+ }
1121
+ });
1122
+ // A translation changes the words, never which rows are listed.
1123
+ const key = rows.join('\n');
1124
+ if (rowsOf === undefined)
1125
+ rowsOf = key;
1126
+ else if (key !== rowsOf)
1127
+ out.push({ path: here('blocks'), message: 'every language lists the same rows: from and joins are the same in each' });
1128
+ }
1129
+ });
656
1130
  return out;
657
1131
  }
1132
+ /** The forms of a number: as a number, a percentage and an amount of money, in the message's language. */
1133
+ const NUMBER_FORMS = ['number', 'percent', 'money'];
1134
+ /** The forms of a value a row's column may be read in, by its type (as the sender fills a template's). */
1135
+ const ROW_FORMS = {
1136
+ timestamptz: ['date', 'time', 'day_month', 'relative_day'],
1137
+ date: ['day_month', 'days_since'],
1138
+ int: NUMBER_FORMS,
1139
+ bigint: NUMBER_FORMS,
1140
+ decimal: NUMBER_FORMS,
1141
+ money: NUMBER_FORMS,
1142
+ float: NUMBER_FORMS,
1143
+ // A choice by its label; a time of day kept as text (`15:00`) in the reader's clock.
1144
+ enum: ['label'],
1145
+ text: ['time'],
1146
+ };
1147
+ /** The most characters a time of day kept as text takes: `09:30:00`. */
1148
+ export const CLOCK_TEXT_MAX = 8;
1149
+ /**
1150
+ * Whether a text column keeps a time of day (`15:00`): one no longer than
1151
+ * {@link CLOCK_TEXT_MAX} characters. Only such a column has a `time` form —
1152
+ * the sender fills it from a value shaped like a clock, and a longer text
1153
+ * column (a note, a name) never holds one.
1154
+ */
1155
+ export function clockShaped(column) {
1156
+ if (column.type !== 'text')
1157
+ return false;
1158
+ const widths = [column.maxLength, column.rules?.validation?.maxLength].filter((width) => typeof width === 'number');
1159
+ return widths.length > 0 && Math.min(...widths) <= CLOCK_TEXT_MAX;
1160
+ }
1161
+ /** The form of a code in groups of four (`K7QX-M2PD`), as a person reads it out. */
1162
+ export const GROUPED_FORM = 'grouped';
1163
+ /**
1164
+ * A code in groups of four joined by `-` (`K7QXM2PD` → `K7QX-M2PD`), the way
1165
+ * a person reads it out; separators already in it are dropped first. Empty
1166
+ * stays empty.
1167
+ */
1168
+ export function groupedCode(value) {
1169
+ if (value === null || value === undefined)
1170
+ return '';
1171
+ const bare = String(value).replace(/[\s-]+/g, '');
1172
+ return (bare.match(/.{1,4}/g) ?? []).join('-');
1173
+ }
1174
+ /** Whether a column holds a code Adminium makes: a text column with a `code` rule. */
1175
+ export function isCodeColumn(column) {
1176
+ return column.type === 'text' && column.rules?.code !== undefined;
1177
+ }
1178
+ /** The forms a row's column may be read in: its type's, a text column's `time` only when it keeps a time of day, and a code's groups. */
1179
+ export function rowFormsOf(column) {
1180
+ const forms = ROW_FORMS[column.type] ?? [];
1181
+ const own = column.type === 'text' && !clockShaped(column) ? forms.filter((form) => form !== 'time') : forms;
1182
+ return isCodeColumn(column) ? [...own, GROUPED_FORM] : own;
1183
+ }
658
1184
  //# sourceMappingURL=outbox.js.map