@adminiumjs/manifest 0.3.0 → 0.3.2
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/documents.d.ts +115 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +171 -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 +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/outbox.d.ts +354 -9
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +223 -9
- package/dist/outbox.js.map +1 -1
- package/dist/plan-model.d.ts +1 -1
- package/dist/plan-model.d.ts.map +1 -1
- package/dist/public-access.d.ts +48 -7
- package/dist/public-access.d.ts.map +1 -1
- package/dist/public-access.js +235 -28
- package/dist/public-access.js.map +1 -1
- package/dist/refs.d.ts +17 -0
- package/dist/refs.d.ts.map +1 -1
- package/dist/refs.js +12 -0
- package/dist/refs.js.map +1 -1
- package/dist/sample.d.ts +49 -1
- package/dist/sample.d.ts.map +1 -1
- package/dist/sample.js +39 -2
- package/dist/sample.js.map +1 -1
- package/dist/schema.d.ts +2501 -158
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +518 -26
- package/dist/schema.js.map +1 -1
- 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,12 +11,16 @@
|
|
|
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';
|
|
14
15
|
import { bookingIssues, bookingSchema } from './booking.js';
|
|
16
|
+
import { appDocumentIssues, appDocumentSchema, mappingIssues } from './documents.js';
|
|
17
|
+
import { formulaExprSchema, tableFormulaIssues } from './formula.js';
|
|
15
18
|
import { pageCalendarIssues } from './page-calendar.js';
|
|
16
|
-
import { emailTemplateSchema, outboxIssues, outboxSchema } from './outbox.js';
|
|
19
|
+
import { emailTemplateSchema, outboxIssues, outboxProducerSchema, outboxSchema } from './outbox.js';
|
|
17
20
|
import { publicAccessIssues, publicAccessSchema, publicKeysSchema } from './public-access.js';
|
|
18
21
|
import { roleLimitIssues, roleLimitsSchema } from './roles.js';
|
|
19
|
-
import {
|
|
22
|
+
import { statesIssues, statesSchema } from './states.js';
|
|
23
|
+
import { NUMERIC_TYPES, labelsSchema, numberOrSetting, refSchema, scalarSchema, settingSourceSchema, tableIndex, textOrLabels, valueFits, } from './refs.js';
|
|
20
24
|
import { compareSemver, parseSemverRange } from './semver.js';
|
|
21
25
|
export { compareSemver };
|
|
22
26
|
/** Integer spec version, frozen at 1 for Adminium 1.x. */
|
|
@@ -206,6 +210,71 @@ export { labelsSchema };
|
|
|
206
210
|
* - `personal`: whether the column is personal data, overriding the guess
|
|
207
211
|
* Adminium makes from its name.
|
|
208
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
|
+
]);
|
|
209
278
|
export const columnRulesSchema = z
|
|
210
279
|
.object({
|
|
211
280
|
options: z
|
|
@@ -245,14 +314,59 @@ export const columnRulesSchema = z
|
|
|
245
314
|
.object({ via: refSchema, from: refSchema, mode: z.enum(['default', 'always']).optional() })
|
|
246
315
|
.strict()
|
|
247
316
|
.optional(),
|
|
248
|
-
|
|
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(),
|
|
249
355
|
code: z
|
|
250
356
|
.object({
|
|
251
357
|
prefix: z.string().regex(/^[A-Z][A-Z0-9]{0,5}-?$/, 'an upper-case prefix, e.g. MR-').optional(),
|
|
252
|
-
length: z.number().int().min(4).max(
|
|
358
|
+
length: z.number().int().min(4).max(16),
|
|
253
359
|
})
|
|
254
360
|
.strict()
|
|
255
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(),
|
|
256
370
|
rollup: z
|
|
257
371
|
.object({
|
|
258
372
|
/** The child table, its foreign key back to this row, and what to add up. */
|
|
@@ -287,14 +401,8 @@ export const columnRulesSchema = z
|
|
|
287
401
|
*/
|
|
288
402
|
stamp: z
|
|
289
403
|
.object({
|
|
290
|
-
set:
|
|
291
|
-
|
|
292
|
-
z.object({ byOrigin: z.object({ public: z.string().min(1), staff: z.string().min(1) }).strict() }).strict(),
|
|
293
|
-
]),
|
|
294
|
-
on: z.union([
|
|
295
|
-
z.literal('create'),
|
|
296
|
-
z.object({ column: refSchema, values: z.array(scalarSchema).min(1).max(16) }).strict(),
|
|
297
|
-
]),
|
|
404
|
+
set: stampSetSchema,
|
|
405
|
+
on: z.union([stampTriggerSchema, z.array(stampTriggerSchema).min(2).max(3)]),
|
|
298
406
|
})
|
|
299
407
|
.strict()
|
|
300
408
|
.optional(),
|
|
@@ -306,6 +414,17 @@ export const columnRulesSchema = z
|
|
|
306
414
|
* mark it otherwise.
|
|
307
415
|
*/
|
|
308
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(),
|
|
309
428
|
})
|
|
310
429
|
.strict();
|
|
311
430
|
/**
|
|
@@ -411,6 +530,13 @@ export const requiredColumnSchema = z
|
|
|
411
530
|
* row, so a few long columns would refuse the table.
|
|
412
531
|
*/
|
|
413
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(),
|
|
414
540
|
/**
|
|
415
541
|
* No two rows may hold the same value (empty values excepted). A text
|
|
416
542
|
* column needs `maxLength`: MySQL indexes no unbounded text.
|
|
@@ -437,6 +563,10 @@ export const requiredColumnSchema = z
|
|
|
437
563
|
.refine((c) => c.maxLength === undefined || c.type === 'text', {
|
|
438
564
|
message: 'maxLength applies to a text column only',
|
|
439
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'],
|
|
440
570
|
})
|
|
441
571
|
.refine((c) => c.unique === undefined || (c.role !== 'pk' && !['json', 'blob'].includes(c.type) && (c.type !== 'text' || c.maxLength !== undefined)), {
|
|
442
572
|
message: 'unique needs a column that can be indexed: not the key, not json or blob, text with maxLength',
|
|
@@ -459,6 +589,21 @@ export const requiredTableSchema = z
|
|
|
459
589
|
capacity: capacitySchema.optional(),
|
|
460
590
|
/** Booking people: no two counted rows of one resource may overlap (see `booking.ts`). */
|
|
461
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(),
|
|
462
607
|
/**
|
|
463
608
|
* What a person calls ONE row ("Category") and the table ("Categories"),
|
|
464
609
|
* in every language the app speaks: a form's title and button, a page's
|
|
@@ -483,6 +628,14 @@ export const requiredTableSchema = z
|
|
|
483
628
|
.refine((t) => t.capacity === undefined || t.booking === undefined, {
|
|
484
629
|
message: 'a table has a capacity or a booking rule, not both',
|
|
485
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'],
|
|
486
639
|
});
|
|
487
640
|
export const requiredSchemaSchema = z
|
|
488
641
|
.object({
|
|
@@ -499,6 +652,99 @@ export const requiredSchemaSchema = z
|
|
|
499
652
|
message: 'duplicate table ref in requiredSchema',
|
|
500
653
|
path: ['tables'],
|
|
501
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
|
+
}
|
|
502
748
|
// ── pages ────────────────────────────────────────────────────────────────────
|
|
503
749
|
export const pageNavSchema = z
|
|
504
750
|
.object({
|
|
@@ -524,6 +770,8 @@ export const pageSchema = z
|
|
|
524
770
|
titles: z
|
|
525
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))
|
|
526
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(),
|
|
527
775
|
})
|
|
528
776
|
.strict();
|
|
529
777
|
// ── roles ────────────────────────────────────────────────────────────────────
|
|
@@ -732,11 +980,35 @@ export const MAX_TABLE_NAME = 63;
|
|
|
732
980
|
* templates. Checked here so an app's CI refuses a typo long before an
|
|
733
981
|
* operator's install would.
|
|
734
982
|
*/
|
|
735
|
-
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) {
|
|
736
989
|
const out = [];
|
|
737
990
|
const index = tableIndex(m.requiredSchema.tables);
|
|
738
991
|
const tables = new Map(m.requiredSchema.tables.map((t) => [t.ref, t]));
|
|
739
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));
|
|
740
1012
|
/** Per table, the columns Adminium decides and no writer may set. */
|
|
741
1013
|
const decided = new Map();
|
|
742
1014
|
const decide = (table, column) => {
|
|
@@ -760,8 +1032,96 @@ export function appReferenceIssues(m) {
|
|
|
760
1032
|
if (rules === undefined)
|
|
761
1033
|
return;
|
|
762
1034
|
const here = (...rest) => at('columns', c, 'rules', ...rest);
|
|
763
|
-
|
|
1035
|
+
const deciders = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default'].filter((name) => rules[name] !== undefined);
|
|
1036
|
+
if (deciders.length > 0)
|
|
764
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' });
|
|
765
1125
|
}
|
|
766
1126
|
// A list the app ships, or one Adminium has built in; nothing else exists
|
|
767
1127
|
// on every install.
|
|
@@ -837,31 +1197,117 @@ export function appReferenceIssues(m) {
|
|
|
837
1197
|
if (set === 'now' && column.type !== 'timestamptz') {
|
|
838
1198
|
out.push({ path: here('stamp', 'set'), message: 'a "now" stamp needs a timestamptz column' });
|
|
839
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
|
+
}
|
|
840
1203
|
else if ((set === 'user-name' || set === 'user-id') && column.type !== 'text') {
|
|
841
1204
|
out.push({ path: here('stamp', 'set'), message: `a "${set}" stamp needs a text column` });
|
|
842
1205
|
}
|
|
843
|
-
else if (typeof set === 'object') {
|
|
844
|
-
for (const value of [set.byOrigin.public, set.byOrigin.staff]) {
|
|
1206
|
+
else if (typeof set === 'object' && 'byOrigin' in set) {
|
|
1207
|
+
for (const value of [set.byOrigin.public, ...(set.byOrigin.staff === undefined ? [] : [set.byOrigin.staff])]) {
|
|
845
1208
|
if (!valueFits(column, value))
|
|
846
1209
|
out.push({ path: here('stamp', 'set'), message: `"${value}" is not a value of "${table.ref}.${column.ref}"` });
|
|
847
1210
|
}
|
|
848
1211
|
}
|
|
849
|
-
if (
|
|
850
|
-
const
|
|
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);
|
|
851
1294
|
if (watched === undefined) {
|
|
852
|
-
out.push({ path:
|
|
1295
|
+
out.push({ path: [...path, 'column'], message: `"${table.ref}" has no column "${trigger.column}"` });
|
|
853
1296
|
}
|
|
854
1297
|
else if (watched.ref === column.ref) {
|
|
855
|
-
out.push({ path:
|
|
1298
|
+
out.push({ path: [...path, 'column'], message: 'a stamp watches another column' });
|
|
856
1299
|
}
|
|
857
|
-
else {
|
|
858
|
-
for (const value of
|
|
1300
|
+
else if ('values' in trigger) {
|
|
1301
|
+
for (const value of trigger.values) {
|
|
859
1302
|
if (!valueFits(watched, value))
|
|
860
|
-
out.push({ path:
|
|
1303
|
+
out.push({ path: [...path, 'values'], message: `${JSON.stringify(value)} is not a value of "${table.ref}.${watched.ref}"` });
|
|
861
1304
|
}
|
|
862
1305
|
}
|
|
863
|
-
|
|
864
|
-
|
|
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);
|
|
865
1311
|
if (others.length > 0)
|
|
866
1312
|
out.push({ path: here('stamp'), message: `a stamped column is not also decided by ${others.join(', ')}` });
|
|
867
1313
|
}
|
|
@@ -891,6 +1337,20 @@ export function appReferenceIssues(m) {
|
|
|
891
1337
|
if (table.booking.cancel?.flag !== undefined)
|
|
892
1338
|
decide(table.ref, table.booking.cancel.flag);
|
|
893
1339
|
}
|
|
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)));
|
|
1347
|
+
}
|
|
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)` });
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
894
1354
|
});
|
|
895
1355
|
out.push(...publicAccessIssues(m.publicAccess ?? [], {
|
|
896
1356
|
index,
|
|
@@ -901,6 +1361,15 @@ export function appReferenceIssues(m) {
|
|
|
901
1361
|
}));
|
|
902
1362
|
out.push(...outboxIssues(m, index));
|
|
903
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
|
+
}
|
|
904
1373
|
return out;
|
|
905
1374
|
}
|
|
906
1375
|
export const appManifestSchema = z
|
|
@@ -935,8 +1404,12 @@ export const appManifestSchema = z
|
|
|
935
1404
|
/** The app's emails: its outbox table and what queues rows in it (see `outbox.ts`). */
|
|
936
1405
|
outbox: outboxSchema.optional(),
|
|
937
1406
|
/** The templates the outbox sends, in each language the app ships. */
|
|
938
|
-
emailTemplates: z.array(emailTemplateSchema).max(
|
|
1407
|
+
emailTemplates: z.array(emailTemplateSchema).max(32).optional(),
|
|
939
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(),
|
|
940
1413
|
})
|
|
941
1414
|
.strict()
|
|
942
1415
|
.refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
|
|
@@ -987,6 +1460,25 @@ export const addOnManifestSchema = z
|
|
|
987
1460
|
.refine((m) => m.requiredSchema?.prefixed !== true, {
|
|
988
1461
|
message: 'an add-on uses its host app\'s tables, so its own cannot be prefixed',
|
|
989
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
|
+
}
|
|
990
1482
|
});
|
|
991
1483
|
/**
|
|
992
1484
|
* The envelope. A document with no `kind` is treated as an app before the union
|