@adminiumjs/manifest 0.3.0-rc.4 → 0.3.1
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.
- package/dist/add-ons.d.ts +63 -0
- package/dist/add-ons.d.ts.map +1 -0
- package/dist/add-ons.js +100 -0
- package/dist/add-ons.js.map +1 -0
- package/dist/booking.d.ts +100 -0
- package/dist/booking.d.ts.map +1 -0
- package/dist/booking.js +237 -0
- package/dist/booking.js.map +1 -0
- package/dist/documents.d.ts +100 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +148 -0
- package/dist/documents.js.map +1 -0
- package/dist/formula.d.ts +124 -0
- package/dist/formula.d.ts.map +1 -0
- package/dist/formula.js +428 -0
- package/dist/formula.js.map +1 -0
- package/dist/index.d.ts +14 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -1
- package/dist/outbox.d.ts +543 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +559 -0
- package/dist/outbox.js.map +1 -0
- package/dist/page-calendar.d.ts +42 -0
- package/dist/page-calendar.d.ts.map +1 -0
- package/dist/page-calendar.js +95 -0
- package/dist/page-calendar.js.map +1 -0
- package/dist/plan-model.d.ts +1 -1
- package/dist/plan-model.d.ts.map +1 -1
- package/dist/public-access.d.ts +208 -0
- package/dist/public-access.d.ts.map +1 -0
- package/dist/public-access.js +681 -0
- package/dist/public-access.js.map +1 -0
- package/dist/refs.d.ts +91 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +82 -0
- package/dist/refs.js.map +1 -0
- package/dist/roles.d.ts +44 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +75 -0
- package/dist/roles.js.map +1 -0
- package/dist/sample.d.ts +150 -2
- package/dist/sample.d.ts.map +1 -1
- package/dist/sample.js +101 -3
- package/dist/sample.js.map +1 -1
- package/dist/schema.d.ts +3464 -151
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +681 -143
- package/dist/schema.js.map +1 -1
- package/dist/semver.d.ts +28 -0
- package/dist/semver.d.ts.map +1 -0
- package/dist/semver.js +85 -0
- package/dist/semver.js.map +1 -0
- package/dist/shapes.d.ts +97 -0
- package/dist/shapes.d.ts.map +1 -0
- package/dist/shapes.js +190 -0
- package/dist/shapes.js.map +1 -0
- package/dist/states.d.ts +134 -0
- package/dist/states.d.ts.map +1 -0
- package/dist/states.js +244 -0
- package/dist/states.js.map +1 -0
- package/dist/validate.d.ts +14 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +29 -2
- package/dist/validate.js.map +1 -1
- package/package.json +2 -2
package/dist/schema.js
CHANGED
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { addOnBlockSchema, addOnCategorySchema, i18nMessageSchema, isSlotId, } from '@adminium/add-on-contracts';
|
|
13
13
|
import { z } from 'zod';
|
|
14
|
+
import { addOnNeedsIssues, addOnsSchema, requiresAddOn } from './add-ons.js';
|
|
15
|
+
import { bookingIssues, bookingSchema } from './booking.js';
|
|
16
|
+
import { appDocumentIssues, appDocumentSchema, mappingIssues } from './documents.js';
|
|
17
|
+
import { formulaExprSchema, tableFormulaIssues } from './formula.js';
|
|
18
|
+
import { pageCalendarIssues } from './page-calendar.js';
|
|
19
|
+
import { emailTemplateSchema, outboxIssues, outboxProducerSchema, outboxSchema } from './outbox.js';
|
|
20
|
+
import { publicAccessIssues, publicAccessSchema, publicKeysSchema } from './public-access.js';
|
|
21
|
+
import { roleLimitIssues, roleLimitsSchema } from './roles.js';
|
|
22
|
+
import { statesIssues, statesSchema } from './states.js';
|
|
23
|
+
import { NUMERIC_TYPES, labelsSchema, numberOrSetting, refSchema, scalarSchema, settingSourceSchema, tableIndex, textOrLabels, valueFits, } from './refs.js';
|
|
24
|
+
import { compareSemver, parseSemverRange } from './semver.js';
|
|
25
|
+
export { compareSemver };
|
|
14
26
|
/** Integer spec version, frozen at 1 for Adminium 1.x. */
|
|
15
27
|
export const MANIFEST_VERSION = 1;
|
|
16
28
|
/**
|
|
@@ -134,6 +146,21 @@ export const compatibilitySchema = z
|
|
|
134
146
|
*/
|
|
135
147
|
engines: z.array(z.enum(MANIFEST_ENGINES)).min(1).optional(),
|
|
136
148
|
requires: z.array(capabilitySchema).optional(),
|
|
149
|
+
/**
|
|
150
|
+
* The installed versions this release can update in place, as a semver
|
|
151
|
+
* range (`>=0.2.0`). An install outside it is not offered the update and
|
|
152
|
+
* is refused one: a release whose tables changed shape says so here, and
|
|
153
|
+
* the operator is told to uninstall first rather than left with an update
|
|
154
|
+
* that fails half-way. Absent means any older version.
|
|
155
|
+
*/
|
|
156
|
+
updatesFrom: z
|
|
157
|
+
.string()
|
|
158
|
+
.min(1)
|
|
159
|
+
.max(120)
|
|
160
|
+
.refine((range) => parseSemverRange(range) !== null, {
|
|
161
|
+
message: 'a semver range such as ">=0.2.0", "^0.2.0" or ">=0.2.0 <1.0.0"',
|
|
162
|
+
})
|
|
163
|
+
.optional(),
|
|
137
164
|
})
|
|
138
165
|
.strict();
|
|
139
166
|
// ── requiredSchema (create-or-map) ───────────────────────────────────────────
|
|
@@ -167,25 +194,7 @@ export const COLUMN_SEMANTICS = [
|
|
|
167
194
|
];
|
|
168
195
|
/** Structural role markers. */
|
|
169
196
|
export const COLUMN_ROLES = ['pk', 'created_at', 'updated_at'];
|
|
170
|
-
|
|
171
|
-
const refSchema = z.string().regex(/^[a-z][a-z0-9_]*$/, 'must be a snake_case identifier');
|
|
172
|
-
/** A BCP 47 tag, the way every label map in a manifest is keyed (`de-DE`). */
|
|
173
|
-
const bcp47TagSchema = z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, 'keyed by BCP 47 tag');
|
|
174
|
-
/** A label in several languages. US English is the one every reader falls back to. */
|
|
175
|
-
export const labelsSchema = z
|
|
176
|
-
.record(bcp47TagSchema, z.string().min(1).max(120))
|
|
177
|
-
.refine((labels) => labels['en-US'] !== undefined, { message: 'labels must include en-US' });
|
|
178
|
-
/** A text that is either one string, or the same text in several languages. */
|
|
179
|
-
const textOrLabels = z.union([z.string().min(1).max(256), labelsSchema]);
|
|
180
|
-
/**
|
|
181
|
-
* A number the manifest states, or one the app's own settings row holds — so
|
|
182
|
-
* a venue can change its capacity without a new release. `{table, column}`
|
|
183
|
-
* reads the one row of that (one-row) table at write time.
|
|
184
|
-
*/
|
|
185
|
-
const numberOrSetting = z.union([
|
|
186
|
-
z.number().int().nonnegative(),
|
|
187
|
-
z.object({ table: refSchema, column: refSchema }).strict(),
|
|
188
|
-
]);
|
|
197
|
+
export { labelsSchema };
|
|
189
198
|
/**
|
|
190
199
|
* The rules an app asks Adminium to keep on a column (written as column-rule
|
|
191
200
|
* overrides at install). The first five are the rules an operator can set in
|
|
@@ -201,6 +210,71 @@ const numberOrSetting = z.union([
|
|
|
201
210
|
* - `personal`: whether the column is personal data, overriding the guess
|
|
202
211
|
* Adminium makes from its name.
|
|
203
212
|
*/
|
|
213
|
+
/** A child list a fingerprint covers: its rows in `orderBy` order, then by key. */
|
|
214
|
+
const hashChildSchema = z
|
|
215
|
+
.object({ table: refSchema, via: refSchema, columns: z.array(refSchema).min(1).max(24), orderBy: refSchema.optional() })
|
|
216
|
+
.strict();
|
|
217
|
+
/**
|
|
218
|
+
* What a stamp writes:
|
|
219
|
+
*
|
|
220
|
+
* - `now` (a timestamptz), `today` (a date, on the venue's calendar),
|
|
221
|
+
* `user-name`, `user-id`;
|
|
222
|
+
* - `byOrigin`: one value for a public write, another for staff — or, with no
|
|
223
|
+
* `staff`, whatever the staff writer chose (a desk records how a client
|
|
224
|
+
* approved; the portal always says "portal");
|
|
225
|
+
* - `copy`: another column of the same row as it stands at that moment (a
|
|
226
|
+
* client's first answer, kept when they edit it later);
|
|
227
|
+
* - `claim`: a column of the signed-in person's own row (their email, their
|
|
228
|
+
* name) on a public write; on a staff write, `staff` says what instead;
|
|
229
|
+
* - `addDays`: a date so many days after another (`due_on` = `issued_on` +
|
|
230
|
+
* the terms' days, `map` giving each term its days);
|
|
231
|
+
* - `hashOf`: a fingerprint — SHA-256 over the named columns, child rows and
|
|
232
|
+
* a linked row's text, in a canonical form anyone can recompute.
|
|
233
|
+
*/
|
|
234
|
+
export const stampSetSchema = z.union([
|
|
235
|
+
z.enum(['now', 'today', 'user-name', 'user-id']),
|
|
236
|
+
z.object({ byOrigin: z.object({ public: z.string().min(1), staff: z.string().min(1).optional() }).strict() }).strict(),
|
|
237
|
+
z.object({ claim: refSchema, staff: z.enum(['user-name', 'user-id']).optional() }).strict(),
|
|
238
|
+
z.object({ copy: refSchema }).strict(),
|
|
239
|
+
z
|
|
240
|
+
.object({
|
|
241
|
+
addDays: z
|
|
242
|
+
.object({
|
|
243
|
+
date: refSchema,
|
|
244
|
+
days: z.union([refSchema, z.number().int().min(0).max(3650)]),
|
|
245
|
+
map: z.record(z.string().min(1), z.number().int().min(0).max(3650)).optional(),
|
|
246
|
+
})
|
|
247
|
+
.strict(),
|
|
248
|
+
})
|
|
249
|
+
.strict(),
|
|
250
|
+
z
|
|
251
|
+
.object({
|
|
252
|
+
hashOf: z
|
|
253
|
+
.object({
|
|
254
|
+
columns: z.array(refSchema).min(1).max(24),
|
|
255
|
+
children: z.array(hashChildSchema).max(4).optional(),
|
|
256
|
+
linked: z
|
|
257
|
+
.array(z
|
|
258
|
+
.object({
|
|
259
|
+
via: refSchema,
|
|
260
|
+
table: refSchema,
|
|
261
|
+
columns: z.array(refSchema).min(1).max(24),
|
|
262
|
+
children: z.array(hashChildSchema).max(4).optional(),
|
|
263
|
+
})
|
|
264
|
+
.strict())
|
|
265
|
+
.max(4)
|
|
266
|
+
.optional(),
|
|
267
|
+
})
|
|
268
|
+
.strict(),
|
|
269
|
+
})
|
|
270
|
+
.strict(),
|
|
271
|
+
]);
|
|
272
|
+
/** When a stamp is written: on create, when a column changes to a value, or when a column is first filled. */
|
|
273
|
+
export const stampTriggerSchema = z.union([
|
|
274
|
+
z.literal('create'),
|
|
275
|
+
z.object({ column: refSchema, values: z.array(scalarSchema).min(1).max(16) }).strict(),
|
|
276
|
+
z.object({ column: refSchema, filled: z.literal(true) }).strict(),
|
|
277
|
+
]);
|
|
204
278
|
export const columnRulesSchema = z
|
|
205
279
|
.object({
|
|
206
280
|
options: z
|
|
@@ -240,14 +314,59 @@ export const columnRulesSchema = z
|
|
|
240
314
|
.object({ via: refSchema, from: refSchema, mode: z.enum(['default', 'always']).optional() })
|
|
241
315
|
.strict()
|
|
242
316
|
.optional(),
|
|
243
|
-
|
|
317
|
+
/**
|
|
318
|
+
* A value filled on create when the writer gives none (and a `copy` on the
|
|
319
|
+
* same column came back empty): the connection's currency, a column of the
|
|
320
|
+
* app's settings row, or a setting of an add-on the app requires.
|
|
321
|
+
*/
|
|
322
|
+
default: z
|
|
323
|
+
.object({ from: z.union([z.literal('connection.currency'), settingSourceSchema]) })
|
|
324
|
+
.strict()
|
|
325
|
+
.optional(),
|
|
326
|
+
/**
|
|
327
|
+
* A running number. `gapless` numbers are taken inside the write that
|
|
328
|
+
* creates the row, so the series never skips or repeats (an invoice's);
|
|
329
|
+
* `scope` numbers per parent row (a deliverable's versions v1, v2 …);
|
|
330
|
+
* `startSetting` reads the first number from a setting.
|
|
331
|
+
*/
|
|
332
|
+
sequence: z
|
|
333
|
+
.object({
|
|
334
|
+
start: z.number().int().min(1).optional(),
|
|
335
|
+
gapless: z.literal(true).optional(),
|
|
336
|
+
startSetting: settingSourceSchema.optional(),
|
|
337
|
+
scope: refSchema.optional(),
|
|
338
|
+
})
|
|
339
|
+
.strict()
|
|
340
|
+
.optional(),
|
|
341
|
+
/**
|
|
342
|
+
* A text column written from a running number of the same row: the
|
|
343
|
+
* prefix, then the digits padded (`INV-2042`). The prefix may come from a
|
|
344
|
+
* setting, so a studio can change it; a change applies to the next number.
|
|
345
|
+
*/
|
|
346
|
+
format: z
|
|
347
|
+
.object({
|
|
348
|
+
from: refSchema,
|
|
349
|
+
prefix: z.string().regex(/^[A-Za-z0-9_/.-]{0,12}$/, 'a prefix of up to 12 letters, digits and - _ / .').optional(),
|
|
350
|
+
prefixSetting: settingSourceSchema.optional(),
|
|
351
|
+
pad: z.number().int().min(0).max(12).optional(),
|
|
352
|
+
})
|
|
353
|
+
.strict()
|
|
354
|
+
.optional(),
|
|
244
355
|
code: z
|
|
245
356
|
.object({
|
|
246
357
|
prefix: z.string().regex(/^[A-Z][A-Z0-9]{0,5}-?$/, 'an upper-case prefix, e.g. MR-').optional(),
|
|
247
|
-
length: z.number().int().min(4).max(
|
|
358
|
+
length: z.number().int().min(4).max(16),
|
|
248
359
|
})
|
|
249
360
|
.strict()
|
|
250
361
|
.optional(),
|
|
362
|
+
/** A value Adminium works out from the row's other columns (see `formula.ts`). */
|
|
363
|
+
formula: formulaExprSchema.optional(),
|
|
364
|
+
/**
|
|
365
|
+
* How a text value is stored whoever writes it: `trim` without spaces at
|
|
366
|
+
* either end, `email` trimmed and in lower case — so a unique address and
|
|
367
|
+
* a person signing in with it agree on every database.
|
|
368
|
+
*/
|
|
369
|
+
normalize: z.enum(['trim', 'email']).optional(),
|
|
251
370
|
rollup: z
|
|
252
371
|
.object({
|
|
253
372
|
/** The child table, its foreign key back to this row, and what to add up. */
|
|
@@ -258,6 +377,32 @@ export const columnRulesSchema = z
|
|
|
258
377
|
times: refSchema.optional(),
|
|
259
378
|
/** A child row whose column holds a value is left out — a voided line (`voided_at`). */
|
|
260
379
|
unlessSet: refSchema.optional(),
|
|
380
|
+
/** Only child rows whose column equals the value are added up (`voided = false`). */
|
|
381
|
+
where: z.object({ column: refSchema, eq: scalarSchema }).strict().optional(),
|
|
382
|
+
/**
|
|
383
|
+
* A second column of this row kept in step: `of − Σminus − total`
|
|
384
|
+
* (`balance = fee − waived − paid`). Adminium writes it; nobody else may.
|
|
385
|
+
*/
|
|
386
|
+
balance: z
|
|
387
|
+
.object({ column: refSchema, of: refSchema, minus: z.array(refSchema).max(4).optional() })
|
|
388
|
+
.strict()
|
|
389
|
+
.optional(),
|
|
390
|
+
/** A child write that would take the balance below zero is refused. */
|
|
391
|
+
cap: z.literal(true).optional(),
|
|
392
|
+
})
|
|
393
|
+
.strict()
|
|
394
|
+
.optional(),
|
|
395
|
+
/**
|
|
396
|
+
* A value Adminium writes when something happens: the moment, or who did
|
|
397
|
+
* it, on a create or when another column changes to one of `values`
|
|
398
|
+
* (`checked_in_at` when `status` becomes `checked_in`). `byOrigin` writes
|
|
399
|
+
* one value for a public write and another for staff. A public write never
|
|
400
|
+
* stamps a person: a browser key is nobody.
|
|
401
|
+
*/
|
|
402
|
+
stamp: z
|
|
403
|
+
.object({
|
|
404
|
+
set: stampSetSchema,
|
|
405
|
+
on: z.union([stampTriggerSchema, z.array(stampTriggerSchema).min(2).max(3)]),
|
|
261
406
|
})
|
|
262
407
|
.strict()
|
|
263
408
|
.optional(),
|
|
@@ -269,6 +414,17 @@ export const columnRulesSchema = z
|
|
|
269
414
|
* mark it otherwise.
|
|
270
415
|
*/
|
|
271
416
|
personal: z.boolean().optional(),
|
|
417
|
+
/**
|
|
418
|
+
* A date that may never be later than today, on the venue's calendar — a
|
|
419
|
+
* payment is recorded when it came in, not when it might.
|
|
420
|
+
*/
|
|
421
|
+
notAfter: z.literal('today').optional(),
|
|
422
|
+
/**
|
|
423
|
+
* A date that may never be earlier than another: a column of the same row,
|
|
424
|
+
* or — with `via`, a foreign key of this row — a column of the row it
|
|
425
|
+
* points at (a payment is never dated before its invoice was issued).
|
|
426
|
+
*/
|
|
427
|
+
notBefore: z.object({ column: refSchema, via: refSchema.optional() }).strict().optional(),
|
|
272
428
|
})
|
|
273
429
|
.strict();
|
|
274
430
|
/**
|
|
@@ -374,6 +530,18 @@ export const requiredColumnSchema = z
|
|
|
374
530
|
* row, so a few long columns would refuse the table.
|
|
375
531
|
*/
|
|
376
532
|
maxLength: z.number().int().min(1).max(MAX_TEXT_LENGTH).optional(),
|
|
533
|
+
/**
|
|
534
|
+
* `decimal` / `money` only: the places kept after the point, 0–4, or
|
|
535
|
+
* `"currency"` — the decimals of the row's own `currency` column (JPY 0,
|
|
536
|
+
* EUR 2, KWD 3), else the connection's currency, else 2. Without it a
|
|
537
|
+
* decimal keeps four places. Totals, rollups and formulas round to it.
|
|
538
|
+
*/
|
|
539
|
+
scale: z.union([z.number().int().min(0).max(4), z.literal('currency')]).optional(),
|
|
540
|
+
/**
|
|
541
|
+
* No two rows may hold the same value (empty values excepted). A text
|
|
542
|
+
* column needs `maxLength`: MySQL indexes no unbounded text.
|
|
543
|
+
*/
|
|
544
|
+
unique: z.literal(true).optional(),
|
|
377
545
|
/** Rules Adminium keeps on the column once installed (see `columnRulesSchema`). */
|
|
378
546
|
rules: columnRulesSchema.optional(),
|
|
379
547
|
/**
|
|
@@ -395,6 +563,14 @@ export const requiredColumnSchema = z
|
|
|
395
563
|
.refine((c) => c.maxLength === undefined || c.type === 'text', {
|
|
396
564
|
message: 'maxLength applies to a text column only',
|
|
397
565
|
path: ['maxLength'],
|
|
566
|
+
})
|
|
567
|
+
.refine((c) => c.scale === undefined || c.type === 'decimal' || c.type === 'money', {
|
|
568
|
+
message: 'scale applies to a decimal or money column only',
|
|
569
|
+
path: ['scale'],
|
|
570
|
+
})
|
|
571
|
+
.refine((c) => c.unique === undefined || (c.role !== 'pk' && !['json', 'blob'].includes(c.type) && (c.type !== 'text' || c.maxLength !== undefined)), {
|
|
572
|
+
message: 'unique needs a column that can be indexed: not the key, not json or blob, text with maxLength',
|
|
573
|
+
path: ['unique'],
|
|
398
574
|
})
|
|
399
575
|
.superRefine((c, ctx) => {
|
|
400
576
|
const issue = defaultIssue(c);
|
|
@@ -411,6 +587,23 @@ export const requiredTableSchema = z
|
|
|
411
587
|
*/
|
|
412
588
|
shape: z.string().regex(/^[a-z][a-z0-9-]*@\d+$/, 'a shape is <name>@<version>').optional(),
|
|
413
589
|
capacity: capacitySchema.optional(),
|
|
590
|
+
/** Booking people: no two counted rows of one resource may overlap (see `booking.ts`). */
|
|
591
|
+
booking: bookingSchema.optional(),
|
|
592
|
+
/** States, the moves between them, locks and no-delete (see `states.ts`). */
|
|
593
|
+
states: statesSchema.optional(),
|
|
594
|
+
/**
|
|
595
|
+
* A table built on a shape an add-on defines, `<addOn>/<name>@<version>`
|
|
596
|
+
* (`invoices/invoice@1`), and which part of it (`document`, `lines`).
|
|
597
|
+
* The table spells out the shape's columns and rules beside its own; the
|
|
598
|
+
* install checks them against the installed add-on. Not the same as
|
|
599
|
+
* `shape`, which lets apps SHARE one table: two apps built on one shape
|
|
600
|
+
* get a table each.
|
|
601
|
+
*/
|
|
602
|
+
builtOn: z
|
|
603
|
+
.string()
|
|
604
|
+
.regex(/^[a-z][a-z0-9-]{1,79}\/[a-z][a-z0-9-]*@[1-9]\d*$/, 'builtOn is <add-on key>/<shape name>@<version>')
|
|
605
|
+
.optional(),
|
|
606
|
+
part: z.string().regex(/^[a-z][a-z0-9_]*$/, 'a part of the shape').optional(),
|
|
414
607
|
/**
|
|
415
608
|
* What a person calls ONE row ("Category") and the table ("Categories"),
|
|
416
609
|
* in every language the app speaks: a form's title and button, a page's
|
|
@@ -431,6 +624,18 @@ export const requiredTableSchema = z
|
|
|
431
624
|
.refine((t) => t.labelPlural === undefined || t.label !== undefined, {
|
|
432
625
|
message: 'labelPlural needs a label',
|
|
433
626
|
path: ['labelPlural'],
|
|
627
|
+
})
|
|
628
|
+
.refine((t) => t.capacity === undefined || t.booking === undefined, {
|
|
629
|
+
message: 'a table has a capacity or a booking rule, not both',
|
|
630
|
+
path: ['booking'],
|
|
631
|
+
})
|
|
632
|
+
.refine((t) => (t.builtOn === undefined) === (t.part === undefined), {
|
|
633
|
+
message: 'a table built on a shape names the part it is, and only such a table does',
|
|
634
|
+
path: ['part'],
|
|
635
|
+
})
|
|
636
|
+
.refine((t) => t.builtOn === undefined || t.shape === undefined, {
|
|
637
|
+
message: 'a table is built on an add-on\'s shape or shared under a shape, not both',
|
|
638
|
+
path: ['builtOn'],
|
|
434
639
|
});
|
|
435
640
|
export const requiredSchemaSchema = z
|
|
436
641
|
.object({
|
|
@@ -447,6 +652,99 @@ export const requiredSchemaSchema = z
|
|
|
447
652
|
message: 'duplicate table ref in requiredSchema',
|
|
448
653
|
path: ['tables'],
|
|
449
654
|
});
|
|
655
|
+
// ── shapes an add-on defines ──────────────────────────────────────────────────
|
|
656
|
+
/**
|
|
657
|
+
* One part of a shape: its columns and the rules on them, and the table's
|
|
658
|
+
* states. A column's `references` names another part of the same shape
|
|
659
|
+
* (`document`), or a part of another of the add-on's shapes
|
|
660
|
+
* (`quote@1/document`).
|
|
661
|
+
*/
|
|
662
|
+
export const shapePartSchema = z
|
|
663
|
+
.object({
|
|
664
|
+
columns: z.array(requiredColumnSchema).min(1).max(60),
|
|
665
|
+
states: statesSchema.optional(),
|
|
666
|
+
})
|
|
667
|
+
.strict()
|
|
668
|
+
.refine((p) => new Set(p.columns.map((c) => c.ref)).size === p.columns.length, {
|
|
669
|
+
message: 'duplicate column ref in part',
|
|
670
|
+
path: ['columns'],
|
|
671
|
+
});
|
|
672
|
+
/**
|
|
673
|
+
* A shape an add-on defines for apps to build their tables on (see
|
|
674
|
+
* `shapes.ts`): named parts, the document profiles made for an app's tables
|
|
675
|
+
* at install (in part and column names), and the messages it sends.
|
|
676
|
+
*/
|
|
677
|
+
export const shapeDefinitionSchema = z
|
|
678
|
+
.object({
|
|
679
|
+
name: z.string().regex(/^[a-z][a-z0-9-]*$/, 'a shape name is kebab-case'),
|
|
680
|
+
version: z.number().int().min(1).max(99),
|
|
681
|
+
parts: z.record(z.string().regex(/^[a-z][a-z0-9_]*$/, 'a part name is snake_case'), shapePartSchema),
|
|
682
|
+
documentProfiles: z
|
|
683
|
+
.array(appDocumentSchema.omit({ addOn: true, table: true, feature: true }).extend({ part: refSchema }).strict())
|
|
684
|
+
.max(8)
|
|
685
|
+
.optional(),
|
|
686
|
+
outbox: z
|
|
687
|
+
.object({
|
|
688
|
+
producers: z.array(outboxProducerSchema).min(1).max(16),
|
|
689
|
+
templates: z
|
|
690
|
+
.array(emailTemplateSchema.omit({ key: true }).extend({ kind: z.string().min(1).max(40) }).strict())
|
|
691
|
+
.max(16)
|
|
692
|
+
.optional(),
|
|
693
|
+
})
|
|
694
|
+
.strict()
|
|
695
|
+
.optional(),
|
|
696
|
+
})
|
|
697
|
+
.strict()
|
|
698
|
+
.refine((d) => Object.keys(d.parts).length > 0, { message: 'a shape has at least one part', path: ['parts'] });
|
|
699
|
+
/**
|
|
700
|
+
* Everything wrong inside an add-on's shapes: each part is checked as the
|
|
701
|
+
* app's tables are, with the parts of all its shapes as the tables (another
|
|
702
|
+
* shape's part as `<name>@<version>/<part>`), and its rules may read only the
|
|
703
|
+
* add-on's own settings.
|
|
704
|
+
*/
|
|
705
|
+
export function shapeDefinitionIssues(addOn, settings, shapes) {
|
|
706
|
+
const out = [];
|
|
707
|
+
const seen = new Set();
|
|
708
|
+
shapes.forEach((shape, s) => {
|
|
709
|
+
const id = `${shape.name}@${String(shape.version)}`;
|
|
710
|
+
if (seen.has(id))
|
|
711
|
+
out.push({ path: ['addOn', 'shapes', s], message: `"${id}" is declared twice` });
|
|
712
|
+
seen.add(id);
|
|
713
|
+
const own = Object.entries(shape.parts).map(([ref, part]) => ({ ref, columns: part.columns, states: part.states }));
|
|
714
|
+
const others = shapes
|
|
715
|
+
.filter((other) => other !== shape)
|
|
716
|
+
.flatMap((other) => Object.entries(other.parts).map(([ref, part]) => ({ ref: `${other.name}@${String(other.version)}/${ref}`, columns: part.columns })));
|
|
717
|
+
const parts = own.map((p) => p.ref);
|
|
718
|
+
const issues = appReferenceIssues({ key: addOn, requiredSchema: { tables: [...own, ...others] } }, { addOn, settings });
|
|
719
|
+
for (const issue of issues) {
|
|
720
|
+
const [, , t, ...rest] = issue.path;
|
|
721
|
+
if (typeof t !== 'number' || t >= parts.length)
|
|
722
|
+
continue;
|
|
723
|
+
out.push({ path: ['addOn', 'shapes', s, 'parts', parts[t], ...rest], message: issue.message });
|
|
724
|
+
}
|
|
725
|
+
const index = tableIndex([...own, ...others]);
|
|
726
|
+
(shape.documentProfiles ?? []).forEach((profile, d) => {
|
|
727
|
+
const at = (...rest) => ['addOn', 'shapes', s, 'documentProfiles', d, ...rest];
|
|
728
|
+
if (shape.parts[profile.part] === undefined) {
|
|
729
|
+
out.push({ path: at('part'), message: `"${id}" has no part "${profile.part}"` });
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
out.push(...mappingIssues(profile.part, profile.mapping, index, at));
|
|
733
|
+
});
|
|
734
|
+
(shape.outbox?.producers ?? []).forEach((producer, p) => {
|
|
735
|
+
const source = 'onCreate' in producer ? producer.onCreate : 'onChange' in producer ? producer.onChange : producer.before;
|
|
736
|
+
if (shape.parts[source.table] === undefined) {
|
|
737
|
+
out.push({ path: ['addOn', 'shapes', s, 'outbox', 'producers', p], message: `"${id}" has no part "${source.table}"` });
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
const kinds = new Set((shape.outbox?.producers ?? []).map((producer) => producer.kind));
|
|
741
|
+
(shape.outbox?.templates ?? []).forEach((template, k) => {
|
|
742
|
+
if (!kinds.has(template.kind))
|
|
743
|
+
out.push({ path: ['addOn', 'shapes', s, 'outbox', 'templates', k, 'kind'], message: `no producer sends "${template.kind}"` });
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
return out;
|
|
747
|
+
}
|
|
450
748
|
// ── pages ────────────────────────────────────────────────────────────────────
|
|
451
749
|
export const pageNavSchema = z
|
|
452
750
|
.object({
|
|
@@ -472,6 +770,8 @@ export const pageSchema = z
|
|
|
472
770
|
titles: z
|
|
473
771
|
.record(z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, 'titles are keyed by BCP 47 tag'), z.string().min(1).max(120))
|
|
474
772
|
.optional(),
|
|
773
|
+
/** The page shows only while this feature's add-ons are there (see `addOns.features`). */
|
|
774
|
+
feature: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a feature id').optional(),
|
|
475
775
|
})
|
|
476
776
|
.strict();
|
|
477
777
|
// ── roles ────────────────────────────────────────────────────────────────────
|
|
@@ -484,6 +784,8 @@ export const roleSchema = z
|
|
|
484
784
|
permissions: z.array(z.string().min(1)).optional(),
|
|
485
785
|
/** Opens the app's own screens and never the dashboard (a till cashier). */
|
|
486
786
|
screensOnly: z.boolean().optional(),
|
|
787
|
+
/** Per table, what the role's update there may write (roles.ts). */
|
|
788
|
+
limits: roleLimitsSchema.optional(),
|
|
487
789
|
})
|
|
488
790
|
.strict();
|
|
489
791
|
// ── settings ─────────────────────────────────────────────────────────────────
|
|
@@ -622,55 +924,8 @@ export const navGroupSchema = z
|
|
|
622
924
|
order: z.number().int(),
|
|
623
925
|
})
|
|
624
926
|
.strict();
|
|
625
|
-
/**
|
|
626
|
-
|
|
627
|
-
* browser key the install creates. `GET` reads, `POST` creates; `PATCH` only
|
|
628
|
-
* behind a claim (a guest changing their own booking), on a narrow writable
|
|
629
|
-
* list. `availability` answers free or full per slot and never a row.
|
|
630
|
-
*/
|
|
631
|
-
export const publicAccessSchema = z
|
|
632
|
-
.object({
|
|
633
|
-
table: refSchema,
|
|
634
|
-
kind: z.enum(['records', 'availability']).optional(),
|
|
635
|
-
methods: z.array(z.enum(['GET', 'POST', 'PATCH'])).min(1),
|
|
636
|
-
select: z.array(refSchema).optional(),
|
|
637
|
-
writable: z.array(refSchema).optional(),
|
|
638
|
-
filters: z
|
|
639
|
-
.array(z
|
|
640
|
-
.object({
|
|
641
|
-
column: refSchema,
|
|
642
|
-
op: z.enum(['eq', 'neq', 'in', 'gte', 'lte']),
|
|
643
|
-
value: z.union([z.string(), z.number(), z.boolean(), z.array(z.union([z.string(), z.number()]))]),
|
|
644
|
-
})
|
|
645
|
-
.strict())
|
|
646
|
-
.optional(),
|
|
647
|
-
/** Values the server writes, whatever the browser sends (`status: confirmed`). */
|
|
648
|
-
defaults: z.record(refSchema, z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
649
|
-
/** Proving you know a row's details, e.g. `{match: [code, mobile]}`. */
|
|
650
|
-
claim: z.object({ match: z.array(refSchema).min(1).max(3) }).strict().optional(),
|
|
651
|
-
/**
|
|
652
|
-
* The confirmation Adminium emails when a guest creates a row here: the
|
|
653
|
-
* column holding their address, the columns the email shows, the app's
|
|
654
|
-
* one-row venue table, and the path under the guest side that manages it.
|
|
655
|
-
*/
|
|
656
|
-
confirm: z
|
|
657
|
-
.object({
|
|
658
|
-
template: z.enum(['booking-confirmation']),
|
|
659
|
-
to: refSchema,
|
|
660
|
-
code: refSchema.optional(),
|
|
661
|
-
when: refSchema.optional(),
|
|
662
|
-
party: refSchema.optional(),
|
|
663
|
-
name: refSchema.optional(),
|
|
664
|
-
venue: z
|
|
665
|
-
.object({ table: refSchema, name: refSchema.optional(), address: refSchema.optional(), phone: refSchema.optional() })
|
|
666
|
-
.strict()
|
|
667
|
-
.optional(),
|
|
668
|
-
link: z.string().max(200).optional(),
|
|
669
|
-
})
|
|
670
|
-
.strict()
|
|
671
|
-
.optional(),
|
|
672
|
-
})
|
|
673
|
-
.strict();
|
|
927
|
+
/** What the app's public screens may do (see `public-access.ts`). */
|
|
928
|
+
export { publicAccessSchema, publicKeysSchema };
|
|
674
929
|
/** The bundled sample data file, inside the package's `seeds/` folder. */
|
|
675
930
|
export const sampleDataSchema = z
|
|
676
931
|
.object({
|
|
@@ -720,24 +975,154 @@ export function prefixFor(appKey) {
|
|
|
720
975
|
export const MAX_TABLE_NAME = 63;
|
|
721
976
|
/**
|
|
722
977
|
* Every name a manifest's own blocks use must name something the manifest
|
|
723
|
-
* declares: a rule's columns, a capacity's
|
|
724
|
-
*
|
|
725
|
-
*
|
|
978
|
+
* declares: a rule's columns, a capacity's or a booking's columns and tables,
|
|
979
|
+
* a public table and the columns it reads and writes, the outbox and its
|
|
980
|
+
* templates. Checked here so an app's CI refuses a typo long before an
|
|
981
|
+
* operator's install would.
|
|
726
982
|
*/
|
|
727
|
-
export function appReferenceIssues(m
|
|
983
|
+
export function appReferenceIssues(m,
|
|
984
|
+
/**
|
|
985
|
+
* When the tables are an add-on's shape rather than an app's: the add-on's
|
|
986
|
+
* own key and setting keys, which its rules may read, and no roles to name.
|
|
987
|
+
*/
|
|
988
|
+
shapeOf) {
|
|
728
989
|
const out = [];
|
|
990
|
+
const index = tableIndex(m.requiredSchema.tables);
|
|
729
991
|
const tables = new Map(m.requiredSchema.tables.map((t) => [t.ref, t]));
|
|
730
|
-
const has =
|
|
992
|
+
const has = index.has;
|
|
993
|
+
/** A setting a rule reads must be there when the rule runs. */
|
|
994
|
+
const settingIssue = (source, path) => {
|
|
995
|
+
if ('addOn' in source) {
|
|
996
|
+
if (shapeOf !== undefined) {
|
|
997
|
+
if (source.addOn !== shapeOf.addOn)
|
|
998
|
+
out.push({ path, message: `a shape reads only its own add-on's settings, not "${source.addOn}"` });
|
|
999
|
+
else if (!shapeOf.settings.includes(source.setting))
|
|
1000
|
+
out.push({ path, message: `"${source.addOn}" has no setting "${source.setting}"` });
|
|
1001
|
+
}
|
|
1002
|
+
else if (!requiresAddOn(m.addOns, source.addOn)) {
|
|
1003
|
+
out.push({ path, message: `"${source.addOn}" is not required by the app (addOns.requires), so its setting may not be there` });
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
else if (!has(source.table, source.column)) {
|
|
1007
|
+
out.push({ path, message: `"${source.table}" has no column "${source.column}"` });
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
/** The tables the app's signed-in people are rows of (its claim identities). */
|
|
1011
|
+
const identityTables = new Set((m.publicAccess ?? []).filter((e) => e.claim !== undefined).map((e) => e.table));
|
|
1012
|
+
/** Per table, the columns Adminium decides and no writer may set. */
|
|
1013
|
+
const decided = new Map();
|
|
1014
|
+
const decide = (table, column) => {
|
|
1015
|
+
const set = decided.get(table) ?? new Set();
|
|
1016
|
+
set.add(column);
|
|
1017
|
+
decided.set(table, set);
|
|
1018
|
+
};
|
|
731
1019
|
m.requiredSchema.tables.forEach((table, t) => {
|
|
732
1020
|
const at = (...rest) => ['requiredSchema', 'tables', t, ...rest];
|
|
733
1021
|
if (m.requiredSchema.prefixed === true && prefixFor(m.key).length + table.ref.length > MAX_TABLE_NAME) {
|
|
734
1022
|
out.push({ path: at('ref'), message: `"${prefixFor(m.key)}${table.ref}" is longer than ${String(MAX_TABLE_NAME)} characters` });
|
|
735
1023
|
}
|
|
1024
|
+
const balances = new Map();
|
|
1025
|
+
table.columns.forEach((column) => {
|
|
1026
|
+
const balance = column.rules?.rollup?.balance;
|
|
1027
|
+
if (balance !== undefined)
|
|
1028
|
+
balances.set(balance.column, column.ref);
|
|
1029
|
+
});
|
|
736
1030
|
table.columns.forEach((column, c) => {
|
|
737
1031
|
const rules = column.rules;
|
|
738
1032
|
if (rules === undefined)
|
|
739
1033
|
return;
|
|
740
1034
|
const here = (...rest) => at('columns', c, 'rules', ...rest);
|
|
1035
|
+
const deciders = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default'].filter((name) => rules[name] !== undefined);
|
|
1036
|
+
if (deciders.length > 0)
|
|
1037
|
+
decide(table.ref, column.ref);
|
|
1038
|
+
// One rule decides a column; two would race for its value. A copy with
|
|
1039
|
+
// a default behind it is the one pair that means something.
|
|
1040
|
+
const racing = deciders.filter((name) => !(name === 'default' && rules.copy !== undefined) && !(name === 'copy' && rules.default !== undefined));
|
|
1041
|
+
if (racing.length > 1 && rules.stamp === undefined) {
|
|
1042
|
+
out.push({ path: here(), message: `a column is decided by one rule, and this one has ${racing.join(', ')}` });
|
|
1043
|
+
}
|
|
1044
|
+
if (rules.normalize !== undefined && column.type !== 'text') {
|
|
1045
|
+
out.push({ path: here('normalize'), message: 'only text is stored trimmed or in lower case' });
|
|
1046
|
+
}
|
|
1047
|
+
const dated = (type) => type === 'date' || type === 'timestamptz';
|
|
1048
|
+
if ((rules.notAfter !== undefined || rules.notBefore !== undefined) && !dated(column.type)) {
|
|
1049
|
+
out.push({ path: here(rules.notAfter !== undefined ? 'notAfter' : 'notBefore'), message: 'only a date is kept within dates' });
|
|
1050
|
+
}
|
|
1051
|
+
if (rules.notBefore !== undefined) {
|
|
1052
|
+
const bound = rules.notBefore;
|
|
1053
|
+
let owner = table.ref;
|
|
1054
|
+
if (bound.via !== undefined) {
|
|
1055
|
+
const via = index.column(table.ref, bound.via);
|
|
1056
|
+
if (via?.type !== 'fk' || via.references === undefined) {
|
|
1057
|
+
out.push({ path: here('notBefore', 'via'), message: `"${table.ref}.${bound.via}" is not a foreign key` });
|
|
1058
|
+
owner = '';
|
|
1059
|
+
}
|
|
1060
|
+
else {
|
|
1061
|
+
owner = via.references;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (owner !== '') {
|
|
1065
|
+
const other = index.column(owner, bound.column);
|
|
1066
|
+
if (other === undefined)
|
|
1067
|
+
out.push({ path: here('notBefore', 'column'), message: `"${owner}" has no column "${bound.column}"` });
|
|
1068
|
+
else if (!dated(other.type))
|
|
1069
|
+
out.push({ path: here('notBefore', 'column'), message: `"${owner}.${bound.column}" is not a date` });
|
|
1070
|
+
else if (bound.via === undefined && other.ref === column.ref)
|
|
1071
|
+
out.push({ path: here('notBefore', 'column'), message: 'a date is bounded by another column' });
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (rules.default !== undefined) {
|
|
1075
|
+
const from = rules.default.from;
|
|
1076
|
+
if (from === 'connection.currency') {
|
|
1077
|
+
if (column.type !== 'text' || (column.maxLength !== undefined && column.maxLength < 3)) {
|
|
1078
|
+
out.push({ path: here('default', 'from'), message: 'a currency is three letters: a text column of at least 3 characters' });
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
settingIssue(from, here('default', 'from'));
|
|
1083
|
+
}
|
|
1084
|
+
if (column.nullable !== true)
|
|
1085
|
+
out.push({ path: here('default'), message: 'a column filled from elsewhere may start empty, so make it nullable' });
|
|
1086
|
+
}
|
|
1087
|
+
if (rules.sequence !== undefined) {
|
|
1088
|
+
const seq = rules.sequence;
|
|
1089
|
+
if (seq.start !== undefined && seq.startSetting !== undefined)
|
|
1090
|
+
out.push({ path: here('sequence'), message: 'a running number starts at start or at startSetting, not both' });
|
|
1091
|
+
if (seq.gapless === true) {
|
|
1092
|
+
if (column.type !== 'int' && column.type !== 'bigint')
|
|
1093
|
+
out.push({ path: here('sequence', 'gapless'), message: 'a number without gaps is a whole number: an int column' });
|
|
1094
|
+
// Rows added as samples, and rows imported with a number of their own, carry none.
|
|
1095
|
+
if (column.nullable !== true)
|
|
1096
|
+
out.push({ path: here('sequence', 'gapless'), message: 'a numbered column is nullable: sample rows carry no number' });
|
|
1097
|
+
}
|
|
1098
|
+
else if (seq.scope !== undefined || seq.startSetting !== undefined) {
|
|
1099
|
+
out.push({ path: here('sequence'), message: 'scope and startSetting number without gaps: add gapless' });
|
|
1100
|
+
}
|
|
1101
|
+
if (seq.startSetting !== undefined)
|
|
1102
|
+
settingIssue(seq.startSetting, here('sequence', 'startSetting'));
|
|
1103
|
+
if (seq.scope !== undefined) {
|
|
1104
|
+
const scope = index.column(table.ref, seq.scope);
|
|
1105
|
+
if (scope?.type !== 'fk')
|
|
1106
|
+
out.push({ path: here('sequence', 'scope'), message: `"${seq.scope}" is not a foreign key of "${table.ref}"` });
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (rules.format !== undefined) {
|
|
1110
|
+
const f = rules.format;
|
|
1111
|
+
const from = index.column(table.ref, f.from);
|
|
1112
|
+
if (from?.rules?.sequence === undefined)
|
|
1113
|
+
out.push({ path: here('format', 'from'), message: `"${f.from}" is not a running number of "${table.ref}"` });
|
|
1114
|
+
if (column.type !== 'text')
|
|
1115
|
+
out.push({ path: here('format'), message: 'a number with a prefix is written into a text column' });
|
|
1116
|
+
if (f.prefix !== undefined && f.prefixSetting !== undefined)
|
|
1117
|
+
out.push({ path: here('format'), message: 'a prefix is written here or read from a setting, not both' });
|
|
1118
|
+
if (f.prefixSetting !== undefined)
|
|
1119
|
+
settingIssue(f.prefixSetting, here('format', 'prefixSetting'));
|
|
1120
|
+
if (column.maxLength !== undefined && (f.prefix ?? '').length + (f.pad ?? 0) > column.maxLength) {
|
|
1121
|
+
out.push({ path: here('format'), message: `"${table.ref}.${column.ref}" holds ${String(column.maxLength)} characters, fewer than the prefix and the padding` });
|
|
1122
|
+
}
|
|
1123
|
+
if (column.nullable !== true)
|
|
1124
|
+
out.push({ path: here('format'), message: 'a numbered column is nullable: sample rows carry their own text' });
|
|
1125
|
+
}
|
|
741
1126
|
// A list the app ships, or one Adminium has built in; nothing else exists
|
|
742
1127
|
// on every install.
|
|
743
1128
|
if (rules.options !== undefined && 'list' in rules.options) {
|
|
@@ -770,7 +1155,161 @@ export function appReferenceIssues(m) {
|
|
|
770
1155
|
if (!has(r.from, name))
|
|
771
1156
|
out.push({ path: here('rollup'), message: `"${r.from}" has no column "${name}"` });
|
|
772
1157
|
}
|
|
1158
|
+
if (r.where !== undefined) {
|
|
1159
|
+
const filter = index.column(r.from, r.where.column);
|
|
1160
|
+
if (filter === undefined)
|
|
1161
|
+
out.push({ path: here('rollup', 'where'), message: `"${r.from}" has no column "${r.where.column}"` });
|
|
1162
|
+
else if (!valueFits(filter, r.where.eq))
|
|
1163
|
+
out.push({ path: here('rollup', 'where'), message: `${JSON.stringify(r.where.eq)} is not a value of "${r.from}.${r.where.column}"` });
|
|
1164
|
+
// An empty value equals nothing: a row left empty would drop out of the total unseen.
|
|
1165
|
+
else if (filter.nullable === true)
|
|
1166
|
+
out.push({ path: here('rollup', 'where'), message: `"${r.from}.${r.where.column}" may be empty, so a row could drop out of the total; make it not nullable` });
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
if (r.balance !== undefined) {
|
|
1170
|
+
const b = r.balance;
|
|
1171
|
+
decide(table.ref, b.column);
|
|
1172
|
+
for (const [name, ref] of [['column', b.column], ['of', b.of], ...(b.minus ?? []).map((x) => ['minus', x])]) {
|
|
1173
|
+
const found = index.column(table.ref, ref);
|
|
1174
|
+
if (found === undefined)
|
|
1175
|
+
out.push({ path: here('rollup', 'balance', name), message: `"${table.ref}" has no column "${ref}"` });
|
|
1176
|
+
else if (!NUMERIC_TYPES.includes(found.type))
|
|
1177
|
+
out.push({ path: here('rollup', 'balance', name), message: `"${table.ref}.${ref}" is not a number` });
|
|
1178
|
+
}
|
|
1179
|
+
if ([b.of, ...(b.minus ?? [])].includes(b.column) || b.column === column.ref) {
|
|
1180
|
+
out.push({ path: here('rollup', 'balance', 'column'), message: 'the balance is a column of its own' });
|
|
1181
|
+
}
|
|
1182
|
+
if (index.column(table.ref, b.column)?.rules !== undefined) {
|
|
1183
|
+
out.push({ path: here('rollup', 'balance', 'column'), message: `"${b.column}" is the balance Adminium keeps; it takes no rules of its own` });
|
|
1184
|
+
}
|
|
773
1185
|
}
|
|
1186
|
+
// A cap holds a balance at zero: this rollup's, or the one that takes
|
|
1187
|
+
// this total away (a write-off is capped by the balance it lowers).
|
|
1188
|
+
if (r.cap === true && r.balance === undefined) {
|
|
1189
|
+
const capped = table.columns.some((x) => x.rules?.rollup?.balance?.minus?.includes(column.ref) === true);
|
|
1190
|
+
if (!capped)
|
|
1191
|
+
out.push({ path: here('rollup', 'cap'), message: 'a cap needs a balance: declare one here, or subtract this total in one' });
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (rules.stamp !== undefined) {
|
|
1195
|
+
const stamp = rules.stamp;
|
|
1196
|
+
const set = stamp.set;
|
|
1197
|
+
if (set === 'now' && column.type !== 'timestamptz') {
|
|
1198
|
+
out.push({ path: here('stamp', 'set'), message: 'a "now" stamp needs a timestamptz column' });
|
|
1199
|
+
}
|
|
1200
|
+
else if (set === 'today' && column.type !== 'date') {
|
|
1201
|
+
out.push({ path: here('stamp', 'set'), message: 'a "today" stamp needs a date column' });
|
|
1202
|
+
}
|
|
1203
|
+
else if ((set === 'user-name' || set === 'user-id') && column.type !== 'text') {
|
|
1204
|
+
out.push({ path: here('stamp', 'set'), message: `a "${set}" stamp needs a text column` });
|
|
1205
|
+
}
|
|
1206
|
+
else if (typeof set === 'object' && 'byOrigin' in set) {
|
|
1207
|
+
for (const value of [set.byOrigin.public, ...(set.byOrigin.staff === undefined ? [] : [set.byOrigin.staff])]) {
|
|
1208
|
+
if (!valueFits(column, value))
|
|
1209
|
+
out.push({ path: here('stamp', 'set'), message: `"${value}" is not a value of "${table.ref}.${column.ref}"` });
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
else if (typeof set === 'object' && 'copy' in set) {
|
|
1213
|
+
const source = index.column(table.ref, set.copy);
|
|
1214
|
+
if (source === undefined)
|
|
1215
|
+
out.push({ path: here('stamp', 'set', 'copy'), message: `"${table.ref}" has no column "${set.copy}"` });
|
|
1216
|
+
else if (source.ref === column.ref)
|
|
1217
|
+
out.push({ path: here('stamp', 'set', 'copy'), message: 'a stamp copies another column' });
|
|
1218
|
+
else if (source.type !== column.type)
|
|
1219
|
+
out.push({ path: here('stamp', 'set', 'copy'), message: `"${table.ref}.${set.copy}" is a ${source.type}, and "${column.ref}" a ${column.type}` });
|
|
1220
|
+
}
|
|
1221
|
+
else if (typeof set === 'object' && 'claim' in set) {
|
|
1222
|
+
if (column.type !== 'text')
|
|
1223
|
+
out.push({ path: here('stamp', 'set'), message: 'a stamp from the signed-in person needs a text column' });
|
|
1224
|
+
if (![...identityTables].some((identity) => has(identity, set.claim))) {
|
|
1225
|
+
out.push({ path: here('stamp', 'set', 'claim'), message: `no table the app's people sign in as has a column "${set.claim}"` });
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
else if (typeof set === 'object' && 'addDays' in set) {
|
|
1229
|
+
const a = set.addDays;
|
|
1230
|
+
if (column.type !== 'date')
|
|
1231
|
+
out.push({ path: here('stamp', 'set'), message: 'a date worked out from another needs a date column' });
|
|
1232
|
+
const from = index.column(table.ref, a.date);
|
|
1233
|
+
if (from === undefined)
|
|
1234
|
+
out.push({ path: here('stamp', 'set', 'addDays', 'date'), message: `"${table.ref}" has no column "${a.date}"` });
|
|
1235
|
+
else if (from.type !== 'date' && from.type !== 'timestamptz')
|
|
1236
|
+
out.push({ path: here('stamp', 'set', 'addDays', 'date'), message: `"${table.ref}.${a.date}" is not a date` });
|
|
1237
|
+
if (typeof a.days === 'string') {
|
|
1238
|
+
const days = index.column(table.ref, a.days);
|
|
1239
|
+
if (days === undefined) {
|
|
1240
|
+
out.push({ path: here('stamp', 'set', 'addDays', 'days'), message: `"${table.ref}" has no column "${a.days}"` });
|
|
1241
|
+
}
|
|
1242
|
+
else if (days.type === 'enum' || days.type === 'text') {
|
|
1243
|
+
const missing = (days.enum ?? []).filter((value) => a.map?.[value] === undefined);
|
|
1244
|
+
if (a.map === undefined || missing.length > 0) {
|
|
1245
|
+
out.push({ path: here('stamp', 'set', 'addDays', 'map'), message: `each value of "${table.ref}.${a.days}" needs its days${missing.length > 0 ? ` (${missing.join(', ')})` : ''}` });
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
else if (days.type !== 'int' && days.type !== 'bigint') {
|
|
1249
|
+
out.push({ path: here('stamp', 'set', 'addDays', 'days'), message: `"${table.ref}.${a.days}" is neither a number of days nor a choice with a map` });
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
else if (typeof set === 'object' && 'hashOf' in set) {
|
|
1254
|
+
const h = set.hashOf;
|
|
1255
|
+
if (column.type !== 'text' || (column.maxLength !== undefined && column.maxLength < 64)) {
|
|
1256
|
+
out.push({ path: here('stamp', 'set'), message: 'a fingerprint is 64 characters: a text column of at least 64' });
|
|
1257
|
+
}
|
|
1258
|
+
for (const ref of h.columns)
|
|
1259
|
+
if (!has(table.ref, ref))
|
|
1260
|
+
out.push({ path: here('stamp', 'set', 'hashOf', 'columns'), message: `"${table.ref}" has no column "${ref}"` });
|
|
1261
|
+
const childIssues = (parent, child, path) => {
|
|
1262
|
+
if (index.table(child.table) === undefined) {
|
|
1263
|
+
out.push({ path, message: `"${child.table}" is not a table of this manifest` });
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
const via = index.column(child.table, child.via);
|
|
1267
|
+
if (via?.type !== 'fk' || via.references !== parent)
|
|
1268
|
+
out.push({ path, message: `"${child.table}.${child.via}" does not point at "${parent}"` });
|
|
1269
|
+
for (const ref of [...child.columns, ...(child.orderBy === undefined ? [] : [child.orderBy])]) {
|
|
1270
|
+
if (!has(child.table, ref))
|
|
1271
|
+
out.push({ path, message: `"${child.table}" has no column "${ref}"` });
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1274
|
+
(h.children ?? []).forEach((child, k) => childIssues(table.ref, child, here('stamp', 'set', 'hashOf', 'children', k)));
|
|
1275
|
+
(h.linked ?? []).forEach((link, k) => {
|
|
1276
|
+
const path = here('stamp', 'set', 'hashOf', 'linked', k);
|
|
1277
|
+
const via = index.column(table.ref, link.via);
|
|
1278
|
+
if (via?.type !== 'fk' || via.references !== link.table) {
|
|
1279
|
+
out.push({ path, message: `"${table.ref}.${link.via}" does not point at "${link.table}"` });
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
for (const ref of link.columns)
|
|
1283
|
+
if (!has(link.table, ref))
|
|
1284
|
+
out.push({ path, message: `"${link.table}" has no column "${ref}"` });
|
|
1285
|
+
(link.children ?? []).forEach((child, c2) => childIssues(link.table, child, [...path, 'children', c2]));
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
const triggers = Array.isArray(stamp.on) ? stamp.on : [stamp.on];
|
|
1289
|
+
triggers.forEach((trigger, k) => {
|
|
1290
|
+
if (trigger === 'create')
|
|
1291
|
+
return;
|
|
1292
|
+
const path = Array.isArray(stamp.on) ? here('stamp', 'on', k) : here('stamp', 'on');
|
|
1293
|
+
const watched = index.column(table.ref, trigger.column);
|
|
1294
|
+
if (watched === undefined) {
|
|
1295
|
+
out.push({ path: [...path, 'column'], message: `"${table.ref}" has no column "${trigger.column}"` });
|
|
1296
|
+
}
|
|
1297
|
+
else if (watched.ref === column.ref) {
|
|
1298
|
+
out.push({ path: [...path, 'column'], message: 'a stamp watches another column' });
|
|
1299
|
+
}
|
|
1300
|
+
else if ('values' in trigger) {
|
|
1301
|
+
for (const value of trigger.values) {
|
|
1302
|
+
if (!valueFits(watched, value))
|
|
1303
|
+
out.push({ path: [...path, 'values'], message: `${JSON.stringify(value)} is not a value of "${table.ref}.${watched.ref}"` });
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
else if (watched.nullable !== true) {
|
|
1307
|
+
out.push({ path: [...path, 'filled'], message: `"${table.ref}.${watched.ref}" is never empty, so it is never first filled` });
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
const others = ['copy', 'sequence', 'code', 'rollup', 'formula', 'format', 'default'].filter((name) => rules[name] !== undefined);
|
|
1311
|
+
if (others.length > 0)
|
|
1312
|
+
out.push({ path: here('stamp'), message: `a stamped column is not also decided by ${others.join(', ')}` });
|
|
774
1313
|
}
|
|
775
1314
|
if ((rules.sequence !== undefined || rules.code !== undefined) && column.role === 'pk') {
|
|
776
1315
|
out.push({ path: here(), message: 'a primary key numbers itself; it takes no sequence or code rule' });
|
|
@@ -792,64 +1331,45 @@ export function appReferenceIssues(m) {
|
|
|
792
1331
|
}
|
|
793
1332
|
}
|
|
794
1333
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
out.push({ path: at('table'), message: `"${entry.table}" is not a table of this app` });
|
|
801
|
-
return;
|
|
802
|
-
}
|
|
803
|
-
for (const list of ['select', 'writable']) {
|
|
804
|
-
for (const column of entry[list] ?? []) {
|
|
805
|
-
if (!has(entry.table, column))
|
|
806
|
-
out.push({ path: at(list), message: `"${entry.table}" has no column "${column}"` });
|
|
807
|
-
}
|
|
1334
|
+
if (table.booking !== undefined) {
|
|
1335
|
+
out.push(...bookingIssues(table, table.booking, index, at));
|
|
1336
|
+
// The late flag is Adminium's to set.
|
|
1337
|
+
if (table.booking.cancel?.flag !== undefined)
|
|
1338
|
+
decide(table.ref, table.booking.cancel.flag);
|
|
808
1339
|
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
out.push({ path: at('methods'), message: 'PATCH is allowed only with a claim' });
|
|
817
|
-
}
|
|
818
|
-
// Nothing a server decides may be written by a browser.
|
|
819
|
-
for (const column of entry.writable ?? []) {
|
|
820
|
-
const rules = table.columns.find((c) => c.ref === column)?.rules;
|
|
821
|
-
if (rules?.copy !== undefined || rules?.sequence !== undefined || rules?.code !== undefined || rules?.rollup !== undefined) {
|
|
822
|
-
out.push({ path: at('writable'), message: `"${column}" is decided by Adminium and cannot be written publicly` });
|
|
823
|
-
}
|
|
1340
|
+
out.push(...tableFormulaIssues(table.columns, (c, ...rest) => at('columns', c, ...rest)));
|
|
1341
|
+
if (table.states !== undefined) {
|
|
1342
|
+
out.push(...statesIssues(table.ref, table.states, {
|
|
1343
|
+
index,
|
|
1344
|
+
roles: shapeOf !== undefined ? undefined : (m.roles ?? []).map((role) => role.key),
|
|
1345
|
+
numbered: table.columns.some((c) => c.rules?.sequence?.gapless === true),
|
|
1346
|
+
}, (...rest) => at('states', ...rest)));
|
|
824
1347
|
}
|
|
825
|
-
if (
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
out.push({ path: at('confirm', name), message: `"${entry.table}" has no column "${column}"` });
|
|
830
|
-
}
|
|
831
|
-
if (!entry.methods.includes('POST'))
|
|
832
|
-
out.push({ path: at('confirm'), message: 'a confirmation is sent on a create, and this entry creates nothing' });
|
|
833
|
-
if (c.venue !== undefined) {
|
|
834
|
-
if (!tables.has(c.venue.table)) {
|
|
835
|
-
out.push({ path: at('confirm', 'venue', 'table'), message: `"${c.venue.table}" is not a table of this app` });
|
|
836
|
-
}
|
|
837
|
-
else {
|
|
838
|
-
for (const [name, column] of [['name', c.venue.name], ['address', c.venue.address], ['phone', c.venue.phone]]) {
|
|
839
|
-
if (column !== undefined && !has(c.venue.table, column)) {
|
|
840
|
-
out.push({ path: at('confirm', 'venue', name), message: `"${c.venue.table}" has no column "${column}"` });
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
}
|
|
1348
|
+
if (table.builtOn !== undefined && shapeOf === undefined) {
|
|
1349
|
+
const addOn = table.builtOn.split('/')[0];
|
|
1350
|
+
if (!requiresAddOn(m.addOns, addOn)) {
|
|
1351
|
+
out.push({ path: at('builtOn'), message: `"${addOn}" defines the shape, so the app requires it (addOns.requires)` });
|
|
844
1352
|
}
|
|
845
1353
|
}
|
|
846
|
-
if (entry.kind === 'availability') {
|
|
847
|
-
if (table.capacity === undefined)
|
|
848
|
-
out.push({ path: at('kind'), message: `"${entry.table}" declares no capacity to answer from` });
|
|
849
|
-
if (entry.methods.some((method) => method !== 'GET'))
|
|
850
|
-
out.push({ path: at('methods'), message: 'availability is read-only' });
|
|
851
|
-
}
|
|
852
1354
|
});
|
|
1355
|
+
out.push(...publicAccessIssues(m.publicAccess ?? [], {
|
|
1356
|
+
index,
|
|
1357
|
+
decided: (table) => decided.get(table) ?? new Set(),
|
|
1358
|
+
answersAvailability: (table) => tables.get(table)?.capacity !== undefined || tables.get(table)?.booking !== undefined,
|
|
1359
|
+
publicKeys: m.publicKeys,
|
|
1360
|
+
roles: m.roles ?? [],
|
|
1361
|
+
}));
|
|
1362
|
+
out.push(...outboxIssues(m, index));
|
|
1363
|
+
out.push(...roleLimitIssues(m.roles ?? [], index));
|
|
1364
|
+
if (shapeOf === undefined)
|
|
1365
|
+
out.push(...addOnNeedsIssues(m));
|
|
1366
|
+
if (m.documents !== undefined) {
|
|
1367
|
+
out.push(...appDocumentIssues(m.documents, {
|
|
1368
|
+
index,
|
|
1369
|
+
addOns: new Set([...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key)),
|
|
1370
|
+
features: new Set((m.addOns?.features ?? []).map((feature) => feature.id)),
|
|
1371
|
+
}));
|
|
1372
|
+
}
|
|
853
1373
|
return out;
|
|
854
1374
|
}
|
|
855
1375
|
export const appManifestSchema = z
|
|
@@ -879,7 +1399,17 @@ export const appManifestSchema = z
|
|
|
879
1399
|
/** Keyed by a kebab-case name; a column names one with `options: {list: name}`. */
|
|
880
1400
|
optionLists: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/, 'a list name is kebab-case'), optionListSchema).optional(),
|
|
881
1401
|
publicAccess: z.array(publicAccessSchema).max(32).optional(),
|
|
1402
|
+
/** Browser keys besides the app's own `customer` key (see `public-access.ts`). */
|
|
1403
|
+
publicKeys: publicKeysSchema.optional(),
|
|
1404
|
+
/** The app's emails: its outbox table and what queues rows in it (see `outbox.ts`). */
|
|
1405
|
+
outbox: outboxSchema.optional(),
|
|
1406
|
+
/** The templates the outbox sends, in each language the app ships. */
|
|
1407
|
+
emailTemplates: z.array(emailTemplateSchema).max(32).optional(),
|
|
882
1408
|
sampleData: sampleDataSchema.optional(),
|
|
1409
|
+
/** The add-ons the app needs, suggests, or needs for a feature (see `add-ons.ts`). */
|
|
1410
|
+
addOns: addOnsSchema.optional(),
|
|
1411
|
+
/** Document profiles on the app's own tables, drawn by an add-on (see `documents.ts`). */
|
|
1412
|
+
documents: z.array(appDocumentSchema).max(16).optional(),
|
|
883
1413
|
})
|
|
884
1414
|
.strict()
|
|
885
1415
|
.refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
|
|
@@ -889,6 +1419,10 @@ export const appManifestSchema = z
|
|
|
889
1419
|
for (const issue of appReferenceIssues(m)) {
|
|
890
1420
|
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
891
1421
|
}
|
|
1422
|
+
// A calendar page's named columns, against the app's own tables.
|
|
1423
|
+
for (const issue of pageCalendarIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
|
|
1424
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
1425
|
+
}
|
|
892
1426
|
});
|
|
893
1427
|
/**
|
|
894
1428
|
* `pages`, `roles` and `frontends` are absent from this branch on purpose, and
|
|
@@ -926,6 +1460,25 @@ export const addOnManifestSchema = z
|
|
|
926
1460
|
.refine((m) => m.requiredSchema?.prefixed !== true, {
|
|
927
1461
|
message: 'an add-on uses its host app\'s tables, so its own cannot be prefixed',
|
|
928
1462
|
path: ['requiredSchema', 'prefixed'],
|
|
1463
|
+
})
|
|
1464
|
+
.superRefine((m, ctx) => {
|
|
1465
|
+
// `addOn.shapes` is typed loosely in the contracts package (it cannot see
|
|
1466
|
+
// the column schema without importing this one); it is checked in full here.
|
|
1467
|
+
const raw = m.addOn.shapes ?? [];
|
|
1468
|
+
const shapes = [];
|
|
1469
|
+
raw.forEach((candidate, s) => {
|
|
1470
|
+
const parsed = shapeDefinitionSchema.safeParse(candidate);
|
|
1471
|
+
if (parsed.success)
|
|
1472
|
+
shapes.push(parsed.data);
|
|
1473
|
+
else
|
|
1474
|
+
for (const issue of parsed.error.issues)
|
|
1475
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: ['addOn', 'shapes', s, ...issue.path.map((p) => (typeof p === 'symbol' ? String(p) : p))] });
|
|
1476
|
+
});
|
|
1477
|
+
if (shapes.length !== raw.length)
|
|
1478
|
+
return;
|
|
1479
|
+
for (const issue of shapeDefinitionIssues(m.key, (m.settings ?? []).map((setting) => setting.key), shapes)) {
|
|
1480
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
1481
|
+
}
|
|
929
1482
|
});
|
|
930
1483
|
/**
|
|
931
1484
|
* The envelope. A document with no `kind` is treated as an app before the union
|
|
@@ -1039,19 +1592,4 @@ export function addOnIssues(m, ctx = {}) {
|
|
|
1039
1592
|
});
|
|
1040
1593
|
return out;
|
|
1041
1594
|
}
|
|
1042
|
-
/**
|
|
1043
|
-
* Numeric semver compare on the release triple (pre-release/build ignored —
|
|
1044
|
-
* enough for the compatibility-window and upgrade ordering checks). Returns
|
|
1045
|
-
* <0, 0, >0. Exported for the installer's upgrade rule.
|
|
1046
|
-
*/
|
|
1047
|
-
export function compareSemver(a, b) {
|
|
1048
|
-
const triple = (v) => v
|
|
1049
|
-
.split('+')[0]
|
|
1050
|
-
.split('-')[0]
|
|
1051
|
-
.split('.')
|
|
1052
|
-
.map((n) => Number.parseInt(n, 10));
|
|
1053
|
-
const [a1 = 0, a2 = 0, a3 = 0] = triple(a);
|
|
1054
|
-
const [b1 = 0, b2 = 0, b3 = 0] = triple(b);
|
|
1055
|
-
return a1 - b1 || a2 - b2 || a3 - b3;
|
|
1056
|
-
}
|
|
1057
1595
|
//# sourceMappingURL=schema.js.map
|