@adminiumjs/manifest 0.3.16 → 0.3.18-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/adjust.d.ts +322 -0
  2. package/dist/adjust.d.ts.map +1 -0
  3. package/dist/adjust.js +512 -0
  4. package/dist/adjust.js.map +1 -0
  5. package/dist/automations.d.ts +2309 -0
  6. package/dist/automations.d.ts.map +1 -0
  7. package/dist/automations.js +384 -0
  8. package/dist/automations.js.map +1 -0
  9. package/dist/compose.d.ts.map +1 -1
  10. package/dist/compose.js +1 -0
  11. package/dist/compose.js.map +1 -1
  12. package/dist/documents.d.ts +82 -1
  13. package/dist/documents.d.ts.map +1 -1
  14. package/dist/documents.js +36 -6
  15. package/dist/documents.js.map +1 -1
  16. package/dist/index.d.ts +15 -6
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +15 -6
  19. package/dist/index.js.map +1 -1
  20. package/dist/key-bytes.d.ts +27 -0
  21. package/dist/key-bytes.d.ts.map +1 -0
  22. package/dist/key-bytes.js +48 -0
  23. package/dist/key-bytes.js.map +1 -0
  24. package/dist/ledger-indexes.d.ts +19 -0
  25. package/dist/ledger-indexes.d.ts.map +1 -0
  26. package/dist/ledger-indexes.js +74 -0
  27. package/dist/ledger-indexes.js.map +1 -0
  28. package/dist/ledgers.d.ts +691 -0
  29. package/dist/ledgers.d.ts.map +1 -0
  30. package/dist/ledgers.js +778 -0
  31. package/dist/ledgers.js.map +1 -0
  32. package/dist/look-up.d.ts +73 -0
  33. package/dist/look-up.d.ts.map +1 -0
  34. package/dist/look-up.js +140 -0
  35. package/dist/look-up.js.map +1 -0
  36. package/dist/marketplace-wire.d.ts +2 -2
  37. package/dist/outbox.d.ts +29 -3
  38. package/dist/outbox.d.ts.map +1 -1
  39. package/dist/outbox.js +94 -15
  40. package/dist/outbox.js.map +1 -1
  41. package/dist/page-config.d.ts +99 -0
  42. package/dist/page-config.d.ts.map +1 -0
  43. package/dist/page-config.js +197 -0
  44. package/dist/page-config.js.map +1 -0
  45. package/dist/plan-context.d.ts +8 -18
  46. package/dist/plan-context.d.ts.map +1 -1
  47. package/dist/plan-context.js +53 -51
  48. package/dist/plan-context.js.map +1 -1
  49. package/dist/plan-model.d.ts +8 -1
  50. package/dist/plan-model.d.ts.map +1 -1
  51. package/dist/plan.d.ts +1 -1
  52. package/dist/plan.d.ts.map +1 -1
  53. package/dist/plan.js +6 -3
  54. package/dist/plan.js.map +1 -1
  55. package/dist/public-access.d.ts +33 -5
  56. package/dist/public-access.d.ts.map +1 -1
  57. package/dist/public-access.js +82 -4
  58. package/dist/public-access.js.map +1 -1
  59. package/dist/refs.d.ts +2 -0
  60. package/dist/refs.d.ts.map +1 -1
  61. package/dist/refs.js +5 -0
  62. package/dist/refs.js.map +1 -1
  63. package/dist/roles.d.ts +46 -0
  64. package/dist/roles.d.ts.map +1 -1
  65. package/dist/roles.js +50 -0
  66. package/dist/roles.js.map +1 -1
  67. package/dist/sample.d.ts +70 -1
  68. package/dist/sample.d.ts.map +1 -1
  69. package/dist/sample.js +208 -5
  70. package/dist/sample.js.map +1 -1
  71. package/dist/schema.d.ts +9657 -2440
  72. package/dist/schema.d.ts.map +1 -1
  73. package/dist/schema.js +751 -52
  74. package/dist/schema.js.map +1 -1
  75. package/dist/state-actions.d.ts +185 -0
  76. package/dist/state-actions.d.ts.map +1 -0
  77. package/dist/state-actions.js +223 -0
  78. package/dist/state-actions.js.map +1 -0
  79. package/dist/states.d.ts +66 -0
  80. package/dist/states.d.ts.map +1 -1
  81. package/dist/states.js +31 -0
  82. package/dist/states.js.map +1 -1
  83. package/dist/validate.d.ts.map +1 -1
  84. package/dist/validate.js +22 -6
  85. package/dist/validate.js.map +1 -1
  86. package/dist/words.d.ts +33 -0
  87. package/dist/words.d.ts.map +1 -0
  88. package/dist/words.js +208 -0
  89. package/dist/words.js.map +1 -0
  90. package/package.json +2 -2
