@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/states.js CHANGED
@@ -31,7 +31,9 @@
31
31
  * stamps) keep being written by Adminium.
32
32
  * - `children`: child tables tied to this row's state. `lock` refuses their
33
33
  * writes while the row is locked; `parentIn` allows them only while the row
34
- * is in those states (payments on a sent invoice); `clearOnCreate` empties
34
+ * is in those states (payments on a sent invoice) — or, apart, `createIn`
35
+ * for a new child and `changeIn` for a change or a delete of one (a payment
36
+ * taken only on a live stay, and voided on a cancelled one too); `clearOnCreate` empties
35
37
  * columns of this row when one of them is created (a recorded payment
36
38
  * clears the client's "I've sent it"). `release` opens a locked child a
37
39
  * little while its parent is in some of the lock's states: a change that
@@ -47,12 +49,28 @@
47
49
  * - `noDelete`: rows in these states, or any row with a number
48
50
  * (`"numbered"`), are never deleted — voided instead.
49
51
  * - `onlyLater`: date columns that may move later and never earlier.
52
+ * - A move may also wait for the row one of its links points at
53
+ * (`requires.linked`), for a window on the clock (`requires.time`, read from
54
+ * moments — see `refs.ts`) and for a value of the settings row
55
+ * (`requires.setting`).
56
+ * - `strict`: a write naming the state the row already holds is refused
57
+ * (a ticket let in once), repeating up to four columns of the row.
58
+ * - `late`: a move made inside some time before a moment sets a flag, or is
59
+ * refused.
60
+ * - `timed`: listed moves Adminium makes by itself once a moment of the row
61
+ * has passed (a held order expiring).
62
+ * - `effects`: a move that moves the row one of its links points at too, by a
63
+ * move that table lists (a guest checked out turns the room to cleaning).
64
+ * - `create`: what a new row must meet to be created — the conditions a move
65
+ * waits for, judged when the row is written (a check-in only for a paid
66
+ * ticket, on its day).
67
+ * - A timed move may also write fixed values to other columns (`set`).
50
68
  *
51
69
  * Adding sample data and importing past records are history: they write any
52
70
  * state their rows were in.
53
71
  */
54
72
  import { z } from 'zod';
55
- import { refSchema, scalarSchema, valueFits } from './refs.js';
73
+ import { momentIssues, momentOffsetSchema, momentSchema, refSchema, scalarSchema, valueFits, } from './refs.js';
56
74
  const stateName = z.string().min(1).max(64);
57
75
  const roleKey = z.string().regex(/^[a-z][a-z0-9-]*$/, 'a role key');
58
76
  /** A condition on the row itself, checked in the same statement that changes it. */
@@ -71,21 +89,57 @@ export const stateConditionSchema = z
71
89
  .refine((c) => [c.eq, c.in, c.isNull, c.gt, c.gte, c.lt, c.lte].filter((part) => part !== undefined).length === 1, {
72
90
  message: 'a condition says one of eq, in, isNull, gt, gte, lt or lte',
73
91
  });
92
+ /** Conditions on the row one of this row's links points at (the order a ticket belongs to is paid). */
93
+ export const linkedConditionSchema = z
94
+ .object({
95
+ /** This row's foreign key. */
96
+ via: refSchema,
97
+ where: z.array(stateConditionSchema).min(1).max(8),
98
+ })
99
+ .strict();
100
+ /** A move allowed only after one moment, before another, or between the two. */
101
+ export const timeConditionSchema = z
102
+ .object({ after: momentSchema.optional(), before: momentSchema.optional() })
103
+ .strict()
104
+ .refine((t) => t.after !== undefined || t.before !== undefined, { message: 'a time condition says after, before or both' });
105
+ /** A value of the app's one-row settings table a move waits for (door sales switched on). */
106
+ export const settingConditionSchema = z.object({ table: refSchema, column: refSchema, eq: scalarSchema }).strict();
74
107
  export const stateMoveSchema = z.union([
75
108
  stateName,
76
109
  z
77
110
  .object({
78
111
  to: stateName,
79
- /** What must be true first: at least n child rows of a table, and conditions on the row. */
112
+ /**
113
+ * What must be true first: at least n child rows of a table, conditions
114
+ * on the row, on the rows its links point at, on the settings row, and
115
+ * a window in time.
116
+ */
80
117
  requires: z
81
118
  .object({
82
119
  children: z.record(refSchema, z.number().int().min(1).max(1000)).optional(),
83
120
  where: z.array(stateConditionSchema).min(1).max(8).optional(),
121
+ linked: z.array(linkedConditionSchema).min(1).max(4).optional(),
122
+ time: timeConditionSchema.optional(),
123
+ setting: z.array(settingConditionSchema).min(1).max(4).optional(),
84
124
  })
85
125
  .strict()
86
126
  .optional(),
87
127
  /** Only people holding one of these app roles may make this move. */
88
128
  roles: z.array(roleKey).min(1).max(8).optional(),
129
+ /**
130
+ * The move takes back the listed move the other way (ready → preparing
131
+ * after preparing → ready): made only by a write that names the state
132
+ * it saw the row in, judged on the row as it stands, and emptying the
133
+ * stamps the move it takes back wrote (`stamp.clearOnBack`) while the
134
+ * stamps of the state it returns to keep what they had.
135
+ */
136
+ undo: z.literal(true).optional(),
137
+ /**
138
+ * An undo's further columns it empties, beside its stamps (how a hand-over
139
+ * taken back was paid): emptied by the move, and open to the table's lock
140
+ * for that move only. Only on a move marked `undo`.
141
+ */
142
+ clears: z.array(refSchema).min(1).max(8).optional(),
89
143
  })
90
144
  .strict(),
91
145
  ]);
@@ -94,7 +148,12 @@ export const stateChildSchema = z
94
148
  /** The child's foreign key to this row. */
95
149
  via: refSchema,
96
150
  lock: z.literal(true).optional(),
151
+ /** Written only while this row is in one of these states: `createIn` and `changeIn` together. */
97
152
  parentIn: z.array(stateName).min(1).max(16).optional(),
153
+ /** Added only while this row is in one of these states (a payment taken on a stay still booked or in house). */
154
+ createIn: z.array(stateName).min(1).max(16).optional(),
155
+ /** Changed or deleted only while this row is in one of these states (a payment voided on a cancelled stay too). */
156
+ changeIn: z.array(stateName).min(1).max(16).optional(),
98
157
  clearOnCreate: z.array(refSchema).min(1).max(8).optional(),
99
158
  /** While this row is in one of `when`, a locked child may still EMPTY these columns of its own, and change nothing else. */
100
159
  release: z.object({ when: z.array(stateName).min(1).max(16), columns: z.array(refSchema).min(1).max(8) }).strict().optional(),
@@ -102,13 +161,142 @@ export const stateChildSchema = z
102
161
  lockLinked: z.record(refSchema, z.array(refSchema).min(1).max(16)).optional(),
103
162
  })