@@ -0,0 +1,778 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ /**
3
+ * LEDGERS AND POSTINGS.
4
+ *
5
+ * A ledger is an add-on's own set of tables that only Adminium writes, from
6
+ * rows the add-on's code works out (stock movements, a gift card's spends).
7
+ * The add-on declares it under `addOn.ledgers`: its receipt table, the tables
8
+ * and columns that may be written, and its ACTIONS (use, receive, spend …) —
9
+ * each with the inputs it takes, the rows it reads first and the rows a lock
10
+ * is named by.
11
+ *
12
+ * A posting is the other half, declared on any table of any manifest
13
+ * (`postings`): "when a row of this table reaches this point, hand these
14
+ * columns to that action". An order line reserving stock at checkout and
15
+ * taking it when the order is picked up is one posting with two points.
16
+ *
17
+ * This file holds the words and what one manifest can check of them by
18
+ * itself. Whether a host's posting fits the ledger it names is checked with
19
+ * both manifests at hand (`postingFitIssues`), at install and when an owner
20
+ * stores a rule.
21
+ *
22
+ * Pure: no I/O, and nothing imported from the manifest's own schema file, so
23
+ * that file may import this one.
24
+ */
25
+ import { z } from 'zod';
26
+ import { refSchema, scalarSchema } from './refs.js';
27
+ const kebab = z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a kebab-case id');
28
+ const addOnKey = z.string().regex(/^[a-z][a-z0-9-]{1,79}$/, 'an add-on key');
29
+ const inputName = z.string().regex(/^[a-z][a-z0-9_]{0,39}$/, 'a snake_case name');
30
+ const readName = z.string().regex(/^[a-z][a-z0-9_]{0,39}$/, 'a snake_case name');
31
+ const stateName = z.string().min(1).max(40);
32
+ // ── limits ───────────────────────────────────────────────────────────────────
33
+ /** The most ledgers one add-on declares. */
34
+ export const LEDGERS_MAX = 4;
35
+ /** The most actions one ledger declares. */
36
+ export const LEDGER_ACTIONS_MAX = 16;
37
+ /** The most tables one ledger may write. */
38
+ export const LEDGER_WRITES_MAX = 12;
39
+ /** The most reads one action declares. */
40
+ export const LEDGER_READS_MAX = 6;
41
+ /** The most rows one read returns. */
42
+ export const LEDGER_READ_ROWS = 1000;
43
+ /** The most postings one table carries. */
44
+ export const POSTINGS_PER_TABLE = 6;
45
+ // ── the ledger ───────────────────────────────────────────────────────────────
46
+ /** What an action's input is: a link, a number, text, a date, a yes/no, a table's stored name, or a row of any table. */
47
+ export const LEDGER_INPUT_TYPES = ['link', 'link?', 'number', 'number?', 'decimal', 'decimal?', 'text', 'text?', 'date?', 'bool?', 'tableRef', 'rowRef'];
48
+ /** Whether a posting may leave the input unmapped. */
49
+ export const optionalInput = (type) => type.endsWith('?');
50
+ /** The phases of a posting round: held, taken, given back. */
51
+ export const POSTING_PHASES = ['reserve', 'post', 'reverse'];
52
+ /**
53
+ * Where a read's key comes from: an input of the action (`input.item`; a
54
+ * `rowRef` input as `input.what.table` / `input.what.row`), a column of an
55
+ * earlier read (`items.unit_id`), the source row (`source.table`,
56
+ * `source.row`, `source.line`), the round's receipt (`receipt.id`), a column
57
+ * of the add-on's settings row (`setting.low_below`), or what a price
58
+ * question recorded (`uses.offer`, `uses.code`, `uses.voucher`).
59
+ */
60
+ export const readFromSchema = z
61
+ .string()
62
+ .regex(/^(input\.[a-z][a-z0-9_]*(\.(table|row))?|source\.(table|row|line)|receipt\.id|setting\.[a-z][a-z0-9_]*|uses\.(offer|code|voucher)|[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*)$/, 'input.<name>, <read>.<column>, source.table | source.row | source.line, receipt.id, setting.<column> or uses.offer | uses.code | uses.voucher');
63
+ const readWhereSchema = z
64
+ .object({ column: refSchema, eq: scalarSchema.optional(), in: z.array(scalarSchema).min(1).max(16).optional() })
65
+ .strict()
66
+ .refine((w) => (w.eq === undefined) !== (w.in === undefined), { message: 'a condition gives one of `eq` or `in`' });
67
+ /** One read an action makes before its code runs: rows of an own table, by up to three keys. */
68
+ export const ledgerReadSchema = z
69
+ .object({
70
+ as: readName,
71
+ table: refSchema,
72
+ /** Each key column and where its values come from; several sources are read together. */
73
+ by: z.array(z.object({ column: refSchema, from: z.union([readFromSchema, z.array(readFromSchema).min(1).max(3)]) }).strict()).max(3),
74
+ where: z.array(readWhereSchema).max(3).optional(),
75
+ limit: z.number().int().min(1).max(LEDGER_READ_ROWS).optional(),
76
+ })
77
+ .strict();
78
+ /** What Adminium fills in for the action, between a floor of zero and a ceiling it reads itself. */
79
+ const decidesSchema = z
80
+ .object({
81
+ input: inputName,
82
+ min: z.literal('0'),
83
+ max: z.union([z.object({ input: inputName }).strict(), z.object({ read: readName, column: refSchema }).strict()]),
84
+ })
85
+ .strict();
86
+ export const ledgerActionSchema = z
87
+ .object({
88
+ inputs: z.record(inputName, z.enum(LEDGER_INPUT_TYPES)),
89
+ phases: z.array(z.enum(POSTING_PHASES)).min(1).max(3),
90
+ reads: z.array(ledgerReadSchema).max(LEDGER_READS_MAX),
91
+ /** What a lock is named by: a column of a read's rows, standing for a row of `table`. */
92
+ locks: z.array(z.object({ read: readName, column: refSchema, table: refSchema }).strict()).min(1).max(4),
93
+ decides: z.array(decidesSchema).min(1).max(4).optional(),
94
+ /** A reserve writes rows that expire: a posting into this action says until when. */
95
+ holds: z.literal(true).optional(),
96
+ /** While the add-on cannot answer: a yes/no column of a read that lets the save through unplanned. */
97
+ unavailable: z.object({ allow: z.object({ read: readName, column: refSchema }).strict() }).strict().optional(),
98
+ /** The tables of the ledger's `writes` this action writes; absent, all of them. */
99
+ writes: z.array(refSchema).min(1).max(LEDGER_WRITES_MAX).optional(),
100
+ })
101
+ .strict()
102
+ .refine((a) => new Set(a.phases).size === a.phases.length, { message: 'a phase is listed once', path: ['phases'] })
103
+ .refine((a) => new Set(a.reads.map((read) => read.as)).size === a.reads.length, { message: 'two reads share a name', path: ['reads'] });
104
+ /**
105
+ * The table a `link` input names a row of: the table of the action's read
106
+ * that is keyed by its own primary key and fed by that input. Null when no
107
+ * read says so (an input the add-on's code looks up by another column).
108
+ * `keyOf` answers a table's primary-key column.
109
+ */
110
+ export function linkInputTable(action, input, keyOf) {
111
+ const type = action.inputs[input];
112
+ if (type !== 'link' && type !== 'link?')
113
+ return null;
114
+ const fed = (from) => (typeof from === 'string' ? [from] : from).includes(`input.${input}`);
115
+ return action.reads.find((read) => read.by.some((by) => fed(by.from) && by.column === keyOf(read.table)))?.table ?? null;
116
+ }
117
+ const writeScopeSchema = z
118
+ .object({
119
+ insert: z.array(refSchema).min(1).max(40).optional(),
120
+ update: z.object({ by: z.array(refSchema).min(1).max(4), set: z.array(refSchema).min(1).max(20) }).strict().optional(),
121
+ })
122
+ .strict()
123
+ .refine((scope) => scope.insert !== undefined || scope.update !== undefined, { message: 'a written table takes inserts, updates, or both' });
124
+ export const ledgerSchema = z
125
+ .object({
126
+ id: kebab,
127
+ /** The receipt table: one row per source, line, posting, phase and round, written by Adminium alone. */
128
+ receipts: refSchema,
129
+ /** Which public answer a refusal becomes: out of stock, or a refused card. */
130
+ refusal: z.enum(['stock', 'value']),
131
+ writes: z.record(refSchema, writeScopeSchema).refine((writes) => Object.keys(writes).length >= 1 && Object.keys(writes).length <= LEDGER_WRITES_MAX, {
132
+ message: `a ledger writes 1 to ${String(LEDGER_WRITES_MAX)} tables`,
133
+ }),
134
+ actions: z.record(kebab, ledgerActionSchema).refine((actions) => Object.keys(actions).length >= 1 && Object.keys(actions).length <= LEDGER_ACTIONS_MAX, {
135
+ message: `a ledger declares 1 to ${String(LEDGER_ACTIONS_MAX)} actions`,
136
+ }),
137
+ })
138
+ .strict();
139
+ export const ledgersSchema = z
140
+ .array(ledgerSchema)
141
+ .min(1)
142
+ .max(LEDGERS_MAX)
143
+ .refine((ledgers) => new Set(ledgers.map((ledger) => ledger.id)).size === ledgers.length, { message: 'two ledgers share an id' });
144
+ // ── the posting ──────────────────────────────────────────────────────────────
145
+ /**
146
+ * The moment a posting's phase fires: the row's own create; a move of its
147
+ * state; a column reaching a value; a column being filled. Under `via` every
148
+ * point but a create is judged on the parent row, unless it says `own`.
149
+ */
150
+ export const postingPointSchema = z.union([
151
+ z.object({ create: z.literal(true) }).strict(),
152
+ z.object({ to: z.array(stateName).min(1).max(16), from: z.array(stateName).min(1).max(16).optional() }).strict(),
153
+ z.object({ column: refSchema, in: z.array(scalarSchema).min(1).max(16), from: z.array(scalarSchema).min(1).max(16).optional(), own: z.literal(true).optional() }).strict(),
154
+ z.object({ column: refSchema, set: z.literal(true), own: z.literal(true).optional() }).strict(),
155
+ ]);
156
+ /** What an input is filled from: a column of the row, the row itself, a column of the `via` parent, a setting of the add-on, or a fixed value. */
157
+ export const postingMappingSchema = z.union([
158
+ refSchema,
159
+ z.object({ row: z.literal(true) }).strict(),
160
+ z.object({ parent: refSchema }).strict(),
161
+ z.object({ setting: refSchema }).strict(),
162
+ z.object({ value: scalarSchema }).strict(),
163
+ ]);
164
+ const phaseSchema = z.object({ on: postingPointSchema }).strict();
165
+ export const postingSchema = z
166
+ .object({
167
+ id: kebab,
168
+ into: z.object({ addOn: addOnKey, ledger: kebab, action: kebab }).strict(),
169
+ /** The rule is live only while this feature's add-ons are there (an app's `addOns.features`). */
170
+ needs: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a feature id').optional(),
171
+ /** This table's rows are lines of the row this foreign key names. */
172
+ via: refSchema.optional(),
173
+ reserve: phaseSchema.optional(),
174
+ post: phaseSchema.optional(),
175
+ reverse: phaseSchema.optional(),
176
+ map: z.record(inputName, postingMappingSchema),
177
+ multipliers: z.record(inputName, postingMappingSchema).optional(),
178
+ /** Until when a hold lasts; needed when the action holds. */
179
+ heldUntil: postingMappingSchema.optional(),
180
+ /** The save is refused while a sibling line has this column filled (a card never pays for a card). */
181
+ refuses: z
182
+ .array(z.object({ table: refSchema.optional(), via: refSchema.optional(), column: refSchema, set: z.literal(true) }).strict())
183
+ .min(1)
184
+ .max(4)
185
+ .optional(),
186
+ /** A line with this column filled is left out of every call (a voided line). */
187
+ unlessSet: refSchema.optional(),
188
+ /** Only such lines are handed over (a payment by card, not by cash). */
189
+ only: z
190
+ .union([
191
+ z.object({ column: refSchema, eq: scalarSchema }).strict(),
192
+ z.object({ column: refSchema, in: z.array(scalarSchema).min(1).max(16) }).strict(),
193
+ z.object({ column: refSchema, set: z.literal(true) }).strict(),
194
+ ])
195
+ .optional(),
196
+ })
197
+ .strict();
198
+ export const postingsSchema = z
199
+ .array(postingSchema)
200
+ .min(1)
201
+ .max(POSTINGS_PER_TABLE)
202
+ .refine((postings) => new Set(postings.map((posting) => posting.id)).size === postings.length, { message: 'two postings of the table share an id' });
203
+ /** The owner's switch, stored apart from the rule so an app update never switches it back on. */
204
+ export const switchedOffSchema = z.object({ postings: z.array(kebab).max(POSTINGS_PER_TABLE), adjust: z.literal(true).optional() }).strict();
205
+ /** Every state a table's `states` names. */
206
+ function statesOf(table) {
207
+ const out = new Set();
208
+ if (table.states === undefined)
209
+ return out;
210
+ out.add(table.states.initial);
211
+ for (const [from, moves] of Object.entries(table.states.moves)) {
212
+ out.add(from);
213
+ for (const move of moves)
214
+ out.add(typeof move === 'string' ? move : String(move.to));
215
+ }
216
+ return out;
217
+ }
218
+ /** The columns of a table that are a balance another column's total keeps. */
219
+ function balanceColumns(table) {
220
+ return new Set(table.columns.flatMap((column) => (column.rules?.rollup?.balance === undefined ? [] : [column.rules.rollup.balance.column])));
221
+ }
222
+ /**
223
+ * Everything wrong with a table's postings that this manifest can see: its
224
+ * points, its `via`, the columns it maps, and — for an add-on posting into
225
+ * its own ledger — the fit with the action it names.
226
+ */
227
+ export function postingIssues(table, ctx, at) {
228
+ const out = [];
229
+ const has = (ref, of = table) => of.columns.find((column) => column.ref === ref);
230
+ (table.postings ?? []).forEach((posting, p) => {
231
+ const here = (...rest) => at('postings', p, ...rest);
232
+ // Where it goes.
233
+ const own = posting.into.addOn === ctx.key;
234
+ if (own && ctx.kind !== 'add-on')
235
+ out.push({ path: here('into', 'addOn'), message: 'a posting goes into an add-on\'s ledger, not into the app itself' });
236
+ if (!own && !ctx.named.has(posting.into.addOn)) {
237
+ out.push({ path: here('into', 'addOn'), message: `"${posting.into.addOn}" is not an add-on this manifest names: add it to addOns.requires or addOns.suggests` });
238
+ }
239
+ if (posting.needs !== undefined) {
240
+ const feature = ctx.features.get(posting.needs);
241
+ if (feature === undefined)
242
+ out.push({ path: here('needs'), message: `"${posting.needs}" is not one of the app's addOns.features` });
243
+ else if (!feature.includes(posting.into.addOn))
244
+ out.push({ path: here('needs'), message: `the feature "${posting.needs}" does not need "${posting.into.addOn}", so it cannot switch this posting` });
245
+ }
246
+ // Whose rows the points are judged on.
247
+ let parent;
248
+ if (posting.via !== undefined) {
249
+ const link = has(posting.via);
250
+ if (link === undefined || link.type !== 'fk' || link.references === undefined) {
251
+ out.push({ path: here('via'), message: `"${table.ref}.${posting.via}" is not a foreign key of the table, so its rows are no lines of anything` });
252
+ }
253
+ else {
254
+ parent = ctx.tables.get(link.references);
255
+ if (parent === undefined)
256
+ out.push({ path: here('via'), message: `"${link.references}" is not one of this manifest's tables` });
257
+ }
258
+ }
259
+ if (posting.reserve === undefined && posting.post === undefined)
260
+ out.push({ path: here(), message: 'a posting declares when it reserves, when it posts, or both' });
261
+ for (const phase of POSTING_PHASES) {
262
+ const point = posting[phase]?.on;
263
+ if (point === undefined)
264
+ continue;
265
+ const path = here(phase, 'on');
266
+ if ('create' in point)
267
+ continue;
268
+ if ('own' in point && point.own === true && posting.via === undefined) {
269
+ out.push({ path: [...path, 'own'], message: '"own" judges a point on the line itself: it is said under "via"' });
270
+ }
271
+ // Under `via` a point is the parent's, unless it says it is the line's own.
272
+ const judged = posting.via !== undefined && !('own' in point && point.own === true) ? parent : table;
273
+ if (judged === undefined)
274
+ continue;
275
+ if ('to' in point) {
276
+ if (judged.states === undefined) {
277
+ out.push({ path: [...path, 'to'], message: `"${judged.ref}" declares no states to move between: name a column and its values instead ({"column": …, "in": […]})` });
278
+ continue;
279
+ }
280
+ const states = statesOf(judged);
281
+ for (const state of [...point.to, ...(point.from ?? [])]) {
282
+ if (!states.has(state))
283
+ out.push({ path, message: `"${state}" is not a state of "${judged.ref}"` });
284
+ }
285
+ continue;
286
+ }
287
+ const column = has(point.column, judged);
288
+ if (column === undefined) {
289
+ out.push({ path: [...path, 'column'], message: `"${judged.ref}" has no column "${point.column}"` });
290
+ }
291
+ else if ('set' in point) {
292
+ if (column.nullable !== true)
293
+ out.push({ path: [...path, 'column'], message: `"${judged.ref}.${point.column}" is never empty, so it is never "set": make it nullable` });
294
+ if (phase === 'reverse' && 'from' in point)
295
+ out.push({ path, message: 'a column being set has no "from"' });
296
+ }
297
+ }
298
+ // What it maps.
299
+ const mapped = (mapping, path, what) => {
300
+ if (typeof mapping === 'string') {
301
+ const column = has(mapping);
302
+ if (column === undefined) {
303
+ out.push({ path, message: `"${table.ref}" has no column "${mapping}"` });
304
+ return;
305
+ }
306
+ if (balanceColumns(table).has(mapping) || column.rules?.rollup !== undefined) {
307
+ out.push({ path, message: `"${table.ref}.${mapping}" is a total Adminium settles in the same save, so ${what} is not read from it` });
308
+ }
309
+ else if (column.rules?.copy?.follow !== undefined) {
310
+ out.push({ path, message: `"${table.ref}.${mapping}" follows another row and is settled in the same save, so ${what} is not read from it` });
311
+ }
312
+ return;
313
+ }
314
+ if ('parent' in mapping) {
315
+ if (posting.via === undefined)
316
+ out.push({ path, message: 'a column of the parent is read under "via"' });
317
+ else if (parent !== undefined && has(mapping.parent, parent) === undefined)
318
+ out.push({ path, message: `"${parent.ref}" has no column "${mapping.parent}"` });
319
+ }
320
+ };
321
+ for (const [input, mapping] of Object.entries(posting.map))
322
+ mapped(mapping, here('map', input), `the input "${input}"`);
323
+ for (const [name, mapping] of Object.entries(posting.multipliers ?? {}))
324
+ mapped(mapping, here('multipliers', name), `the multiplier "${name}"`);
325
+ if (posting.heldUntil !== undefined) {
326
+ mapped(posting.heldUntil, here('heldUntil'), 'how long a hold lasts');
327
+ // A hold ends at a moment a row keeps: nothing else can be read as one, and a hold with no end is never given back.
328
+ if (typeof posting.heldUntil !== 'string' && !('parent' in posting.heldUntil))
329
+ out.push({ path: here('heldUntil'), message: 'how long a hold lasts is read from a column of the row, or of the row its lines belong to ({"parent": …})' });
330
+ }
331
+ if (posting.unlessSet !== undefined && has(posting.unlessSet) === undefined)
332
+ out.push({ path: here('unlessSet'), message: `"${table.ref}" has no column "${posting.unlessSet}"` });
333
+ if (posting.only !== undefined && has(posting.only.column) === undefined)
334
+ out.push({ path: here('only', 'column'), message: `"${table.ref}" has no column "${posting.only.column}"` });
335
+ (posting.refuses ?? []).forEach((refusal, r) => {
336
+ const path = here('refuses', r);
337
+ if ((refusal.table === undefined) !== (refusal.via === undefined)) {
338
+ out.push({ path, message: 'lines of another table are named by that table and its foreign key to the same parent ("table" and "via" together)' });
339
+ return;
340
+ }
341
+ const sibling = refusal.table === undefined ? table : ctx.tables.get(refusal.table);
342
+ if (sibling === undefined) {
343
+ out.push({ path: [...path, 'table'], message: `"${String(refusal.table)}" is not one of this manifest's tables` });
344
+ return;
345
+ }
346
+ if (posting.via === undefined)
347
+ out.push({ path, message: 'sibling lines are lines of one parent: said under "via"' });
348
+ if (has(refusal.column, sibling) === undefined)
349
+ out.push({ path: [...path, 'column'], message: `"${sibling.ref}" has no column "${refusal.column}"` });
350
+ if (refusal.via !== undefined) {
351
+ const link = has(refusal.via, sibling);
352
+ if (link === undefined || link.type !== 'fk' || (parent !== undefined && link.references !== parent.ref)) {
353
+ out.push({ path: [...path, 'via'], message: `"${sibling.ref}.${refusal.via}" is not a foreign key to the same parent` });
354
+ }
355
+ }
356
+ });
357
+ // An add-on posting into its own ledger: the action is at hand.
358
+ if (own && ctx.kind === 'add-on') {
359
+ const ledger = ctx.ledgers.find((candidate) => candidate.id === posting.into.ledger);
360
+ if (ledger === undefined)
361
+ out.push({ path: here('into', 'ledger'), message: `this add-on declares no ledger "${posting.into.ledger}"` });
362
+ else
363
+ out.push(...postingFitIssues(posting, ledger, { timed: parentTimed(posting, table, parent) }).map((issue) => ({ path: here(...issue.path), message: issue.message })));
364
+ }
365
+ });
366
+ return out;
367
+ }
368
+ /** Whether the row a posting's points are judged on moves by itself in time (`states.timed`). */
369
+ function parentTimed(posting, table, parent) {
370
+ const judged = posting.via === undefined ? table : parent;
371
+ return judged?.states?.timed !== undefined;
372
+ }
373
+ /**
374
+ * Whether a posting fits the ledger action it names: the action exists, every
375
+ * input it needs is mapped and nothing else is, its phases are ones the
376
+ * action has, and a hold has an end.
377
+ *
378
+ * `timed`: whether the judged row's table moves by itself in time — a round
379
+ * that Adminium decides at the create must be given back by something.
380
+ */
381
+ export function postingFitIssues(posting, ledger, facts) {
382
+ const out = [];
383
+ const action = ledger.actions[posting.into.action];
384
+ if (action === undefined) {
385
+ out.push({ path: ['into', 'action'], message: `the ledger "${ledger.id}" has no action "${posting.into.action}": it has ${Object.keys(ledger.actions).map((name) => `"${name}"`).join(', ')}` });
386
+ return out;
387
+ }
388
+ for (const [input, type] of Object.entries(action.inputs)) {
389
+ const decided = (action.decides ?? []).some((rule) => rule.input === input);
390
+ if (posting.map[input] === undefined && !optionalInput(type) && !decided) {
391
+ out.push({ path: ['map'], message: `the action "${posting.into.action}" needs "${input}" (${type}): map it to a column, {"row": true}, {"parent": …}, {"setting": …} or {"value": …}` });
392
+ }
393
+ }
394
+ for (const input of Object.keys(posting.map)) {
395
+ if (action.inputs[input] === undefined)
396
+ out.push({ path: ['map', input], message: `the action "${posting.into.action}" takes no input "${input}"` });
397
+ }
398
+ for (const phase of POSTING_PHASES) {
399
+ if (posting[phase] !== undefined && !action.phases.includes(phase)) {
400
+ out.push({ path: [phase], message: `the action "${posting.into.action}" has no "${phase}" phase` });
401
+ }
402
+ }
403
+ if (action.holds === true) {
404
+ if (posting.reverse === undefined)
405
+ out.push({ path: ['reverse'], message: `the action "${posting.into.action}" holds: say when the hold is given back ("reverse")` });
406
+ if (posting.reserve !== undefined && posting.heldUntil === undefined)
407
+ out.push({ path: ['heldUntil'], message: `the action "${posting.into.action}" holds: say until when ("heldUntil")` });
408
+ }
409
+ if (action.decides !== undefined) {
410
+ const onCreate = [posting.reserve, posting.post].some((phase) => phase !== undefined && 'create' in phase.on);
411
+ if (onCreate && posting.reverse === undefined)
412
+ out.push({ path: ['reverse'], message: 'an amount Adminium decides at the create is given back by something: declare "reverse"' });
413
+ if (onCreate && posting.heldUntil === undefined && !facts.timed) {
414
+ out.push({ path: ['heldUntil'], message: 'an amount Adminium decides at the create needs an end: a timed move of the row\'s state that reaches the reverse point, or "heldUntil"' });
415
+ }
416
+ // An input another input caps is read from the parent's balance.
417
+ for (const rule of action.decides) {
418
+ if ('input' in rule.max && posting.map[rule.max.input] === undefined) {
419
+ out.push({ path: ['map'], message: `"${rule.input}" is decided up to "${rule.max.input}": map "${rule.max.input}" to what is still due ({"parent": <the balance column>})` });
420
+ }
421
+ }
422
+ }
423
+ return out;
424
+ }
425
+ // ── the receipt table ────────────────────────────────────────────────────────
426
+ /** The columns of a receipt table Adminium keys by, in key order. */
427
+ export const RECEIPT_KEY = ['source_table', 'source_row', 'source_line', 'posting', 'phase', 'round'];
428
+ /** Every column of a receipt table, with the type it must have. `held_until` is the one that may be empty. */
429
+ export const RECEIPT_COLUMNS = {
430
+ source_table: { type: ['text'], tableRef: true },
431
+ source_row: { type: ['text'] },
432
+ source_line: { type: ['text'] },
433
+ line_table: { type: ['text'], tableRef: true },
434
+ ledger: { type: ['text'] },
435
+ action: { type: ['text'] },
436
+ posting: { type: ['text'] },
437
+ phase: { type: ['enum'], enum: POSTING_PHASES },
438
+ round: { type: ['int'] },
439
+ state: { type: ['enum'], enum: ['planned', 'unplanned'] },
440
+ rows: { type: ['int'] },
441
+ add_on_version: { type: ['text'] },
442
+ origin: { type: ['enum'], enum: ['staff', 'public', 'system'] },
443
+ by: { type: ['text'] },
444
+ at: { type: ['timestamptz'] },
445
+ held_until: { type: ['timestamptz'], nullable: true },
446
+ };
447
+ /**
448
+ * Whether a table is a receipt table as Adminium writes one: exactly the
449
+ * columns of `RECEIPT_COLUMNS` beside its key, each of its type, none but
450
+ * `held_until` nullable, and a bounded width on every text column of the key.
451
+ */
452
+ export function receiptTableIssues(table, at) {
453
+ const out = [];
454
+ const columns = new Map(table.columns.map((column) => [column.ref, column]));
455
+ for (const [name, want] of Object.entries(RECEIPT_COLUMNS)) {
456
+ const column = columns.get(name);
457
+ if (column === undefined) {
458
+ out.push({ path: at('columns'), message: `a receipt table has a column "${name}" (${want.type.join(' or ')})` });
459
+ continue;
460
+ }
461
+ const c = table.columns.indexOf(column);
462
+ if (!want.type.includes(column.type))
463
+ out.push({ path: at('columns', c, 'type'), message: `"${table.ref}.${name}" is ${want.type.join(' or ')}, not ${column.type}` });
464
+ if (want.enum !== undefined && (column.enum === undefined || want.enum.some((value) => !column.enum.includes(value)) || column.enum.length !== want.enum.length)) {
465
+ out.push({ path: at('columns', c, 'enum'), message: `"${table.ref}.${name}" takes exactly ${want.enum.map((value) => `"${value}"`).join(', ')}` });
466
+ }
467
+ if ((column.nullable === true) !== (want.nullable === true)) {
468
+ out.push({ path: at('columns', c, 'nullable'), message: want.nullable === true ? `"${table.ref}.${name}" is empty while nothing is held: make it nullable` : `"${table.ref}.${name}" is part of every receipt: it is never nullable` });
469
+ }
470
+ if (column.type === 'text' && column.maxLength === undefined)
471
+ out.push({ path: at('columns', c, 'maxLength'), message: `"${table.ref}.${name}" needs a maxLength: Adminium indexes it` });
472
+ if (want.tableRef === true && column.rules?.tableRef !== true)
473
+ out.push({ path: at('columns', c, 'rules'), message: `"${table.ref}.${name}" holds a table's stored name: give it rules.tableRef` });
474
+ }
475
+ for (const column of table.columns) {
476
+ if (column.role === 'pk' || RECEIPT_COLUMNS[column.ref] !== undefined)
477
+ continue;
478
+ out.push({ path: at('columns', table.columns.indexOf(column)), message: `a receipt table holds what Adminium writes and nothing else: take out "${column.ref}"` });
479
+ }
480
+ return out;
481
+ }
482
+ export const LEDGER_ISSUE_CODES = [
483
+ 'LEDGER_OUT_OF_SCOPE',
484
+ 'LEDGER_WRITES_DECIDED',
485
+ 'LEDGER_UPDATE_KEY',
486
+ 'LEDGER_READ_CHAIN',
487
+ 'LEDGER_LOCK',
488
+ 'LEDGER_TABLE_GUARDED',
489
+ 'LEDGER_DECIDES_TYPE',
490
+ ];
491
+ /** The kinds of a table's limits, however it spells them (one rule, or a list). */
492
+ function capacityKinds(capacity) {
493
+ if (capacity === undefined || capacity === null)
494
+ return [];
495
+ const rules = Array.isArray(capacity) ? capacity : [capacity];
496
+ return rules.map((rule) => String(rule.kind ?? 'slot'));
497
+ }
498
+ /**
499
+ * Everything that would let a ledger reach outside what it declares: a table
500
+ * that is not the add-on's own, a written column Adminium decides, an update
501
+ * that could name more than one row, a read whose key comes from nowhere, a
502
+ * capped total nobody locks, a table another guard already rules, an amount
503
+ * decided that is no number.
504
+ *
505
+ * `addOn.scopes` grants nothing and refuses nothing at run time; a ledger is
506
+ * bounded by these checks and by the same ones made again on every answer.
507
+ */
508
+ export function ledgerIssues(m) {
509
+ const out = [];
510
+ const tables = new Map(m.tables.map((table) => [table.ref, table]));
511
+ const column = (table, ref) => table?.columns.find((candidate) => candidate.ref === ref);
512
+ const settings = m.settingsTable === undefined ? undefined : tables.get(m.settingsTable);
513
+ /** The tables whose capped total a row of `ref` feeds: the parent, with the total's column. */
514
+ const cappedParents = (ref) => m.tables.filter((parent) => parent.columns.some((candidate) => {
515
+ const rollup = candidate.rules?.rollup;
516
+ if (rollup?.from !== ref)
517
+ return false;
518
+ // Capped itself, or taken away in a balance another capped total keeps.
519
+ return rollup.cap === true || parent.columns.some((other) => other.rules?.rollup?.cap === true && other.rules.rollup.balance?.minus?.includes(candidate.ref) === true);
520
+ }));
521
+ m.ledgers.forEach((ledger, l) => {
522
+ const at = (...rest) => ['addOn', 'ledgers', l, ...rest];
523
+ const outside = (ref, path, what) => {
524
+ if (tables.has(ref))
525
+ return false;
526
+ out.push({ code: 'LEDGER_OUT_OF_SCOPE', path, message: `${what} "${ref}" is not one of this add-on's own tables: a ledger reads and writes its own tables and no others` });
527
+ return true;
528
+ };
529
+ // Rule 1 (the receipt table), and rule 6: a ledger table is ruled by its ledger alone.
530
+ outside(ledger.receipts, at('receipts'), 'the receipt table');
531
+ for (const [ref, scope] of Object.entries(ledger.writes)) {
532
+ const path = at('writes', ref);
533
+ if (outside(ref, path, 'the written table'))
534
+ continue;
535
+ const table = tables.get(ref);
536
+ if (ref === ledger.receipts)
537
+ out.push({ code: 'LEDGER_OUT_OF_SCOPE', path, message: `"${ref}" is the receipt table, which Adminium alone writes: take it out of "writes"` });
538
+ if (table.booking !== undefined)
539
+ out.push({ code: 'LEDGER_TABLE_GUARDED', path, message: `"${ref}" carries a booking rule, so it cannot be a ledger table` });
540
+ const kinds = capacityKinds(table.capacity).filter((kind) => kind === 'slot' || kind === 'night');
541
+ if (kinds.length > 0)
542
+ out.push({ code: 'LEDGER_TABLE_GUARDED', path, message: `"${ref}" carries a ${kinds[0]} limit, so it cannot be a ledger table (a parent limit may stay)` });
543
+ const gapless = table.columns.find((candidate) => candidate.rules?.sequence?.gapless === true);
544
+ if (gapless !== undefined)
545
+ out.push({ code: 'LEDGER_TABLE_GUARDED', path, message: `"${ref}.${gapless.ref}" is numbered without gaps, so "${ref}" cannot be a ledger table` });
546
+ // A row an answer adds is linked to the receipt of the call that added it: Adminium fills the link, the table declares it.
547
+ if (scope.insert !== undefined) {
548
+ const link = column(table, 'receipt_id');
549
+ if (link === undefined || link.nullable !== true) {
550
+ out.push({ code: 'LEDGER_TABLE_GUARDED', path: [...path, 'insert'], message: `"${ref}" takes rows an answer adds, so it carries "receipt_id": a link to "${ledger.receipts}" that may be empty` });
551
+ }
552
+ }
553
+ // Rule 2: what an answer may write is never what Adminium decides.
554
+ const decided = (ref2) => {
555
+ const found = column(table, ref2);
556
+ if (found === undefined)
557
+ return `"${ref}" has no column "${ref2}"`;
558
+ if (found.role === 'pk')
559
+ return `"${ref}.${ref2}" is the table's key`;
560
+ if (ref2 === 'receipt_id')
561
+ return `"${ref}.receipt_id" links a row to its receipt, which Adminium fills`;
562
+ const rules = found.rules;
563
+ for (const rule of ['rollup', 'formula', 'stamp', 'sequence', 'code', 'copy'])
564
+ if (rules?.[rule] !== undefined)
565
+ return `"${ref}.${ref2}" is decided by its ${rule} rule`;
566
+ if (table.columns.some((other) => other.rules?.rollup?.balance?.column === ref2))
567
+ return `"${ref}.${ref2}" is a balance Adminium keeps`;
568
+ return null;
569
+ };
570
+ (scope.insert ?? []).forEach((ref2, i) => {
571
+ const why = decided(ref2);
572
+ if (why !== null)
573
+ out.push({ code: 'LEDGER_WRITES_DECIDED', path: [...path, 'insert', i], message: `${why}: an answer never writes it` });
574
+ });
575
+ (scope.update?.set ?? []).forEach((ref2, i) => {
576
+ const why = decided(ref2);
577
+ if (why !== null)
578
+ out.push({ code: 'LEDGER_WRITES_DECIDED', path: [...path, 'update', 'set', i], message: `${why}: an answer never writes it` });
579
+ });
580
+ // Rule 3: an update names one row — by the table's key, or by one of its unique sets.
581
+ if (scope.update !== undefined) {
582
+ const by = [...scope.update.by].sort().join('\u0000');
583
+ const key = table.columns.filter((candidate) => candidate.role === 'pk').map((candidate) => candidate.ref);
584
+ const sets = [key, ...table.columns.filter((candidate) => candidate.unique === true).map((candidate) => [candidate.ref]), ...(table.unique ?? []).map((set) => [...set])];
585
+ if (!sets.some((set) => set.length > 0 && [...set].sort().join('\u0000') === by)) {
586
+ out.push({ code: 'LEDGER_UPDATE_KEY', path: [...path, 'update', 'by'], message: `an update names one row of "${ref}": by its key (${key.join(', ') || 'none'}) or by one of its unique sets, not by ${scope.update.by.join(', ')}` });
587
+ }
588
+ }
589
+ }
590
+ for (const [name, action] of Object.entries(ledger.actions)) {
591
+ const here = (...rest) => at('actions', name, ...rest);
592
+ const written = action.writes ?? Object.keys(ledger.writes);
593
+ (action.writes ?? []).forEach((ref, i) => {
594
+ if (ledger.writes[ref] === undefined)
595
+ out.push({ code: 'LEDGER_OUT_OF_SCOPE', path: here('writes', i), message: `the action "${name}" writes "${ref}", which the ledger's "writes" does not list` });
596
+ });
597
+ // Rule 4: every key of a read comes from something known before it.
598
+ const readTables = new Map();
599
+ /** How many reads deep a read is: one more than the deepest read it takes a key from. */
600
+ const depth = new Map();
601
+ action.reads.forEach((read, r) => {
602
+ const path = here('reads', r);
603
+ const table = outside(read.table, [...path, 'table'], 'the read table') ? undefined : tables.get(read.table);
604
+ if (read.by.length === 0)
605
+ out.push({ code: 'LEDGER_READ_CHAIN', path: [...path, 'by'], message: `the read "${read.as}" names no key: a ledger read finds its rows by at least one column` });
606
+ let deepest = 0;
607
+ read.by.forEach((by, b) => {
608
+ const at2 = [...path, 'by', b];
609
+ if (table !== undefined && column(table, by.column) === undefined)
610
+ out.push({ code: 'LEDGER_READ_CHAIN', path: [...at2, 'column'], message: `"${read.table}" has no column "${by.column}"` });
611
+ for (const from of Array.isArray(by.from) ? by.from : [by.from]) {
612
+ const [head, name2, part] = from.split('.');
613
+ if (head === 'input') {
614
+ const type = action.inputs[name2];
615
+ if (type === undefined)
616
+ out.push({ code: 'LEDGER_READ_CHAIN', path: [...at2, 'from'], message: `"${from}": the action "${name}" takes no input "${name2}"` });
617
+ else if ((part !== undefined) !== (type === 'rowRef')) {
618
+ out.push({ code: 'LEDGER_READ_CHAIN', path: [...at2, 'from'], message: type === 'rowRef' ? `"${name2}" is a row of any table: read it as "input.${name2}.table" or "input.${name2}.row"` : `"${name2}" is ${type}: read it as "input.${name2}"` });
619
+ }
620
+ }
621
+ else if (head === 'source' || head === 'uses') {
622
+ // The fixed words: the source row, and what a price question recorded.
623
+ }
624
+ else if (head === 'receipt') {
625
+ if (!action.phases.includes('reverse'))
626
+ out.push({ code: 'LEDGER_READ_CHAIN', path: [...at2, 'from'], message: `"receipt.id" is the round being given back: the action "${name}" has no "reverse" phase` });
627
+ }
628
+ else if (head === 'setting') {
629
+ if (settings === undefined)
630
+ out.push({ code: 'LEDGER_READ_CHAIN', path: [...at2, 'from'], message: `"${from}": this add-on declares no settings table (addOn.settingsTable)` });
631
+ else if (column(settings, name2) === undefined)
632
+ out.push({ code: 'LEDGER_READ_CHAIN', path: [...at2, 'from'], message: `"${from}": "${settings.ref}" has no column "${name2}"` });
633
+ }
634
+ else if (!readTables.has(head)) {
635
+ out.push({ code: 'LEDGER_READ_CHAIN', path: [...at2, 'from'], message: `"${from}": no read named "${head}" comes before "${read.as}"` });
636
+ }
637
+ else {
638
+ const earlier = readTables.get(head);
639
+ if (earlier !== undefined && column(earlier, name2) === undefined)
640
+ out.push({ code: 'LEDGER_READ_CHAIN', path: [...at2, 'from'], message: `"${from}": "${earlier.ref}" has no column "${name2}"` });
641
+ deepest = Math.max(deepest, depth.get(head) ?? 1);
642
+ }
643
+ }
644
+ });
645
+ for (const [w, condition] of (read.where ?? []).entries()) {
646
+ if (table !== undefined && column(table, condition.column) === undefined)
647
+ out.push({ code: 'LEDGER_READ_CHAIN', path: [...path, 'where', w, 'column'], message: `"${read.table}" has no column "${condition.column}"` });
648
+ }
649
+ if (deepest + 1 > 3)
650
+ out.push({ code: 'LEDGER_READ_CHAIN', path, message: `the read "${read.as}" is four reads deep: a chain of reads is at most three` });
651
+ readTables.set(read.as, table);
652
+ depth.set(read.as, deepest + 1);
653
+ });
654
+ const allow = action.unavailable?.allow;
655
+ if (allow !== undefined) {
656
+ const read = readTables.get(allow.read);
657
+ const flag = column(read, allow.column);
658
+ if (!readTables.has(allow.read))
659
+ out.push({ code: 'LEDGER_READ_CHAIN', path: here('unavailable', 'allow', 'read'), message: `the action "${name}" has no read "${allow.read}"` });
660
+ else if (read !== undefined && (flag === undefined || !(flag.type === 'bool' || flag.type === 'int') || flag.nullable === true)) {
661
+ out.push({ code: 'LEDGER_READ_CHAIN', path: here('unavailable', 'allow', 'column'), message: `"${read.ref}.${allow.column}" says yes or no for every row: a bool, or a whole number 0 or 1, that is not nullable` });
662
+ }
663
+ }
664
+ // Rule 5: what a lock is named by, and every capped total the action's rows feed.
665
+ const locked = new Set();
666
+ action.locks.forEach((lock, k) => {
667
+ const path = here('locks', k);
668
+ if (!readTables.has(lock.read)) {
669
+ out.push({ code: 'LEDGER_LOCK', path: [...path, 'read'], message: `the action "${name}" has no read "${lock.read}" to name a lock by` });
670
+ }
671
+ else {
672
+ const read = readTables.get(lock.read);
673
+ if (read !== undefined && column(read, lock.column) === undefined)
674
+ out.push({ code: 'LEDGER_LOCK', path: [...path, 'column'], message: `"${read.ref}" has no column "${lock.column}"` });
675
+ }
676
+ if (!outside(lock.table, [...path, 'table'], 'the locked table'))
677
+ locked.add(lock.table);
678
+ });
679
+ for (const ref of written) {
680
+ if (!tables.has(ref))
681
+ continue;
682
+ for (const parent of cappedParents(ref)) {
683
+ // The parent itself, or a table it always belongs to (an item stands for all of its levels).
684
+ const stands = [parent.ref, ...parent.columns.filter((candidate) => candidate.type === 'fk' && candidate.nullable !== true && candidate.references !== undefined).map((candidate) => candidate.references)];
685
+ if (stands.some((candidate) => locked.has(candidate)))
686
+ continue;
687
+ out.push({ code: 'LEDGER_LOCK', path: here('locks'), message: `the action "${name}" writes "${ref}", whose rows a capped total of "${parent.ref}" adds up: lock "${parent.ref}"${stands.length > 1 ? ` (or ${stands.slice(1).map((candidate) => `"${candidate}"`).join(', ')}, which it always belongs to)` : ''}` });
688
+ }
689
+ }
690
+ // Rule 7: an amount Adminium decides is a number, between zero and a number it reads.
691
+ (action.decides ?? []).forEach((rule, d) => {
692
+ const path = here('decides', d);
693
+ const numeric = (type) => type === 'decimal' || type === 'number';
694
+ const type = action.inputs[rule.input];
695
+ if (!numeric(type))
696
+ out.push({ code: 'LEDGER_DECIDES_TYPE', path: [...path, 'input'], message: type === undefined ? `the action "${name}" takes no input "${rule.input}"` : `"${rule.input}" is ${type}: an amount Adminium decides is a decimal or a number` });
697
+ if ('input' in rule.max) {
698
+ const max = action.inputs[rule.max.input];
699
+ if (max === undefined || !['decimal', 'decimal?', 'number', 'number?'].includes(max))
700
+ out.push({ code: 'LEDGER_DECIDES_TYPE', path: [...path, 'max', 'input'], message: `the ceiling "${rule.max.input}" is not a decimal or a number input of the action "${name}"` });
701
+ }
702
+ else if (!readTables.has(rule.max.read)) {
703
+ out.push({ code: 'LEDGER_DECIDES_TYPE', path: [...path, 'max', 'read'], message: `the action "${name}" has no read "${rule.max.read}" to take a ceiling from` });
704
+ }
705
+ else {
706
+ const read = readTables.get(rule.max.read);
707
+ const ceiling = column(read, rule.max.column);
708
+ if (read !== undefined && (ceiling === undefined || !['decimal', 'money', 'int', 'bigint', 'float'].includes(ceiling.type))) {
709
+ out.push({ code: 'LEDGER_DECIDES_TYPE', path: [...path, 'max', 'column'], message: `"${read.ref}.${rule.max.column}" is not a number to take a ceiling from` });
710
+ }
711
+ }
712
+ });
713
+ }
714
+ });
715
+ return out;
716
+ }
717
+ // ── stock words ──────────────────────────────────────────────────────────────
718
+ /**
719
+ * A question asked of a ledger's action with nothing written: one line per
720
+ * row asked about, a quantity of one, answered `in`, `low` or `out`. `input`
721
+ * is the action's input that takes the row asked about; `showLeftBelow` names
722
+ * the settings column holding the owner's "show how many are left below".
723
+ */
724
+ export const wordsSchema = z
725
+ .object({
726
+ id: kebab,
727
+ ledger: kebab,
728
+ action: kebab,
729
+ input: inputName,
730
+ showLeftBelow: z.object({ setting: refSchema }).strict().optional(),
731
+ })
732
+ .strict();
733
+ export const wordsListSchema = z
734
+ .array(wordsSchema)
735
+ .min(1)
736
+ .max(4)
737
+ .refine((words) => new Set(words.map((one) => one.id)).size === words.length, { message: 'two words share an id' });
738
+ /** The most rows one words question asks about. */
739
+ export const WORDS_IDS_MAX = 60;
740
+ /** Everything wrong with an add-on's stock words that its own manifest can see. */
741
+ export function wordsIssues(m) {
742
+ const out = [];
743
+ const settings = m.settingsTable === undefined ? undefined : m.tables.find((table) => table.ref === m.settingsTable);
744
+ m.words.forEach((words, w) => {
745
+ const at = (...rest) => ['addOn', 'words', w, ...rest];
746
+ const ledger = m.ledgers.find((candidate) => candidate.id === words.ledger);
747
+ if (ledger === undefined) {
748
+ out.push({ path: at('ledger'), message: `this add-on declares no ledger "${words.ledger}"` });
749
+ return;
750
+ }
751
+ const action = ledger.actions[words.action];
752
+ if (action === undefined) {
753
+ out.push({ path: at('action'), message: `the ledger "${ledger.id}" has no action "${words.action}"` });
754
+ return;
755
+ }
756
+ // The question is "what if one were taken now": the action's own taking phase, with a quantity of one.
757
+ if (!action.phases.includes('post'))
758
+ out.push({ path: at('action'), message: `words ask what a "post" of the action would do, and "${words.action}" has no "post" phase` });
759
+ if (action.inputs['quantity'] === undefined)
760
+ out.push({ path: at('action'), message: `words ask about a quantity of one, and "${words.action}" takes no input "quantity"` });
761
+ const input = action.inputs[words.input];
762
+ if (input === undefined)
763
+ out.push({ path: at('input'), message: `the action "${words.action}" takes no input "${words.input}"` });
764
+ else if (input !== 'rowRef' && input !== 'link')
765
+ out.push({ path: at('input'), message: `words name the input that takes the row asked about: a row of any table (rowRef) or a link, and "${words.input}" is ${input}` });
766
+ if (words.showLeftBelow !== undefined) {
767
+ const column = settings?.columns.find((candidate) => candidate.ref === words.showLeftBelow.setting);
768
+ if (settings === undefined)
769
+ out.push({ path: at('showLeftBelow', 'setting'), message: 'this add-on declares no settings table (addOn.settingsTable) to keep the number in' });
770
+ else if (column === undefined)
771
+ out.push({ path: at('showLeftBelow', 'setting'), message: `"${settings.ref}" has no column "${words.showLeftBelow.setting}"` });
772
+ else if (!['int', 'bigint', 'decimal'].includes(column.type))
773
+ out.push({ path: at('showLeftBelow', 'setting'), message: `"${settings.ref}.${column.ref}" holds a number: how many may be left before the figure is shown` });
774
+ }
775
+ });
776
+ return out;
777
+ }
778
+ //# sourceMappingURL=ledgers.js.map