104
163
  .strict()
105
- .refine((c) => c.lock === undefined || c.parentIn === undefined, {
164
+ .refine((c) => c.lock === undefined || (c.parentIn === undefined && c.createIn === undefined && c.changeIn === undefined), {
106
165
  message: 'a child is locked with its parent, or writable only in some of its states — not both',
166
+ })
167
+ .refine((c) => c.parentIn === undefined || (c.createIn === undefined && c.changeIn === undefined), {
168
+ message: 'parentIn says createIn and changeIn at once: say parentIn, or createIn and changeIn, not both',
107
169
  })
108
170
  .refine((c) => c.release === undefined || c.lock === true, { message: 'a child is released only from its parent\'s lock (lock: true)' })
109
171
  .refine((c) => c.lockLinked === undefined || (Object.keys(c.lockLinked).length >= 1 && Object.keys(c.lockLinked).length <= 8), {
110
172
  message: 'lockLinked names 1 to 8 link columns',
111
173
  });
174
+ // ── once means once, late moves, timed moves, moves of a linked row ─────────
175
+ /**
176
+ * `strict`: a write that names the state the row already holds is refused
177
+ * instead of passing silently (a ticket scanned twice). `show` names up to
178
+ * four columns of the row the refusal repeats (the door it came in by);
179
+ * never a personal or secret column.
180
+ */
181
+ export const strictStatesSchema = z.union([
182
+ z.literal(true),
183
+ z.object({ show: z.array(refSchema).min(1).max(4) }).strict(),
184
+ ]);
185
+ /**
186
+ * A move to `to` (from one of `from`, or from anywhere) made inside `within`
187
+ * before `moment`: `flag` lets it through and sets the bool column `flag`;
188
+ * `refuse` turns it away — for public writers, or with `refuse: 'everyone'`
189
+ * for every writer.
190
+ */
191
+ export const lateMoveSchema = z
192
+ .object({
193
+ to: stateName,
194
+ from: z.array(stateName).min(1).max(16).optional(),
195
+ moment: momentSchema,
196
+ within: momentOffsetSchema,
197
+ mode: z.enum(['flag', 'refuse']),
198
+ flag: refSchema.optional(),
199
+ refuse: z.enum(['public', 'everyone']).optional(),
200
+ })
201
+ .strict()
202
+ .refine((l) => (l.mode === 'flag') === (l.flag !== undefined), {
203
+ message: 'a flag names its column, and only mode "flag" has one',
204
+ path: ['flag'],
205
+ })
206
+ .refine((l) => l.refuse === undefined || l.mode === 'refuse', {
207
+ message: 'who is refused is said only by mode "refuse"',
208
+ path: ['refuse'],
209
+ });
210
+ /**
211
+ * A listed move Adminium makes by itself once `at` has passed, for a row still
212
+ * in `from`. `set` writes fixed values to other columns of the row in the same
213
+ * move (why an order still open at closing was cancelled).
214
+ */
215
+ export const timedMoveSchema = z
216
+ .object({
217
+ from: stateName,
218
+ to: stateName,
219
+ at: momentSchema,
220
+ set: z
221
+ .record(refSchema, z.union([scalarSchema, z.null()]))
222
+ .refine((set) => Object.keys(set).length >= 1 && Object.keys(set).length <= 8, { message: 'a timed move sets 1 to 8 columns' })
223
+ .optional(),
224
+ })
225
+ .strict();
226
+ /**
227
+ * What a row must meet to be created at all: a value of its own
228
+ * (`where`), of the row one of its links points at (`linked`), of the
229
+ * settings row (`setting`), and a window on the clock (`time`) — the same
230
+ * shapes a move waits for. A check-in recorded only for a paid ticket, on
231
+ * its day, from half an hour before the doors.
232
+ */
233
+ export const createRequiresSchema = z
234
+ .object({
235
+ requires: z
236
+ .object({
237
+ where: z.array(stateConditionSchema).min(1).max(8).optional(),
238
+ linked: z.array(linkedConditionSchema).min(1).max(4).optional(),
239
+ time: timeConditionSchema.optional(),
240
+ setting: z.array(settingConditionSchema).min(1).max(4).optional(),
241
+ })
242
+ .strict()
243
+ .refine((r) => [r.where, r.linked, r.time, r.setting].some((part) => part !== undefined), {
244
+ message: 'a create requires where, linked, time or setting',
245
+ }),
246
+ })
247
+ .strict();
248
+ /** The one column an effect sets on the linked row (that table's state), and the state it moves to. */
249
+ const effectSetSchema = z
250
+ .record(refSchema, stateName)
251
+ .refine((set) => Object.keys(set).length === 1, { message: 'an effect sets one column: the linked table\'s state' });
252
+ /**
253
+ * When this row moves to `on.to`, the row its link `via` points at moves too,
254
+ * in the same write: `set` names that table's state column and the state it
255
+ * moves to, one of the moves that table lists (a guest checked out turns the
256
+ * room to cleaning). One link, never a chain.
257
+ */
258
+ export const moveEffectSchema = z
259
+ .object({
260
+ on: z.object({ to: stateName }).strict(),
261
+ via: refSchema,
262
+ set: effectSetSchema,
263
+ })
264
+ .strict();
265
+ /**
266
+ * When the link `on.change` really changes — while this row is in one of
267
+ * `on.in` before and after the write — the row it pointed at moves by `old`
268
+ * and the row it now points at by `new`, in the same write (a guest moved to
269
+ * another room: the old room to cleaning, the new one to occupied, which only
270
+ * a ready room may be). Each is that table's declared move; a new row already
271
+ * in `new`'s state refuses the write. Either side may be left out, and a link
272
+ * emptied or first set moves only the side there is.
273
+ */
274
+ export const changeEffectSchema = z
275
+ .object({
276
+ on: z.object({ change: refSchema, in: z.array(stateName).min(1).max(16).optional() }).strict(),
277
+ old: z.object({ set: effectSetSchema }).strict().optional(),
278
+ new: z.object({ set: effectSetSchema }).strict().optional(),
279
+ })
280
+ .strict()
281
+ .refine((e) => e.old !== undefined || e.new !== undefined, { message: 'an effect of a changed link moves the old row, the new one, or both' });
282
+ /** Whether an effect is set off by a changed link rather than by a move. */
283
+ export function isChangeEffect(effect) {
284
+ return 'change' in effect.on;
285
+ }
286
+ /**
287
+ * An effect, parsed by the schema of the form written (a move's, or a changed
288
+ * link's), so a mistake is named where it is: a union of the two would only
289
+ * say the input is invalid.
290
+ */
291
+ export const stateEffectSchema = z.custom().superRefine((value, ctx) => {
292
+ const on = typeof value === 'object' && value !== null ? value.on : undefined;
293
+ const change = typeof on === 'object' && on !== null && 'change' in on;
294
+ const parsed = (change ? changeEffectSchema : moveEffectSchema).safeParse(value);
295
+ if (parsed.success)
296
+ return;
297
+ for (const issue of parsed.error.issues)
298
+ ctx.addIssue({ ...issue, path: [...issue.path], continue: false });
299
+ });
112
300
  export const statesSchema = z
113
301
  .object({
114
302
  column: refSchema,
@@ -129,12 +317,76 @@ export const statesSchema = z
129
317
  .optional(),
130
318
  noDelete: z.object({ when: z.union([z.literal('numbered'), z.array(stateName).min(1).max(16)]) }).strict().optional(),
131
319
  onlyLater: z.array(refSchema).min(1).max(8).optional(),
320
+ /** A move to the state a row already holds is refused, naming when it got there (see {@link strictStatesSchema}). */
321
+ strict: strictStatesSchema.optional(),
322
+ /** A move made close to a moment sets a flag, or is refused (see {@link lateMoveSchema}). */
323
+ late: z.array(lateMoveSchema).min(1).max(4).optional(),
324
+ /** Moves Adminium makes on its own when a moment passes (see {@link timedMoveSchema}). */
325
+ timed: z.array(timedMoveSchema).min(1).max(8).optional(),
326
+ /** A move that moves the row one of its links points at too (see {@link stateEffectSchema}). */
327
+ effects: z.array(stateEffectSchema).min(1).max(4).optional(),
328
+ /** What a new row must meet to be created (see {@link createRequiresSchema}). */
329
+ create: createRequiresSchema.optional(),
132
330
  })
133
331
  .strict();
134
332
  /** A move written as a bare state is a move with nothing asked first. */
333
+ /**
334
+ * Whether a state is reached only by moves marked `undo`: made only by a
335
+ * person naming the state they saw — never by a door that names none (a
336
+ * timed move, an effect, an email's `onSent`, a guest's writable value).
337
+ */
338
+ export function reachedOnlyByUndo(states, state) {
339
+ if (states === undefined)
340
+ return false;
341
+ const moves = Object.values(states.moves).flatMap((list) => list.filter((move) => moveTarget(move) === String(state)));
342
+ return moves.length > 0 && moves.every((move) => typeof move === 'object' && move.undo === true);
343
+ }
135
344
  export function moveTarget(move) {
136
345
  return typeof move === 'string' ? move : move.to;
137
346
  }
347
+ /**
348
+ * Everything wrong with what a table's undo moves empty (`clears`), and with a
349
+ * move that another move to the same state comes before — the judge takes the
350
+ * first, so a later one marked `undo` or naming `clears` would never be made.
351
+ * Paths are relative to the states. The one judge for an app's manifest and a
352
+ * states rule saved in Studio (the server's `statesRuleIssue`).
353
+ */
354
+ export function undoMoveIssues(states, table, lookup) {
355
+ const out = [];
356
+ for (const [from, moves] of Object.entries(states.moves)) {
357
+ const marked = (candidate) => typeof candidate === 'object' && (candidate.undo === true || candidate.clears !== undefined);
358
+ moves.forEach((move, m) => {
359
+ const to = moveTarget(move);
360
+ // The judge takes the first move to a state: a second one, when either is an undo or empties columns, is never made.
361
+ const first = moves.slice(0, m).find((other) => moveTarget(other) === to);
362
+ if (first !== undefined && (marked(first) || marked(move))) {
363
+ out.push({ path: ['moves', from, m], message: `another move from "${from}" to "${to}" comes first, so this one is never made` });
364
+ }
365
+ if (typeof move === 'string')
366
+ return;
367
+ if (move.clears !== undefined && move.undo !== true) {
368
+ out.push({ path: ['moves', from, m, 'clears'], message: 'only a move marked undo empties columns as it goes' });
369
+ }
370
+ (move.clears ?? []).forEach((ref, c) => {
371
+ const path = ['moves', from, m, 'clears', c];
372
+ const found = lookup.column(ref);
373
+ if (found === undefined)
374
+ out.push({ path, message: `"${table}" has no column "${ref}"` });
375
+ else if (ref === states.column)
376
+ out.push({ path, message: 'the state moves by the move itself, not by what it empties' });
377
+ else if (found.key)
378
+ out.push({ path, message: `"${table}.${ref}" is the key, which never changes` });
379
+ else if (!found.nullable)
380
+ out.push({ path, message: `"${table}.${ref}" is not nullable, so an undo cannot empty it` });
381
+ else if (lookup.decided(ref))
382
+ out.push({ path, message: `"${table}.${ref}" is written by another rule already` });
383
+ if (move.clears.indexOf(ref) !== c)
384
+ out.push({ path, message: `"${ref}" is named twice` });
385
+ });
386
+ });
387
+ }
388
+ return out;
389
+ }
138
390
  /** Everything wrong with one table's `states` against the manifest's tables. */
139
391
  export function statesIssues(table, states, ctx, at) {
140
392
  const out = [];
@@ -178,8 +430,22 @@ export function statesIssues(table, states, ctx, at) {
178
430
  out.push({ path: at('moves', from, m, 'roles'), message: `"${role}" is not one of the app's roles` });
179
431
  }
180
432
  }
433
+ // An undo takes back a listed move: the one from where it goes to where it starts.
434
+ if (move.undo === true && !(states.moves[to] ?? []).some((back) => moveTarget(back) === from)) {
435
+ out.push({ path: at('moves', from, m, 'undo'), message: `no listed move goes from "${to}" to "${from}", so this move takes nothing back` });
436
+ }
181
437
  });
182
438
  }
439
+ // What an undo empties besides its stamps, and a move another to the same state hides: one judge with a states rule saved in Studio.
440
+ for (const issue of undoMoveIssues(states, table, {
441
+ column: (ref) => {
442
+ const found = index.column(table, ref);
443
+ return found === undefined ? undefined : { key: found.role === 'pk', nullable: found.nullable === true };
444
+ },
445
+ decided: (ref) => ctx.decided?.(ref) === true,
446
+ })) {
447
+ out.push({ path: at(...issue.path), message: issue.message });
448
+ }
183
449
  if (states.lock !== undefined) {
184
450
  states.lock.when.forEach((value, i) => known(value, at('lock', 'when', i)));
185
451
  for (const ref of states.lock.except ?? []) {
@@ -202,7 +468,8 @@ export function statesIssues(table, states, ctx, at) {
202
468
  out.push({ path: here('via'), message: `"${child}.${rule.via}" does not point at "${table}"` });
203
469
  if (rule.lock === true && states.lock === undefined)
204
470
  out.push({ path: here('lock'), message: 'a child is locked with its parent, and the parent has no lock' });
205
- (rule.parentIn ?? []).forEach((value, i) => known(value, here('parentIn', i)));
471
+ for (const key of ['parentIn', 'createIn', 'changeIn'])
472
+ (rule[key] ?? []).forEach((value, i) => known(value, here(key, i)));
206
473
  for (const ref of rule.clearOnCreate ?? []) {
207
474
  const found = index.column(table, ref);
208
475
  if (found === undefined)
@@ -254,7 +521,8 @@ export function statesIssues(table, states, ctx, at) {
254
521
  out.push({ path: here('lockLinked', link), message: `"${target}.${ref}" is the key, which never changes` });
255
522
  }
256
523
  }
257
- if (rule.lock === undefined && rule.parentIn === undefined && rule.clearOnCreate === undefined && rule.lockLinked === undefined) {
524
+ const tied = rule.parentIn !== undefined || rule.createIn !== undefined || rule.changeIn !== undefined;
525
+ if (rule.lock === undefined && !tied && rule.clearOnCreate === undefined && rule.lockLinked === undefined) {
258
526
  out.push({ path: here(), message: 'a child says lock, parentIn, clearOnCreate or lockLinked' });
259
527
  }
260
528
  }
@@ -286,9 +554,300 @@ export function statesIssues(table, states, ctx, at) {
286
554
  else if (found.type !== 'date' && found.type !== 'timestamptz')
287
555
  out.push({ path: at('onlyLater'), message: `"${table}.${ref}" is not a date` });
288
556
  }
557
+ out.push(...conditionedMoveIssues(table, states, ctx, at, values));
289
558
  return out;
290
559
  }
291
- function conditionIssues(table, condition, index, path) {
560
+ /**
561
+ * Everything wrong with the rules a move waits for or sets off: conditions on
562
+ * a linked row, the settings row and the clock; the once-means-once refusal;
563
+ * late moves; the moves Adminium makes when a moment passes; and the moves of
564
+ * a linked row a move sets off.
565
+ */
566
+ function conditionedMoveIssues(table, states, ctx, at, values) {
567
+ const out = [];
568
+ const { index } = ctx;
569
+ const known = (value, path) => {
570
+ if (values.length > 0 && !values.includes(value))
571
+ out.push({ path, message: `"${value}" is not a value of "${table}.${states.column}"` });
572
+ };
573
+ /** The listed move from `from` to `to`, when there is one. */
574
+ const listed = (from, to) => (states.moves[from] ?? []).find((move) => moveTarget(move) === to);
575
+ const reached = (to) => Object.values(states.moves).some((moves) => moves.some((move) => moveTarget(move) === to));
576
+ /** The table a link of this table points at, or an issue. */
577
+ const linkTarget = (via, path) => {
578
+ const found = index.column(table, via);
579
+ if (found === undefined) {
580
+ out.push({ path, message: `"${table}" has no column "${via}"` });
581
+ return undefined;
582
+ }
583
+ if (found.role === 'pk') {
584
+ out.push({ path, message: `"${table}.${via}" is the key, and points at no other row` });
585
+ return undefined;
586
+ }
587
+ if (found.type !== 'fk' || found.references === undefined || index.table(found.references) === undefined) {
588
+ out.push({ path, message: `"${table}.${via}" does not point at a table of this manifest` });
589
+ return undefined;
590
+ }
591
+ return found.references;
592
+ };
593
+ for (const [from, moves] of Object.entries(states.moves)) {
594
+ moves.forEach((move, m) => {
595
+ if (typeof move === 'string' || move.requires === undefined)
596
+ return;
597
+ const here = (...rest) => at('moves', from, m, 'requires', ...rest);
598
+ (move.requires.linked ?? []).forEach((linked, l) => {
599
+ const target = linkTarget(linked.via, here('linked', l, 'via'));
600
+ if (target === undefined)
601
+ return;
602
+ linked.where.forEach((condition, w) => out.push(...conditionIssues(target, condition, index, here('linked', l, 'where', w))));
603
+ });
604
+ const time = move.requires.time;
605
+ if (time?.after !== undefined)
606
+ out.push(...momentIssues(table, time.after, index, here('time', 'after')));
607
+ if (time?.before !== undefined)
608
+ out.push(...momentIssues(table, time.before, index, here('time', 'before')));
609
+ (move.requires.setting ?? []).forEach((setting, k) => {
610
+ const path = here('setting', k);
611
+ if (index.table(setting.table) === undefined) {
612
+ out.push({ path, message: `"${setting.table}" is not a table of this manifest` });
613
+ return;
614
+ }
615
+ const found = index.column(setting.table, setting.column);
616
+ if (found === undefined)
617
+ out.push({ path, message: `"${setting.table}" has no column "${setting.column}"` });
618
+ else if (!valueFits(found, setting.eq))
619
+ out.push({ path, message: `${JSON.stringify(setting.eq)} is not a value of "${setting.table}.${setting.column}"` });
620
+ });
621
+ });
622
+ }
623
+ if (typeof states.strict === 'object') {
624
+ states.strict.show.forEach((ref, i) => {
625
+ const path = at('strict', 'show', i);
626
+ if (index.column(table, ref) === undefined) {
627
+ out.push({ path, message: `"${table}" has no column "${ref}"` });
628
+ return;
629
+ }
630
+ const kept = ctx.keptFromReaders?.(ref) ?? null;
631
+ if (kept !== null)
632
+ out.push({ path, message: `"${table}.${ref}" is ${kept}, so a refusal never repeats it` });
633
+ });
634
+ }
635
+ (states.late ?? []).forEach((late, i) => {
636
+ const here = (...rest) => at('late', i, ...rest);
637
+ known(late.to, here('to'));
638
+ (late.from ?? []).forEach((from, f) => {
639
+ known(from, here('from', f));
640
+ if (listed(from, late.to) === undefined)
641
+ out.push({ path: here('from', f), message: `no listed move goes from "${from}" to "${late.to}"` });
642
+ });
643
+ if (late.from === undefined && !reached(late.to))
644
+ out.push({ path: here('to'), message: `no listed move goes to "${late.to}"` });
645
+ out.push(...momentIssues(table, late.moment, index, here('moment')));
646
+ if (late.flag !== undefined) {
647
+ const flag = index.column(table, late.flag);
648
+ if (flag === undefined)
649
+ out.push({ path: here('flag'), message: `"${table}" has no column "${late.flag}"` });
650
+ else if (flag.type !== 'bool')
651
+ out.push({ path: here('flag'), message: `"${table}.${late.flag}" is not a bool` });
652
+ else if (late.flag === states.column)
653
+ out.push({ path: here('flag'), message: 'the flag is a column of its own, not the state' });
654
+ if (ctx.decided?.(late.flag) === true)
655
+ out.push({ path: here('flag'), message: `"${table}.${late.flag}" is written by another rule already` });
656
+ if ((states.late ?? []).some((other, j) => j < i && other.flag === late.flag)) {
657
+ out.push({ path: here('flag'), message: `"${late.flag}" is set by another late move of this table already` });
658
+ }
659
+ }
660
+ const cancel = ctx.bookingCancel;
661
+ if (cancel !== undefined && cancel.column === states.column && cancel.to === late.to) {
662
+ out.push({ path: here('to'), message: `the booking rule already says when a move to "${late.to}" is late; one late rule per move` });
663
+ }
664
+ if ((states.late ?? []).some((other, j) => j < i && other.to === late.to)) {
665
+ out.push({ path: here('to'), message: `another late rule already judges the move to "${late.to}"; one late rule per move` });
666
+ }
667
+ });
668
+ (states.timed ?? []).forEach((timed, i) => {
669
+ const here = (...rest) => at('timed', i, ...rest);
670
+ known(timed.from, here('from'));
671
+ known(timed.to, here('to'));
672
+ const move = listed(timed.from, timed.to);
673
+ if (move === undefined)
674
+ out.push({ path: here('to'), message: `no listed move goes from "${timed.from}" to "${timed.to}"` });
675
+ else if (typeof move === 'object' && move.undo === true)
676
+ out.push({ path: here('to'), message: `the move from "${timed.from}" to "${timed.to}" is an undo, which only a person makes` });
677
+ if ((states.timed ?? []).some((other, j) => j < i && other.from === timed.from)) {
678
+ out.push({ path: here('from'), message: `another timed move already leaves "${timed.from}"` });
679
+ }
680
+ const vias = [timed.at, ...(timed.at.or ?? [])].some((moment) => moment.via !== undefined);
681
+ if (vias)
682
+ out.push({ path: here('at'), message: "a timed move reads its own row's time, never a linked row's" });
683
+ else
684
+ out.push(...momentIssues(table, timed.at, index, here('at')));
685
+ // A move waiting for a time later than the one it is made at could never pass.
686
+ const after = typeof move === 'object' ? move.requires?.time?.after : undefined;
687
+ if (after !== undefined && neverPasses(after, timed.at)) {
688
+ out.push({ path: here('at'), message: `the move from "${timed.from}" to "${timed.to}" waits until later than this, so it could never be made in time` });
689
+ }
690
+ for (const [ref, value] of Object.entries(timed.set ?? {})) {
691
+ const path = here('set', ref);
692
+ const found = index.column(table, ref);
693
+ if (found === undefined)
694
+ out.push({ path, message: `"${table}" has no column "${ref}"` });
695
+ else if (ref === states.column)
696
+ out.push({ path, message: 'the state moves by the timed move itself, not by what it sets' });
697
+ else if (found.role === 'pk')
698
+ out.push({ path, message: `"${table}.${ref}" is the key, which never changes` });
699
+ else if (value === null ? found.nullable !== true : !valueFits(found, value))
700
+ out.push({ path, message: `${JSON.stringify(value)} is not a value of "${table}.${ref}"` });
701
+ else if (ctx.decided?.(ref) === true)
702
+ out.push({ path, message: `"${table}.${ref}" is written by another rule already` });
703
+ }
704
+ });
705
+ const create = states.create?.requires;
706
+ if (create !== undefined) {
707
+ const here = (...rest) => at('create', 'requires', ...rest);
708
+ (create.where ?? []).forEach((condition, w) => out.push(...conditionIssues(table, condition, index, here('where', w))));
709
+ (create.linked ?? []).forEach((linked, l) => {
710
+ const target = linkTarget(linked.via, here('linked', l, 'via'));
711
+ if (target === undefined)
712
+ return;
713
+ linked.where.forEach((condition, w) => out.push(...conditionIssues(target, condition, index, here('linked', l, 'where', w))));
714
+ });
715
+ if (create.time?.after !== undefined)
716
+ out.push(...momentIssues(table, create.time.after, index, here('time', 'after')));
717
+ if (create.time?.before !== undefined)
718
+ out.push(...momentIssues(table, create.time.before, index, here('time', 'before')));
719
+ (create.setting ?? []).forEach((setting, k) => {
720
+ const path = here('setting', k);
721
+ if (index.table(setting.table) === undefined) {
722
+ out.push({ path, message: `"${setting.table}" is not a table of this manifest` });
723
+ return;
724
+ }
725
+ const found = index.column(setting.table, setting.column);
726
+ if (found === undefined)
727
+ out.push({ path, message: `"${setting.table}" has no column "${setting.column}"` });
728
+ else if (!valueFits(found, setting.eq))
729
+ out.push({ path, message: `${JSON.stringify(setting.eq)} is not a value of "${setting.table}.${setting.column}"` });
730
+ });
731
+ }
732
+ /** Everything wrong with an effect's move of a row of `target` (its states `theirs`) by `set`. */
733
+ const effectMoveIssues = (target, theirs, set, here) => {
734
+ for (const [column, state] of Object.entries(set)) {
735
+ if (column !== theirs.column) {
736
+ out.push({ path: here('set', column), message: `"${target}" moves by "${theirs.column}", not "${column}"` });
737
+ continue;
738
+ }
739
+ const moves = Object.values(theirs.moves).flatMap((list) => list.filter((move) => moveTarget(move) === state));
740
+ if (moves.length === 0)
741
+ out.push({ path: here('set', column), message: `no move of "${target}" goes to "${state}"` });
742
+ // An undo is made only by a person naming the state they saw; an effect names none.
743
+ if (moves.length > 0 && moves.every((move) => typeof move === 'object' && move.undo === true)) {
744
+ out.push({ path: here('set', column), message: `every move of "${target}" to "${state}" is an undo, which only a person makes` });
745
+ }
746
+ // The linked row is moved inside this write, after its own rows are held: its move may wait only for its own row.
747
+ const reaches = moves.some((move) => {
748
+ if (typeof move === 'string')
749
+ return false;
750
+ const time = move.requires?.time;
751
+ const vias = [time?.after, time?.before].flatMap((m) => (m === undefined ? [] : [m, ...(m.or ?? [])])).some((m) => m.via !== undefined);
752
+ return (move.requires?.linked?.length ?? 0) > 0 || vias;
753
+ });
754
+ if (reaches)
755
+ out.push({ path: here('set', column), message: `the move of "${target}" to "${state}" waits for another row, so an effect cannot make it` });
756
+ // Judged late by a time read through another row: that row would be read after this write's own rows.
757
+ const lateThrough = (theirs.late ?? []).some((late) => late.to === state && [late.moment, ...(late.moment.or ?? [])].some((m) => m.via !== undefined));
758
+ if (lateThrough)
759
+ out.push({ path: here('set', column), message: `a move of "${target}" to "${state}" is judged late by another row's time, so an effect cannot make it` });
760
+ }
761
+ };
762
+ /** Everything wrong with the table an effect moves a row of, whatever sets it off. */
763
+ const effectTargetIssues = (target, theirs, path) => {
764
+ if (target === ctx.outboxTable)
765
+ out.push({ path, message: `"${target}" is the app's outbox, whose messages move only by the outbox's own moves` });
766
+ // Moved inside this write, after its own rows are held: a lock taken per day, or a parent held first, would come too late.
767
+ if (ctx.bookedOf?.(target) === true)
768
+ out.push({ path, message: `"${target}" books people by the day, and its lock cannot be taken inside another row's write` });
769
+ const parent = ctx.lineOf?.(target);
770
+ if (parent !== undefined)
771
+ out.push({ path, message: `"${target}" rows are lines of "${parent}", which would be held after this write's own rows` });
772
+ if ((theirs.effects?.length ?? 0) > 0)
773
+ out.push({ path, message: `"${target}" sets off effects of its own; an effect moves one row, never a chain` });
774
+ };
775
+ (states.effects ?? []).forEach((effect, i) => {
776
+ const here = (...rest) => at('effects', i, ...rest);
777
+ if (isChangeEffect(effect)) {
778
+ (effect.on.in ?? []).forEach((state, k) => known(state, here('on', 'in', k)));
779
+ if ((states.effects ?? []).some((other, j) => j < i && isChangeEffect(other) && other.on.change === effect.on.change)) {
780
+ out.push({ path: here(), message: `another effect already moves the rows "${effect.on.change}" points at when it changes` });
781
+ }
782
+ if (effect.on.change === states.column)
783
+ out.push({ path: here('on', 'change'), message: 'the state moves by its moves: an effect of a changed link watches a link' });
784
+ const target = linkTarget(effect.on.change, here('on', 'change'));
785
+ if (target === undefined)
786
+ return;
787
+ const theirs = ctx.statesOf?.(target);
788
+ if (theirs === undefined) {
789
+ out.push({ path: here('on', 'change'), message: `"${target}" declares no states, so its rows have no moves to make` });
790
+ return;
791
+ }
792
+ if (effect.old !== undefined)
793
+ effectMoveIssues(target, theirs, effect.old.set, (...rest) => here('old', ...rest));
794
+ if (effect.new !== undefined)
795
+ effectMoveIssues(target, theirs, effect.new.set, (...rest) => here('new', ...rest));
796
+ effectTargetIssues(target, theirs, here('on', 'change'));
797
+ return;
798
+ }
799
+ known(effect.on.to, here('on', 'to'));
800
+ if (!reached(effect.on.to))
801
+ out.push({ path: here('on', 'to'), message: `no listed move goes to "${effect.on.to}", so nothing sets this off` });
802
+ if ((states.effects ?? []).some((other, j) => j < i && !isChangeEffect(other) && other.on.to === effect.on.to && other.via === effect.via)) {
803
+ out.push({ path: here(), message: `another effect already moves the row "${effect.via}" points at on a move to "${effect.on.to}"` });
804
+ }
805
+ const target = linkTarget(effect.via, here('via'));
806
+ if (target === undefined)
807
+ return;
808
+ const theirs = ctx.statesOf?.(target);
809
+ if (theirs === undefined) {
810
+ out.push({ path: here('via'), message: `"${target}" declares no states, so its rows have no moves to make` });
811
+ return;
812
+ }
813
+ effectMoveIssues(target, theirs, effect.set, here);
814
+ effectTargetIssues(target, theirs, here('via'));
815
+ });
816
+ return out;
817
+ }
818
+ /** Minutes of a literal shift, or undefined when it is read from a setting. */
819
+ function literalMinutes(offset) {
820
+ if (offset === undefined)
821
+ return 0;
822
+ const { minutes, hours, days } = offset;
823
+ if (typeof minutes === 'number')
824
+ return minutes;
825
+ if (typeof hours === 'number')
826
+ return hours * 60;
827
+ if (typeof days === 'number')
828
+ return days * 1440;
829
+ return undefined;
830
+ }
831
+ /**
832
+ * Whether `after` is certainly later than `at`: both the same own column,
833
+ * no time of day, no fallback, and literal shifts. Anything else depends on
834
+ * the row and the settings, and is the app's to get right.
835
+ */
836
+ function neverPasses(after, at) {
837
+ const plain = (m) => m.via === undefined && m.time === undefined && m.or === undefined;
838
+ if (!plain(after) || !plain(at) || after.column !== at.column)
839
+ return false;
840
+ const shift = (m) => {
841
+ if (m.plus !== undefined)
842
+ return literalMinutes(m.plus);
843
+ const back = literalMinutes(m.minus);
844
+ return back === undefined ? undefined : -back;
845
+ };
846
+ const a = shift(after);
847
+ const b = shift(at);
848
+ return a !== undefined && b !== undefined && a > b;
849
+ }
850
+ export function conditionIssues(table, condition, index, path) {
292
851
  const found = index.column(table, condition.column);
293
852
  if (found === undefined)
294
853
  return [{ path, message: `"${table}" has no column "${condition.column}"` }];