@adminiumjs/manifest 0.3.16 → 0.3.18-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adjust.d.ts +322 -0
- package/dist/adjust.d.ts.map +1 -0
- package/dist/adjust.js +512 -0
- package/dist/adjust.js.map +1 -0
- package/dist/automations.d.ts +2309 -0
- package/dist/automations.d.ts.map +1 -0
- package/dist/automations.js +384 -0
- package/dist/automations.js.map +1 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +1 -0
- package/dist/compose.js.map +1 -1
- package/dist/documents.d.ts +82 -1
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +36 -6
- package/dist/documents.js.map +1 -1
- package/dist/index.d.ts +15 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -6
- package/dist/index.js.map +1 -1
- package/dist/key-bytes.d.ts +27 -0
- package/dist/key-bytes.d.ts.map +1 -0
- package/dist/key-bytes.js +48 -0
- package/dist/key-bytes.js.map +1 -0
- package/dist/ledger-indexes.d.ts +19 -0
- package/dist/ledger-indexes.d.ts.map +1 -0
- package/dist/ledger-indexes.js +74 -0
- package/dist/ledger-indexes.js.map +1 -0
- package/dist/ledgers.d.ts +691 -0
- package/dist/ledgers.d.ts.map +1 -0
- package/dist/ledgers.js +778 -0
- package/dist/ledgers.js.map +1 -0
- package/dist/look-up.d.ts +73 -0
- package/dist/look-up.d.ts.map +1 -0
- package/dist/look-up.js +140 -0
- package/dist/look-up.js.map +1 -0
- package/dist/marketplace-wire.d.ts +2 -2
- package/dist/outbox.d.ts +29 -3
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +94 -15
- package/dist/outbox.js.map +1 -1
- package/dist/page-config.d.ts +99 -0
- package/dist/page-config.d.ts.map +1 -0
- package/dist/page-config.js +197 -0
- package/dist/page-config.js.map +1 -0
- package/dist/plan-context.d.ts +8 -18
- package/dist/plan-context.d.ts.map +1 -1
- package/dist/plan-context.js +53 -51
- package/dist/plan-context.js.map +1 -1
- package/dist/plan-model.d.ts +8 -1
- package/dist/plan-model.d.ts.map +1 -1
- package/dist/plan.d.ts +1 -1
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +6 -3
- package/dist/plan.js.map +1 -1
- package/dist/public-access.d.ts +33 -5
- package/dist/public-access.d.ts.map +1 -1
- package/dist/public-access.js +82 -4
- package/dist/public-access.js.map +1 -1
- package/dist/refs.d.ts +2 -0
- package/dist/refs.d.ts.map +1 -1
- package/dist/refs.js +5 -0
- package/dist/refs.js.map +1 -1
- package/dist/roles.d.ts +46 -0
- package/dist/roles.d.ts.map +1 -1
- package/dist/roles.js +50 -0
- package/dist/roles.js.map +1 -1
- package/dist/sample.d.ts +70 -1
- package/dist/sample.d.ts.map +1 -1
- package/dist/sample.js +208 -5
- package/dist/sample.js.map +1 -1
- package/dist/schema.d.ts +9657 -2440
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +751 -52
- package/dist/schema.js.map +1 -1
- package/dist/state-actions.d.ts +185 -0
- package/dist/state-actions.d.ts.map +1 -0
- package/dist/state-actions.js +223 -0
- package/dist/state-actions.js.map +1 -0
- package/dist/states.d.ts +66 -0
- package/dist/states.d.ts.map +1 -1
- package/dist/states.js +31 -0
- package/dist/states.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +22 -6
- package/dist/validate.js.map +1 -1
- package/dist/words.d.ts +33 -0
- package/dist/words.d.ts.map +1 -0
- package/dist/words.js +208 -0
- package/dist/words.js.map +1 -0
- package/package.json +2 -2
package/dist/schema.js
CHANGED
|
@@ -8,21 +8,27 @@
|
|
|
8
8
|
* The install PLANNER (planInstall / requiredSchema → create-or-map diff) and
|
|
9
9
|
* the server-side installer are later layers that consume a validated manifest.
|
|
10
10
|
*/
|
|
11
|
-
import { addOnBlockSchema, addOnCategorySchema, i18nMessageSchema, isSlotId, } from '@adminium/add-on-contracts';
|
|
11
|
+
import { addOnBlockSchema, addOnCategorySchema, i18nMessageSchema, isSlotId, recordTabIssues, } from '@adminium/add-on-contracts';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { addOnNeedsIssues, addOnsSchema, requiresAddOn } from './add-ons.js';
|
|
14
14
|
import { bookingIssues, bookingSchema } from './booking.js';
|
|
15
15
|
import { capacityIssues, capacitySchema, isLegacyCapacity, kindOf, rulesOf, viaIndexIssues } from './capacity.js';
|
|
16
16
|
import { appDocumentIssues, appDocumentSchema, mappingIssues } from './documents.js';
|
|
17
17
|
import { formulaColumns, formulaExprSchema, tableFormulaIssues } from './formula.js';
|
|
18
|
+
import { lookUpIssues, lookUpSchema } from './look-up.js';
|
|
18
19
|
import { pageCalendarIssues } from './page-calendar.js';
|
|
20
|
+
import { pageConfigIssues } from './page-config.js';
|
|
19
21
|
import { emailTemplateSchema, outboxIssues, outboxProducerSchema, outboxSchema } from './outbox.js';
|
|
20
22
|
import { codeWhereSchema, personalColumn, publicAccessIssues, publicAccessSchema, publicKeysSchema, shareCodeColumns, unlistedColumn } from './public-access.js';
|
|
21
|
-
import { roleLimitIssues, roleLimitsSchema } from './roles.js';
|
|
23
|
+
import { roleAddOnTableIssues, roleAddOnTablesSchema, roleLimitIssues, roleLimitsSchema } from './roles.js';
|
|
22
24
|
import { conditionIssues, stateConditionSchema, statesIssues, statesSchema } from './states.js';
|
|
23
25
|
import { MOMENT_LIMITS, clockTimeSchema, momentIssues, momentSchema, settingRefSchema } from './refs.js';
|
|
24
26
|
import { NUMERIC_TYPES, labelsSchema, refSchema, scalarSchema, settingSourceSchema, tableIndex, textOrLabels, valueFits, } from './refs.js';
|
|
25
27
|
import { compareSemver, parseSemverRange } from './semver.js';
|
|
28
|
+
import { installFloorWords } from './words.js';
|
|
29
|
+
import { automationIssues, manifestAutomationsSchema } from './automations.js';
|
|
30
|
+
import { adjustDecidedColumns, adjustIssues, adjustSchema, adjusterIssues, adjusterSchema } from './adjust.js';
|
|
31
|
+
import { ledgerIssues, ledgersSchema, postingIssues, postingsSchema, receiptTableIssues, wordsIssues, wordsListSchema } from './ledgers.js';
|
|
26
32
|
export { compareSemver };
|
|
27
33
|
/** Integer spec version, frozen at 1 for Adminium 1.x. */
|
|
28
34
|
export const MANIFEST_VERSION = 1;
|
|
@@ -383,6 +389,13 @@ const dateBoundSchema = z
|
|
|
383
389
|
strict: z.union([z.literal(true), z.array(stateConditionSchema).min(1).max(8)]).optional(),
|
|
384
390
|
})
|
|
385
391
|
.strict();
|
|
392
|
+
/** A table of an add-on, by the add-on's key and the table's own short name. */
|
|
393
|
+
export const addOnTableSchema = z
|
|
394
|
+
.object({
|
|
395
|
+
addOn: z.string().regex(/^[a-z][a-z0-9-]{1,79}$/, 'an add-on key'),
|
|
396
|
+
table: refSchema,
|
|
397
|
+
})
|
|
398
|
+
.strict();
|
|
386
399
|
export const columnRulesSchema = z
|
|
387
400
|
.object({
|
|
388
401
|
options: z
|
|
@@ -512,7 +525,8 @@ export const columnRulesSchema = z
|
|
|
512
525
|
lookup: z
|
|
513
526
|
.object({
|
|
514
527
|
from: refSchema,
|
|
515
|
-
table
|
|
528
|
+
/** One of the manifest's own tables, or a table of an add-on it names (the column then carries the same `addOnLink`). */
|
|
529
|
+
table: z.union([refSchema, addOnTableSchema]),
|
|
516
530
|
column: refSchema,
|
|
517
531
|
where: codeWhereSchema.optional(),
|
|
518
532
|
scope: z
|
|
@@ -547,6 +561,8 @@ export const columnRulesSchema = z
|
|
|
547
561
|
.optional(),
|
|
548
562
|
/** A child write that would take the balance below zero is refused. */
|
|
549
563
|
cap: z.literal(true).optional(),
|
|
564
|
+
/** With `cap`: the cap is not judged for a row whose yes/no `column` is true (an item that may be sold below zero). */
|
|
565
|
+
capUnless: z.object({ column: refSchema }).strict().optional(),
|
|
550
566
|
})
|
|
551
567
|
.strict()
|
|
552
568
|
.optional(),
|
|
@@ -604,6 +620,38 @@ export const columnRulesSchema = z
|
|
|
604
620
|
* (`clientKey`), on a table no public entry creates rows of.
|
|
605
621
|
*/
|
|
606
622
|
retryKey: z.literal(true).optional(),
|
|
623
|
+
/**
|
|
624
|
+
* A link into a table of an add-on the manifest names: the key of a row
|
|
625
|
+
* there. No foreign key is made, so the manifest installs whether or not
|
|
626
|
+
* the add-on is there; the link is read only while the add-on is
|
|
627
|
+
* installed, connected to this app and switched on.
|
|
628
|
+
*/
|
|
629
|
+
addOnLink: addOnTableSchema.optional(),
|
|
630
|
+
/**
|
|
631
|
+
* A text column that holds a table's stored name (`<maker>:<table>`, or
|
|
632
|
+
* the table's own name when no app made it). Adminium rewrites it when
|
|
633
|
+
* the table is renamed.
|
|
634
|
+
*/
|
|
635
|
+
tableRef: z.literal(true).optional(),
|
|
636
|
+
/**
|
|
637
|
+
* A formula column over a total of its own row whose change is told
|
|
638
|
+
* after the save commits (a stock level going low), so a rule or a
|
|
639
|
+
* screen may act on it. Up to four on a table.
|
|
640
|
+
*/
|
|
641
|
+
announce: z.literal(true).optional(),
|
|
642
|
+
/**
|
|
643
|
+
* Text a person types that may hold no link and no address, on every
|
|
644
|
+
* write whoever makes it: `true` for a name (no digits, 80 characters),
|
|
645
|
+
* or how many digits and characters a note may hold.
|
|
646
|
+
*/
|
|
647
|
+
plainText: z.union([z.literal(true), z.object({ digits: z.number().int().min(0).max(20).optional(), max: z.number().int().min(1).max(1000).optional() }).strict()]).optional(),
|
|
648
|
+
/**
|
|
649
|
+
* A keyed hash of the address in `of`, written by Adminium: what a
|
|
650
|
+
* customer is told apart by where the address itself must not travel.
|
|
651
|
+
*/
|
|
652
|
+
customerKey: z.object({ of: refSchema }).strict().optional(),
|
|
653
|
+
/** The last four characters of the code in `of`, written by Adminium: what a list shows of a code it never shows. */
|
|
654
|
+
codeLast4: z.object({ of: refSchema }).strict().optional(),
|
|
607
655
|
})
|
|
608
656
|
.strict();
|
|
609
657
|
/**
|
|
@@ -743,6 +791,8 @@ export const requiredColumnSchema = z
|
|
|
743
791
|
if (issue !== null)
|
|
744
792
|
ctx.addIssue({ code: 'custom', message: issue, path: ['default'] });
|
|
745
793
|
});
|
|
794
|
+
/** A table's declared plain indexes: up to six sets of 1 to 4 columns, in index order. */
|
|
795
|
+
export const tableIndexesSchema = z.array(z.array(refSchema).min(1).max(4)).min(1).max(6);
|
|
746
796
|
export const requiredTableSchema = z
|
|
747
797
|
.object({
|
|
748
798
|
ref: z.string().regex(/^[a-z][a-z0-9_]*$/, 'table ref must be a snake_case identifier'),
|
|
@@ -786,6 +836,15 @@ export const requiredTableSchema = z
|
|
|
786
836
|
* never collides.
|
|
787
837
|
*/
|
|
788
838
|
unique: z.array(z.array(refSchema).min(2).max(4)).min(1).max(8).optional(),
|
|
839
|
+
/**
|
|
840
|
+
* Plain indexes beside the ones Adminium makes for keys, links and unique
|
|
841
|
+
* sets: up to six sets of 1 to 4 columns a list is filtered or sorted by.
|
|
842
|
+
*/
|
|
843
|
+
indexes: tableIndexesSchema.optional(),
|
|
844
|
+
/** What a row of this table hands to an add-on's ledger, and when (see `ledgers.ts`). */
|
|
845
|
+
postings: postingsSchema.optional(),
|
|
846
|
+
/** Where an add-on's offers and codes lower this order's price (see `adjust.ts`). */
|
|
847
|
+
adjust: adjustSchema.optional(),
|
|
789
848
|
})
|
|
790
849
|
.strict()
|
|
791
850
|
.refine((t) => new Set(t.columns.map((c) => c.ref)).size === t.columns.length, { message: 'duplicate column ref in table', path: ['columns'] })
|
|
@@ -812,7 +871,46 @@ export const requiredTableSchema = z
|
|
|
812
871
|
.superRefine((t, ctx) => {
|
|
813
872
|
for (const issue of uniqueSetIssues(t))
|
|
814
873
|
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
874
|
+
for (const issue of indexSetIssues(t))
|
|
875
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
815
876
|
});
|
|
877
|
+
/**
|
|
878
|
+
* Everything wrong with a table's declared indexes: a column it lacks, a
|
|
879
|
+
* column named twice, a set given twice, a column no index can hold, and a
|
|
880
|
+
* set the database indexes already (a unique set, or one unique or key
|
|
881
|
+
* column).
|
|
882
|
+
*/
|
|
883
|
+
export function indexSetIssues(t) {
|
|
884
|
+
const out = [];
|
|
885
|
+
const keyOf = (set) => set.join('\u0000');
|
|
886
|
+
const uniques = new Set((t.unique ?? []).map((set) => keyOf([...set].sort())));
|
|
887
|
+
const seen = new Set();
|
|
888
|
+
(t.indexes ?? []).forEach((set, k) => {
|
|
889
|
+
const path = ['indexes', k];
|
|
890
|
+
if (new Set(set).size !== set.length)
|
|
891
|
+
out.push({ path, message: 'an index names each column once' });
|
|
892
|
+
// The order of an index's columns is part of it: (a, b) and (b, a) are two indexes.
|
|
893
|
+
if (seen.has(keyOf(set)))
|
|
894
|
+
out.push({ path, message: 'the same columns are indexed twice' });
|
|
895
|
+
seen.add(keyOf(set));
|
|
896
|
+
if (uniques.has(keyOf([...set].sort())))
|
|
897
|
+
out.push({ path, message: 'indexed already: these columns are a unique set' });
|
|
898
|
+
for (const ref of set) {
|
|
899
|
+
const column = t.columns.find((c) => c.ref === ref);
|
|
900
|
+
if (column === undefined) {
|
|
901
|
+
out.push({ path, message: `no column "${ref}" to index` });
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
const indexable = column.type !== 'json' && column.type !== 'blob' && (column.type !== 'text' || column.maxLength !== undefined || column.rules?.code !== undefined);
|
|
905
|
+
if (!indexable)
|
|
906
|
+
out.push({ path, message: `an index needs columns that can be indexed: not json or blob, text with maxLength ("${ref}")` });
|
|
907
|
+
if (set.length === 1 && (column.role === 'pk' || column.unique === true || column.rules?.code !== undefined)) {
|
|
908
|
+
out.push({ path, message: `indexed already: "${ref}" is unique` });
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
return out;
|
|
913
|
+
}
|
|
816
914
|
/**
|
|
817
915
|
* Everything wrong with a table's unique sets: a column it lacks, a column
|
|
818
916
|
* named twice, a set given twice, a column no index can hold, a set its own
|
|
@@ -878,11 +976,18 @@ export const shapePartSchema = z
|
|
|
878
976
|
.object({
|
|
879
977
|
columns: z.array(requiredColumnSchema).min(1).max(60),
|
|
880
978
|
states: statesSchema.optional(),
|
|
979
|
+
indexes: tableIndexesSchema.optional(),
|
|
980
|
+
postings: postingsSchema.optional(),
|
|
981
|
+
adjust: adjustSchema.optional(),
|
|
881
982
|
})
|
|
882
983
|
.strict()
|
|
883
984
|
.refine((p) => new Set(p.columns.map((c) => c.ref)).size === p.columns.length, {
|
|
884
985
|
message: 'duplicate column ref in part',
|
|
885
986
|
path: ['columns'],
|
|
987
|
+
})
|
|
988
|
+
.superRefine((p, ctx) => {
|
|
989
|
+
for (const issue of indexSetIssues(p))
|
|
990
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
886
991
|
});
|
|
887
992
|
/**
|
|
888
993
|
* A shape an add-on defines for apps to build their tables on (see
|
|
@@ -1001,6 +1106,8 @@ export const roleSchema = z
|
|
|
1001
1106
|
screensOnly: z.boolean().optional(),
|
|
1002
1107
|
/** Per table, what the role's update there may write (roles.ts). */
|
|
1003
1108
|
limits: roleLimitsSchema.optional(),
|
|
1109
|
+
/** Grants on tables of add-ons the app names (roles.ts): live while that add-on is connected to the app. */
|
|
1110
|
+
tables: roleAddOnTablesSchema.optional(),
|
|
1004
1111
|
})
|
|
1005
1112
|
.strict();
|
|
1006
1113
|
// ── settings ─────────────────────────────────────────────────────────────────
|
|
@@ -1158,6 +1265,14 @@ export const sampleDataSchema = z
|
|
|
1158
1265
|
})
|
|
1159
1266
|
.strict()
|
|
1160
1267
|
.optional(),
|
|
1268
|
+
/**
|
|
1269
|
+
* Rows for an add-on the app names, by the add-on's key: a second sample
|
|
1270
|
+
* file in the app's package (see `sample.ts`). Loaded while that add-on
|
|
1271
|
+
* is connected to the app, and removed with the app's sample data.
|
|
1272
|
+
*/
|
|
1273
|
+
addOns: z
|
|
1274
|
+
.record(z.string().regex(/^[a-z][a-z0-9-]{1,79}$/, 'an add-on key'), z.object({ file: z.string().regex(/^seeds\/[a-z0-9][a-z0-9._-]*\.json$/, 'a file in seeds/, ending .json') }).strict())
|
|
1275
|
+
.optional(),
|
|
1161
1276
|
})
|
|
1162
1277
|
.strict();
|
|
1163
1278
|
/** Hosted-only and offline-required are mutually exclusive. */
|
|
@@ -1734,7 +1849,9 @@ shapeOf) {
|
|
|
1734
1849
|
out.push({ path: here('requiredWhen'), message: 'a column required only sometimes may be empty the rest of the time, so make it nullable' });
|
|
1735
1850
|
if (rules.required === true)
|
|
1736
1851
|
out.push({ path: here('requiredWhen'), message: 'a column is required always, or only when another column says so, not both' });
|
|
1737
|
-
|
|
1852
|
+
// A copy that only fills what a write leaves out still leaves a person to ask when there is nothing to copy.
|
|
1853
|
+
const fillsWhatIsLeftOut = deciders.length === 1 && deciders[0] === 'copy' && (rules.copy?.mode ?? 'default') === 'default' && rules.copy?.follow !== true;
|
|
1854
|
+
if (deciders.length > 0 && !fillsWhatIsLeftOut)
|
|
1738
1855
|
out.push({ path: here('requiredWhen'), message: 'Adminium fills this column, so nobody is asked for it' });
|
|
1739
1856
|
}
|
|
1740
1857
|
if (rules.default !== undefined) {
|
|
@@ -1891,6 +2008,30 @@ shapeOf) {
|
|
|
1891
2008
|
if (!capped)
|
|
1892
2009
|
out.push({ path: here('rollup', 'cap'), message: 'a cap needs a balance: declare one here, or subtract this total in one' });
|
|
1893
2010
|
}
|
|
2011
|
+
if (r.capUnless !== undefined) {
|
|
2012
|
+
const flag = index.column(table.ref, r.capUnless.column);
|
|
2013
|
+
if (r.cap !== true)
|
|
2014
|
+
out.push({ path: here('rollup', 'capUnless'), message: 'capUnless lifts a cap: say "cap": true beside it' });
|
|
2015
|
+
if (flag === undefined)
|
|
2016
|
+
out.push({ path: here('rollup', 'capUnless', 'column'), message: `"${table.ref}" has no column "${r.capUnless.column}"` });
|
|
2017
|
+
else if (flag.type !== 'bool' || flag.nullable === true)
|
|
2018
|
+
out.push({ path: here('rollup', 'capUnless', 'column'), message: `"${table.ref}.${flag.ref}" says yes or no for every row: a bool that is not nullable` });
|
|
2019
|
+
}
|
|
2020
|
+
// One scale: a balance, what it starts from, what it takes away and what is summed are all the same decimals.
|
|
2021
|
+
if (r.balance !== undefined && child !== undefined && r.sum !== undefined) {
|
|
2022
|
+
const scaleOf = (found) => (found === undefined || !['decimal', 'money'].includes(found.type) ? undefined : (found.scale ?? 'default'));
|
|
2023
|
+
const parts = [
|
|
2024
|
+
[`${table.ref}.${r.balance.column}`, scaleOf(index.column(table.ref, r.balance.column))],
|
|
2025
|
+
[`${table.ref}.${r.balance.of}`, scaleOf(index.column(table.ref, r.balance.of))],
|
|
2026
|
+
...(r.balance.minus ?? []).map((ref) => [`${table.ref}.${ref}`, scaleOf(index.column(table.ref, ref))]),
|
|
2027
|
+
[`${table.ref}.${column.ref}`, scaleOf(column)],
|
|
2028
|
+
[`${r.from}.${r.sum}`, scaleOf(index.column(r.from, r.sum))],
|
|
2029
|
+
];
|
|
2030
|
+
const scales = new Set(parts.map(([, scale]) => scale).filter((scale) => scale !== undefined));
|
|
2031
|
+
if (scales.size > 1 && r.times === undefined) {
|
|
2032
|
+
out.push({ path: here('rollup', 'balance'), message: `a balance and its parts keep one scale: ${parts.filter(([, scale]) => scale !== undefined).map(([name, scale]) => `${name} ${scale === 'default' ? '(none said)' : String(scale)}`).join(', ')}` });
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
1894
2035
|
out.push(...rollupCountIssues(r, column.type, here));
|
|
1895
2036
|
}
|
|
1896
2037
|
if (rules.stamp !== undefined) {
|
|
@@ -2080,10 +2221,139 @@ shapeOf) {
|
|
|
2080
2221
|
if (rules.lookup !== undefined) {
|
|
2081
2222
|
out.push(...codeLookupIssues(table, column, rules.lookup, here('lookup'), { index, tables, shareCodes: (ref) => shareCodeColumns(m.publicAccess ?? [], ref) }));
|
|
2082
2223
|
}
|
|
2224
|
+
if (rules.addOnLink !== undefined) {
|
|
2225
|
+
const link = rules.addOnLink;
|
|
2226
|
+
if (!['int', 'bigint', 'text'].includes(column.type) || column.nullable !== true || column.references !== undefined) {
|
|
2227
|
+
out.push({ path: here('addOnLink'), message: `a link into an add-on is a nullable int, bigint or text column with no "references": the add-on may not be there` });
|
|
2228
|
+
}
|
|
2229
|
+
const others = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'perNight'].filter((name) => rules[name] !== undefined);
|
|
2230
|
+
if (others.length > 0)
|
|
2231
|
+
out.push({ path: here('addOnLink'), message: `a link into an add-on is filled by a person or a lookup, not by ${others.join(', ')}` });
|
|
2232
|
+
if (shapeOf !== undefined) {
|
|
2233
|
+
if (link.addOn !== shapeOf.addOn)
|
|
2234
|
+
out.push({ path: here('addOnLink', 'addOn'), message: `a shape links only into its own add-on's tables, not "${link.addOn}"` });
|
|
2235
|
+
}
|
|
2236
|
+
else if (link.addOn === m.key) {
|
|
2237
|
+
out.push({ path: here('addOnLink', 'addOn'), message: 'a link into its own table is a foreign key: use type "fk" and "references"' });
|
|
2238
|
+
}
|
|
2239
|
+
else if (![...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].some((need) => need.key === link.addOn)) {
|
|
2240
|
+
out.push({ path: here('addOnLink', 'addOn'), message: `"${link.addOn}" is not an add-on this manifest names: add it to addOns.requires or addOns.suggests` });
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
if (rules.tableRef !== undefined && (column.type !== 'text' || column.maxLength === undefined)) {
|
|
2244
|
+
out.push({ path: here('tableRef'), message: 'a table name is kept in a text column with maxLength' });
|
|
2245
|
+
}
|
|
2246
|
+
if (rules.plainText !== undefined && column.type !== 'text') {
|
|
2247
|
+
out.push({ path: here('plainText'), message: 'plain text is a rule of a text column' });
|
|
2248
|
+
}
|
|
2249
|
+
if (rules.announce !== undefined) {
|
|
2250
|
+
// Told from the settle, which has the row before and after only for a formula over one of its own totals.
|
|
2251
|
+
const reads = rules.formula === undefined ? [] : formulaColumns(rules.formula);
|
|
2252
|
+
const total = reads.some((ref) => {
|
|
2253
|
+
const read = index.column(table.ref, ref);
|
|
2254
|
+
return read?.rules?.rollup !== undefined || table.columns.some((x) => x.rules?.rollup?.balance?.column === ref);
|
|
2255
|
+
});
|
|
2256
|
+
if (rules.formula === undefined || !total) {
|
|
2257
|
+
out.push({ path: here('announce'), message: 'a change is announced of a formula column that reads a total of its own row (a rollup, or its balance)' });
|
|
2258
|
+
}
|
|
2259
|
+
if (column.type === 'money')
|
|
2260
|
+
out.push({ path: here('announce'), message: 'a money total is not announced: announce a flag or a state worked out from it (an int)' });
|
|
2261
|
+
}
|
|
2262
|
+
if (rules.customerKey !== undefined) {
|
|
2263
|
+
const of = index.column(table.ref, rules.customerKey.of);
|
|
2264
|
+
if (column.type !== 'text' || column.maxLength !== 64 || column.nullable !== true) {
|
|
2265
|
+
out.push({ path: here('customerKey'), message: 'a customer key is a nullable text column of 64 characters' });
|
|
2266
|
+
}
|
|
2267
|
+
if (of === undefined)
|
|
2268
|
+
out.push({ path: here('customerKey', 'of'), message: `"${table.ref}" has no column "${rules.customerKey.of}"` });
|
|
2269
|
+
else if (of.type !== 'text' || of.ref === column.ref)
|
|
2270
|
+
out.push({ path: here('customerKey', 'of'), message: `"${table.ref}.${of.ref}" is not a text column holding an address` });
|
|
2271
|
+
const others = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup', 'perNight'].filter((name) => rules[name] !== undefined);
|
|
2272
|
+
if (others.length > 0)
|
|
2273
|
+
out.push({ path: here('customerKey'), message: `a customer key is worked out from its address, not also decided by ${others.join(', ')}` });
|
|
2274
|
+
decide(table.ref, column.ref);
|
|
2275
|
+
}
|
|
2276
|
+
if (rules.codeLast4 !== undefined) {
|
|
2277
|
+
const of = index.column(table.ref, rules.codeLast4.of);
|
|
2278
|
+
if (column.type !== 'text' || column.nullable !== true)
|
|
2279
|
+
out.push({ path: here('codeLast4'), message: 'the last four of a code are kept in a nullable text column' });
|
|
2280
|
+
if (of === undefined)
|
|
2281
|
+
out.push({ path: here('codeLast4', 'of'), message: `"${table.ref}" has no column "${rules.codeLast4.of}"` });
|
|
2282
|
+
else if (of.rules?.code === undefined || of.ref === column.ref)
|
|
2283
|
+
out.push({ path: here('codeLast4', 'of'), message: `"${table.ref}.${of.ref}" is not a code Adminium makes (rules.code)` });
|
|
2284
|
+
const others = ['copy', 'sequence', 'code', 'rollup', 'stamp', 'formula', 'format', 'default', 'lookup', 'perNight'].filter((name) => rules[name] !== undefined);
|
|
2285
|
+
if (others.length > 0)
|
|
2286
|
+
out.push({ path: here('codeLast4'), message: `the last four of a code are copied from it, not also decided by ${others.join(', ')}` });
|
|
2287
|
+
decide(table.ref, column.ref);
|
|
2288
|
+
}
|
|
2083
2289
|
});
|
|
2084
2290
|
if (table.columns.filter((column) => column.rules?.lookup !== undefined).length > 2) {
|
|
2085
2291
|
out.push({ path: at('columns'), message: 'a table resolves at most two typed codes' });
|
|
2086
2292
|
}
|
|
2293
|
+
if (table.columns.filter((column) => column.rules?.announce !== undefined).length > 4) {
|
|
2294
|
+
out.push({ path: at('columns'), message: 'a table announces at most four columns' });
|
|
2295
|
+
}
|
|
2296
|
+
if (table.adjust !== undefined) {
|
|
2297
|
+
// The reductions, who gave one, whether the customer was proved, the links a typed code fills: all Adminium's.
|
|
2298
|
+
for (const [of, columns] of adjustDecidedColumns(table.ref, table.adjust))
|
|
2299
|
+
for (const ref of columns)
|
|
2300
|
+
decide(of, ref);
|
|
2301
|
+
const named = new Set([...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key));
|
|
2302
|
+
out.push(...adjustIssues(table, table.adjust, {
|
|
2303
|
+
key: shapeOf?.addOn ?? m.key,
|
|
2304
|
+
kind: shapeOf !== undefined || m.ledgers !== undefined ? 'add-on' : 'app',
|
|
2305
|
+
tables: tables,
|
|
2306
|
+
named,
|
|
2307
|
+
features: new Map((m.addOns?.features ?? []).map((feature) => [feature.id, feature.requires])),
|
|
2308
|
+
formulaReads: (of, ref) => {
|
|
2309
|
+
const formula = index.column(of, ref)?.rules?.formula;
|
|
2310
|
+
return formula === undefined ? null : formulaColumns(formula);
|
|
2311
|
+
},
|
|
2312
|
+
publicWritable: (of) => new Set((m.publicAccess ?? []).filter((entry) => entry.table === of).flatMap((entry) => entry.writable ?? [])),
|
|
2313
|
+
}, at));
|
|
2314
|
+
}
|
|
2315
|
+
if (table.postings !== undefined) {
|
|
2316
|
+
// What a posting makes Adminium's own: how long a hold lasts, and an amount its ledger decides — on the row, or on the parent its lines belong to.
|
|
2317
|
+
for (const [p, posting] of table.postings.entries()) {
|
|
2318
|
+
const parentRef = posting.via === undefined ? undefined : table.columns.find((x) => x.ref === posting.via)?.references;
|
|
2319
|
+
// A receipt names its parent row, its line and its rule, not the lines' table: two tables of lines under one parent need two rule ids.
|
|
2320
|
+
if (parentRef !== undefined) {
|
|
2321
|
+
const twin = [...tables.values()].find((other) => other.ref < table.ref && (other.postings ?? []).some((theirs) => theirs.id === posting.id && theirs.via !== undefined && other.columns.find((x) => x.ref === theirs.via)?.references === parentRef));
|
|
2322
|
+
if (twin !== undefined)
|
|
2323
|
+
out.push({ path: at('postings', p, 'id'), message: `"${twin.ref}" has a posting "${posting.id}" for lines of "${parentRef}" too: give the two different ids` });
|
|
2324
|
+
}
|
|
2325
|
+
const own = (mapping) => {
|
|
2326
|
+
if (typeof mapping === 'string')
|
|
2327
|
+
decide(table.ref, mapping);
|
|
2328
|
+
else if (parentRef !== undefined && typeof mapping === 'object' && mapping !== null && 'parent' in mapping)
|
|
2329
|
+
decide(parentRef, String(mapping.parent));
|
|
2330
|
+
};
|
|
2331
|
+
if (posting.heldUntil !== undefined)
|
|
2332
|
+
own(posting.heldUntil);
|
|
2333
|
+
const action = (m.ledgers ?? []).find((ledger) => ledger.id === posting.into.ledger && posting.into.addOn === m.key)?.actions[posting.into.action];
|
|
2334
|
+
for (const rule of action?.decides ?? []) {
|
|
2335
|
+
const mapping = posting.map[rule.input];
|
|
2336
|
+
if (mapping === undefined)
|
|
2337
|
+
continue;
|
|
2338
|
+
own(mapping);
|
|
2339
|
+
// Adminium writes the amount there: never over the row's key, nor over the state its moves are judged by.
|
|
2340
|
+
if (typeof mapping !== 'string')
|
|
2341
|
+
continue;
|
|
2342
|
+
if (table.columns.find((x) => x.ref === mapping)?.role === 'pk')
|
|
2343
|
+
out.push({ path: at('postings', p, 'map', rule.input), message: `"${rule.input}" is decided by Adminium and written to "${table.ref}.${mapping}", which is the row's key` });
|
|
2344
|
+
else if (table.states?.column === mapping)
|
|
2345
|
+
out.push({ path: at('postings', p, 'map', rule.input), message: `"${rule.input}" is decided by Adminium and written to "${table.ref}.${mapping}", which keeps the row's state` });
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
out.push(...postingIssues(table, {
|
|
2349
|
+
key: shapeOf?.addOn ?? m.key,
|
|
2350
|
+
kind: shapeOf !== undefined || m.ledgers !== undefined ? 'add-on' : 'app',
|
|
2351
|
+
tables: tables,
|
|
2352
|
+
named: new Set([...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key)),
|
|
2353
|
+
features: new Map((m.addOns?.features ?? []).map((feature) => [feature.id, feature.requires])),
|
|
2354
|
+
ledgers: m.ledgers ?? [],
|
|
2355
|
+
}, at));
|
|
2356
|
+
}
|
|
2087
2357
|
if (table.capacity !== undefined)
|
|
2088
2358
|
out.push(...capacityIssues(table, table.capacity, index, at));
|
|
2089
2359
|
if (table.booking !== undefined) {
|
|
@@ -2105,6 +2375,8 @@ shapeOf) {
|
|
|
2105
2375
|
bookedOf: (ref) => tables.get(ref)?.booking !== undefined,
|
|
2106
2376
|
lineOf: (ref) => [...tables.values()].find((other) => other.states?.children?.[ref] !== undefined)?.ref,
|
|
2107
2377
|
outboxTable: m.outbox?.table,
|
|
2378
|
+
pages: new Set((m.pages ?? []).flatMap((page) => ('ref' in page && typeof page.ref === 'string' ? [page.ref] : []))),
|
|
2379
|
+
codePages: new Set(m.codePages ?? []),
|
|
2108
2380
|
}, (...rest) => at('states', ...rest)));
|
|
2109
2381
|
// A late move's flag is Adminium's to set, as a booking's is.
|
|
2110
2382
|
for (const late of table.states.late ?? [])
|
|
@@ -2123,6 +2395,7 @@ shapeOf) {
|
|
|
2123
2395
|
index,
|
|
2124
2396
|
decided: (table) => decided.get(table) ?? new Set(),
|
|
2125
2397
|
answersAvailability: (table) => tables.get(table)?.capacity !== undefined || tables.get(table)?.booking !== undefined,
|
|
2398
|
+
addOns: new Set([...(m.kind === 'add-on' ? [m.key] : []), ...[...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key)]),
|
|
2126
2399
|
capacityOf: (table) => tables.get(table)?.capacity,
|
|
2127
2400
|
figures: figureColumns(m.requiredSchema.tables),
|
|
2128
2401
|
mailsOnCreate: (table) => (m.outbox?.producers ?? []).some((producer) => 'onCreate' in producer && producer.onCreate.table === table),
|
|
@@ -2135,12 +2408,36 @@ shapeOf) {
|
|
|
2135
2408
|
out.push(...slotPartyIssues(m));
|
|
2136
2409
|
out.push(...outboxIssues(m, index));
|
|
2137
2410
|
out.push(...roleLimitIssues(m.roles ?? [], index));
|
|
2411
|
+
// A role's grants on an add-on's tables: an app's to give, on the add-ons it names.
|
|
2412
|
+
if ((m.roles ?? []).some((role) => role.tables !== undefined)) {
|
|
2413
|
+
const roles = (m.roles ?? []);
|
|
2414
|
+
if (m.kind === 'add-on') {
|
|
2415
|
+
roles.forEach((role, r) => {
|
|
2416
|
+
if (role.tables !== undefined)
|
|
2417
|
+
out.push({ path: ['roles', r, 'tables'], message: 'an add-on\'s role grants its own tables (permissions): "tables" is how an app\'s role reaches an add-on\'s' });
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
else {
|
|
2421
|
+
out.push(...roleAddOnTableIssues(roles, new Set([...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key))));
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
// The rules it ships: about its own tables, roles and templates, and only what a rule can do.
|
|
2425
|
+
if (m.automations !== undefined) {
|
|
2426
|
+
out.push(...automationIssues({
|
|
2427
|
+
automations: m.automations,
|
|
2428
|
+
tables: m.requiredSchema.tables,
|
|
2429
|
+
roles: (m.roles ?? []).map((role) => role.key),
|
|
2430
|
+
templates: m.emailTemplates ?? [],
|
|
2431
|
+
decided: (table) => decided.get(table) ?? new Set(),
|
|
2432
|
+
}));
|
|
2433
|
+
}
|
|
2138
2434
|
if (shapeOf === undefined)
|
|
2139
2435
|
out.push(...addOnNeedsIssues(m));
|
|
2140
2436
|
if (m.documents !== undefined) {
|
|
2141
2437
|
out.push(...appDocumentIssues(m.documents, {
|
|
2142
2438
|
index,
|
|
2143
|
-
|
|
2439
|
+
// An add-on draws its own documents itself, and another add-on's only when it suggests that one.
|
|
2440
|
+
addOns: new Set([...(m.kind === 'add-on' ? [m.key] : []), ...[...(m.addOns?.requires ?? []), ...(m.addOns?.suggests ?? [])].map((need) => need.key)]),
|
|
2144
2441
|
features: new Set((m.addOns?.features ?? []).map((feature) => feature.id)),
|
|
2145
2442
|
perNight: (ref) => new Map((tables.get(ref)?.columns ?? []).flatMap((column) => column.rules?.perNight === undefined ? [] : [[column.ref, { rateVia: column.rules.perNight.rate.via }]])),
|
|
2146
2443
|
unlisted: (table, column) => unlistedColumn(m, table, column),
|
|
@@ -2209,7 +2506,8 @@ function settingTableIssues(m) {
|
|
|
2209
2506
|
const tables = m.requiredSchema.tables;
|
|
2210
2507
|
const settingsTable = m.outbox?.settings?.table;
|
|
2211
2508
|
const oneRow = (ref) => {
|
|
2212
|
-
|
|
2509
|
+
// An add-on's settings table is one row by its own word: the install makes the row, and it may link to others.
|
|
2510
|
+
if (ref === settingsTable || ref === m.addOn?.settingsTable)
|
|
2213
2511
|
return true;
|
|
2214
2512
|
const table = tables.find((t) => t.ref === ref);
|
|
2215
2513
|
if (table === undefined)
|
|
@@ -2412,8 +2710,19 @@ function codeRenewIssues(table, column, renew, path) {
|
|
|
2412
2710
|
*/
|
|
2413
2711
|
function codeLookupIssues(table, column, lookup, path, ctx) {
|
|
2414
2712
|
const out = [];
|
|
2415
|
-
|
|
2416
|
-
|
|
2713
|
+
const into = lookup.table;
|
|
2714
|
+
if (typeof into !== 'string') {
|
|
2715
|
+
// The other table is an add-on's: this manifest cannot see it, so the link says where it goes.
|
|
2716
|
+
const link = column.rules?.addOnLink;
|
|
2717
|
+
if (link === undefined || link.addOn !== into.addOn || link.table !== into.table) {
|
|
2718
|
+
out.push({
|
|
2719
|
+
path: [...path, 'table'],
|
|
2720
|
+
message: `a lookup into "${into.addOn}"'s table "${into.table}" fills a column that links there: give "${table.ref}.${column.ref}" rules.addOnLink {"addOn": "${into.addOn}", "table": "${into.table}"}`,
|
|
2721
|
+
});
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
else if (column.type !== 'fk' || column.nullable !== true || column.references !== into) {
|
|
2725
|
+
out.push({ path: [...path, 'table'], message: `a lookup fills this table's link to "${into}": the column is a nullable foreign key to it` });
|
|
2417
2726
|
}
|
|
2418
2727
|
const typed = table.columns.find((c) => c.ref === lookup.from);
|
|
2419
2728
|
if (typed === undefined) {
|
|
@@ -2427,15 +2736,23 @@ function codeLookupIssues(table, column, lookup, path, ctx) {
|
|
|
2427
2736
|
decidedByRules(typed.rules)) {
|
|
2428
2737
|
out.push({ path: [...path, 'from'], message: 'the code is typed into a nullable text column of up to 64 characters' });
|
|
2429
2738
|
}
|
|
2430
|
-
|
|
2739
|
+
if (typeof into !== 'string') {
|
|
2740
|
+
// What the code is found by is the add-on's to keep; only this table's side of a scope is checked here.
|
|
2741
|
+
(lookup.scope ?? []).forEach((scope, k) => {
|
|
2742
|
+
if (ctx.index.column(table.ref, scope.equals) === undefined)
|
|
2743
|
+
out.push({ path: [...path, 'scope', k, 'equals'], message: `"${table.ref}" has no column "${scope.equals}"` });
|
|
2744
|
+
});
|
|
2745
|
+
return out;
|
|
2746
|
+
}
|
|
2747
|
+
const target = ctx.tables.get(into);
|
|
2431
2748
|
if (target === undefined) {
|
|
2432
|
-
out.push({ path: [...path, 'table'], message: `"${
|
|
2749
|
+
out.push({ path: [...path, 'table'], message: `"${into}" is not a table of this app` });
|
|
2433
2750
|
return out;
|
|
2434
2751
|
}
|
|
2435
2752
|
const found = target.columns.find((c) => c.ref === lookup.column);
|
|
2436
2753
|
const scopeColumns = (lookup.scope ?? []).map((s) => s.column);
|
|
2437
2754
|
if (found === undefined) {
|
|
2438
|
-
out.push({ path: [...path, 'column'], message: `"${
|
|
2755
|
+
out.push({ path: [...path, 'column'], message: `"${into}" has no column "${lookup.column}"` });
|
|
2439
2756
|
}
|
|
2440
2757
|
else {
|
|
2441
2758
|
// A set of columns unique together counts when its other columns are the scope's.
|
|
@@ -2445,46 +2762,79 @@ function codeLookupIssues(table, column, lookup, path, ctx) {
|
|
|
2445
2762
|
return others.length === scopeColumns.length && others.every((ref) => scopeColumns.includes(ref));
|
|
2446
2763
|
});
|
|
2447
2764
|
if (found.type !== 'text' || (found.unique !== true && found.rules?.code === undefined && !scoped)) {
|
|
2448
|
-
out.push({ path: [...path, 'column'], message: `a code finds one row: make "${
|
|
2765
|
+
out.push({ path: [...path, 'column'], message: `a code finds one row: make "${into}.${found.ref}" unique (or unique with its scope)` });
|
|
2449
2766
|
}
|
|
2450
2767
|
if (found.rules?.code === undefined && found.rules?.normalize !== 'code') {
|
|
2451
|
-
out.push({ path: [...path, 'column'], message: `"${
|
|
2768
|
+
out.push({ path: [...path, 'column'], message: `"${into}.${found.ref}" is compared as a code: give it normalize "code"` });
|
|
2452
2769
|
}
|
|
2453
|
-
if (ctx.shareCodes(
|
|
2770
|
+
if (ctx.shareCodes(into).includes(found.ref)) {
|
|
2454
2771
|
out.push({ path: [...path, 'column'], message: "a shared link's code is never looked up" });
|
|
2455
2772
|
}
|
|
2456
2773
|
}
|
|
2457
2774
|
(lookup.where ?? []).forEach((condition, k) => {
|
|
2458
2775
|
const at = [...path, 'where', k];
|
|
2459
|
-
const filter = ctx.index.column(
|
|
2776
|
+
const filter = ctx.index.column(into, condition.column);
|
|
2460
2777
|
if (filter === undefined) {
|
|
2461
|
-
out.push({ path: [...at, 'column'], message: `"${
|
|
2778
|
+
out.push({ path: [...at, 'column'], message: `"${into}" has no column "${condition.column}"` });
|
|
2462
2779
|
}
|
|
2463
2780
|
else if ('eq' in condition) {
|
|
2464
2781
|
if (!valueFits(filter, condition.eq))
|
|
2465
|
-
out.push({ path: [...at, 'eq'], message: `${JSON.stringify(condition.eq)} is not a value of "${
|
|
2782
|
+
out.push({ path: [...at, 'eq'], message: `${JSON.stringify(condition.eq)} is not a value of "${into}.${filter.ref}"` });
|
|
2466
2783
|
}
|
|
2467
2784
|
else if (filter.type !== 'date' && filter.type !== 'timestamptz') {
|
|
2468
|
-
out.push({ path: [...at, 'column'], message: `"${
|
|
2785
|
+
out.push({ path: [...at, 'column'], message: `"${into}.${filter.ref}" is not a date` });
|
|
2469
2786
|
}
|
|
2470
2787
|
});
|
|
2471
2788
|
(lookup.scope ?? []).forEach((scope, k) => {
|
|
2472
2789
|
const at = [...path, 'scope', k];
|
|
2473
|
-
const theirs = ctx.index.column(
|
|
2790
|
+
const theirs = ctx.index.column(into, scope.column);
|
|
2474
2791
|
const ours = ctx.index.column(table.ref, scope.equals);
|
|
2475
2792
|
if (theirs === undefined)
|
|
2476
|
-
out.push({ path: [...at, 'column'], message: `"${
|
|
2793
|
+
out.push({ path: [...at, 'column'], message: `"${into}" has no column "${scope.column}"` });
|
|
2477
2794
|
if (ours === undefined)
|
|
2478
2795
|
out.push({ path: [...at, 'equals'], message: `"${table.ref}" has no column "${scope.equals}"` });
|
|
2479
2796
|
if (theirs !== undefined && ours !== undefined && (theirs.type !== ours.type || theirs.references !== ours.references)) {
|
|
2480
|
-
out.push({ path: at, message: `"${table.ref}.${ours.ref}" and "${
|
|
2797
|
+
out.push({ path: at, message: `"${table.ref}.${ours.ref}" and "${into}.${theirs.ref}" hold different things` });
|
|
2481
2798
|
}
|
|
2482
2799
|
if (scope.orEmpty === true && theirs !== undefined && theirs.nullable !== true) {
|
|
2483
|
-
out.push({ path: [...at, 'orEmpty'], message: `"${
|
|
2800
|
+
out.push({ path: [...at, 'orEmpty'], message: `"${into}.${theirs.ref}" is never empty` });
|
|
2484
2801
|
}
|
|
2485
2802
|
});
|
|
2486
2803
|
return out;
|
|
2487
2804
|
}
|
|
2805
|
+
/**
|
|
2806
|
+
* The version of Adminium that first installs an add-on the way it installs
|
|
2807
|
+
* an app: with pages, roles, rules on its own tables and the rest of
|
|
2808
|
+
* `installBlocksShape`. A manifest that uses one of those words declares at
|
|
2809
|
+
* least this in `compatibility.minAdminiumVersion`, so an older server
|
|
2810
|
+
* answers "needs a newer Adminium" and never "unrecognized key".
|
|
2811
|
+
*/
|
|
2812
|
+
export const ADD_ON_INSTALL_FLOOR = '0.3.18';
|
|
2813
|
+
/**
|
|
2814
|
+
* The blocks an app and an add-on declare in the same words: what Adminium
|
|
2815
|
+
* makes at install beside the tables. Both branches spread it, so a block
|
|
2816
|
+
* added here is read the same way from either kind.
|
|
2817
|
+
*/
|
|
2818
|
+
const installBlocksShape = {
|
|
2819
|
+
roles: z.array(roleSchema).optional(),
|
|
2820
|
+
/** Rows a table starts with: written at install, only into a table that holds none. */
|
|
2821
|
+
seeds: z.array(seedSchema).optional(),
|
|
2822
|
+
navGroups: z.array(navGroupSchema).max(12).optional(),
|
|
2823
|
+
/** Keyed by a kebab-case name; a column names one with `options: {list: name}`. */
|
|
2824
|
+
optionLists: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/, 'a list name is kebab-case'), optionListSchema).optional(),
|
|
2825
|
+
publicAccess: z.array(publicAccessSchema).max(64).optional(),
|
|
2826
|
+
/** Browser keys besides the app's own `customer` key (see `public-access.ts`). */
|
|
2827
|
+
publicKeys: publicKeysSchema.optional(),
|
|
2828
|
+
/** The emails: an outbox table and what queues rows in it (see `outbox.ts`). */
|
|
2829
|
+
outbox: outboxSchema.optional(),
|
|
2830
|
+
/** The templates the outbox sends, in each language shipped. */
|
|
2831
|
+
emailTemplates: z.array(emailTemplateSchema).max(32).optional(),
|
|
2832
|
+
sampleData: sampleDataSchema.optional(),
|
|
2833
|
+
/** Document profiles on the manifest's own tables, drawn by an add-on (see `documents.ts`). */
|
|
2834
|
+
documents: z.array(appDocumentSchema).max(16).optional(),
|
|
2835
|
+
/** Rules the manifest ships: installed switched as it says, the owner's to switch or copy (see `automations.ts`). */
|
|
2836
|
+
automations: manifestAutomationsSchema.optional(),
|
|
2837
|
+
};
|
|
2488
2838
|
export const appManifestSchema = z
|
|
2489
2839
|
.object({
|
|
2490
2840
|
kind: z.literal('app'),
|
|
@@ -2493,9 +2843,8 @@ export const appManifestSchema = z
|
|
|
2493
2843
|
compatibility: compatibilitySchema,
|
|
2494
2844
|
requiredSchema: requiredSchemaSchema,
|
|
2495
2845
|
pages: z.array(pageSchema).min(1),
|
|
2496
|
-
roles: z.array(roleSchema).optional(),
|
|
2497
2846
|
settings: z.array(settingSchema).optional(),
|
|
2498
|
-
|
|
2847
|
+
...installBlocksShape,
|
|
2499
2848
|
widgets: z.array(manifestWidgetSchema).optional(),
|
|
2500
2849
|
capabilities: z.array(capabilitySchema).optional(),
|
|
2501
2850
|
/**
|
|
@@ -2508,27 +2857,16 @@ export const appManifestSchema = z
|
|
|
2508
2857
|
* required-singular shape however their `requiredSchema` was repaired.
|
|
2509
2858
|
*/
|
|
2510
2859
|
frontends: z.array(frontendSchema).min(1),
|
|
2511
|
-
navGroups: z.array(navGroupSchema).max(12).optional(),
|
|
2512
|
-
/** Keyed by a kebab-case name; a column names one with `options: {list: name}`. */
|
|
2513
|
-
optionLists: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/, 'a list name is kebab-case'), optionListSchema).optional(),
|
|
2514
|
-
publicAccess: z.array(publicAccessSchema).max(64).optional(),
|
|
2515
|
-
/** Browser keys besides the app's own `customer` key (see `public-access.ts`). */
|
|
2516
|
-
publicKeys: publicKeysSchema.optional(),
|
|
2517
|
-
/** The app's emails: its outbox table and what queues rows in it (see `outbox.ts`). */
|
|
2518
|
-
outbox: outboxSchema.optional(),
|
|
2519
|
-
/** The templates the outbox sends, in each language the app ships. */
|
|
2520
|
-
emailTemplates: z.array(emailTemplateSchema).max(32).optional(),
|
|
2521
|
-
sampleData: sampleDataSchema.optional(),
|
|
2522
2860
|
/** The add-ons the app needs, suggests, or needs for a feature (see `add-ons.ts`). */
|
|
2523
2861
|
addOns: addOnsSchema.optional(),
|
|
2524
|
-
/** Document profiles on the app's own tables, drawn by an add-on (see `documents.ts`). */
|
|
2525
|
-
documents: z.array(appDocumentSchema).max(16).optional(),
|
|
2526
2862
|
})
|
|
2527
2863
|
.strict()
|
|
2528
2864
|
.refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
|
|
2529
2865
|
.refine(compatibilityWindowOrdered, { ...WINDOW_MESSAGE, path: [...WINDOW_MESSAGE.path] })
|
|
2530
2866
|
.refine(sidesAreDistinct, { ...SIDES_MESSAGE, path: [...SIDES_MESSAGE.path] })
|
|
2531
2867
|
.superRefine((m, ctx) => {
|
|
2868
|
+
for (const issue of installFloorIssues(m))
|
|
2869
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
2532
2870
|
for (const issue of appReferenceIssues(m)) {
|
|
2533
2871
|
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
2534
2872
|
}
|
|
@@ -2536,22 +2874,28 @@ export const appManifestSchema = z
|
|
|
2536
2874
|
for (const issue of pageCalendarIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
|
|
2537
2875
|
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
2538
2876
|
}
|
|
2877
|
+
// A records page's tab words, filters and bulk actions, against the app's own tables.
|
|
2878
|
+
for (const issue of pageConfigIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
|
|
2879
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
2880
|
+
}
|
|
2539
2881
|
});
|
|
2540
2882
|
/**
|
|
2541
|
-
*
|
|
2542
|
-
*
|
|
2543
|
-
*
|
|
2883
|
+
* WHAT AN ADD-ON MAY DECLARE, AND WHAT IT STILL MAY NOT.
|
|
2884
|
+
*
|
|
2885
|
+
* An add-on that keeps tables of its own is installed the way an app is: it
|
|
2886
|
+
* may declare generated `pages` over those tables, `roles`, rules on its
|
|
2887
|
+
* columns, option lists, emails, documents, sample data and public entries —
|
|
2888
|
+
* the blocks of `installBlocksShape`, read by the same code as an app's.
|
|
2544
2889
|
*
|
|
2545
|
-
*
|
|
2546
|
-
*
|
|
2547
|
-
*
|
|
2548
|
-
*
|
|
2549
|
-
* `template` the engine renders with `bindings` and `config`, and an add-on
|
|
2550
|
-
* still cannot install one. Roles and frontends remain refused outright.
|
|
2890
|
+
* Two kinds of page now live in one add-on document, and they are different
|
|
2891
|
+
* things: a top-level `pages` row is a GENERATED page, a `template` the engine
|
|
2892
|
+
* renders with `bindings` and `config`; an `addOn.pages` row is CODE, a module
|
|
2893
|
+
* of the add-on's own bundle. Both are addressed under the add-on's key.
|
|
2551
2894
|
*
|
|
2552
|
-
*
|
|
2553
|
-
*
|
|
2554
|
-
*
|
|
2895
|
+
* `frontends` is absent on purpose: an add-on has no screens outside the
|
|
2896
|
+
* dashboard, and leaving the field off a `.strict()` schema is a stronger
|
|
2897
|
+
* guarantee than a lint rule. So are `addOns.requires` and `addOns.features`:
|
|
2898
|
+
* an add-on may suggest another, never need one.
|
|
2555
2899
|
*/
|
|
2556
2900
|
export const addOnManifestSchema = z
|
|
2557
2901
|
.object({
|
|
@@ -2566,13 +2910,33 @@ export const addOnManifestSchema = z
|
|
|
2566
2910
|
settings: z.array(settingSchema).optional(),
|
|
2567
2911
|
capabilities: z.array(capabilitySchema).optional(),
|
|
2568
2912
|
widgets: z.array(manifestWidgetSchema).optional(),
|
|
2913
|
+
/** Generated pages over the add-on's own tables. */
|
|
2914
|
+
pages: z.array(pageSchema).min(1).optional(),
|
|
2915
|
+
...installBlocksShape,
|
|
2916
|
+
/** Other add-ons this one works with when they are there. Never one it needs. */
|
|
2917
|
+
addOns: addOnsSchema.pick({ suggests: true }).strict().optional(),
|
|
2569
2918
|
})
|
|
2570
2919
|
.strict()
|
|
2571
2920
|
.refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
|
|
2572
2921
|
.refine(compatibilityWindowOrdered, { ...WINDOW_MESSAGE, path: [...WINDOW_MESSAGE.path] })
|
|
2573
|
-
.
|
|
2574
|
-
|
|
2575
|
-
|
|
2922
|
+
.superRefine((m, ctx) => {
|
|
2923
|
+
for (const issue of installFloorIssues(m))
|
|
2924
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
2925
|
+
// Add-ons released before the floor keep the page refs they shipped with.
|
|
2926
|
+
if (m.pages !== undefined || compareSemver(m.compatibility.minAdminiumVersion, ADD_ON_INSTALL_FLOOR) >= 0) {
|
|
2927
|
+
for (const issue of addOnPageRefIssues(m))
|
|
2928
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
2929
|
+
}
|
|
2930
|
+
// From the floor on a page's code is handed out behind the page's own permission; a slot's is handed to everybody signed in.
|
|
2931
|
+
if (compareSemver(m.compatibility.minAdminiumVersion, ADD_ON_INSTALL_FLOOR) >= 0) {
|
|
2932
|
+
for (const issue of sharedPageFileIssues(m))
|
|
2933
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
2934
|
+
}
|
|
2935
|
+
if (!installsLikeAnApp(m))
|
|
2936
|
+
return;
|
|
2937
|
+
// An issue with a code of its own carries it in `params`, where the validator reads it back.
|
|
2938
|
+
for (const issue of addOnInstallIssues(m))
|
|
2939
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path, ...(issue.code === undefined ? {} : { params: { code: issue.code } }) });
|
|
2576
2940
|
})
|
|
2577
2941
|
.superRefine((m, ctx) => {
|
|
2578
2942
|
// `addOn.shapes` is typed loosely in the contracts package (it cannot see
|
|
@@ -2600,6 +2964,341 @@ export const addOnManifestSchema = z
|
|
|
2600
2964
|
export const manifestSchema = z.preprocess((v) => typeof v === 'object' && v !== null && !Array.isArray(v) && v.kind === undefined
|
|
2601
2965
|
? { ...v, kind: 'app' }
|
|
2602
2966
|
: v, z.discriminatedUnion('kind', [appManifestSchema, addOnManifestSchema]));
|
|
2967
|
+
/**
|
|
2968
|
+
* A word only a newer Adminium reads, under a floor that admits an older one:
|
|
2969
|
+
* that server would refuse the manifest as an unknown key, so the floor is
|
|
2970
|
+
* raised instead and it answers "needs a newer Adminium".
|
|
2971
|
+
*/
|
|
2972
|
+
function installFloorIssues(m) {
|
|
2973
|
+
const floor = m.compatibility.minAdminiumVersion;
|
|
2974
|
+
if (compareSemver(floor, ADD_ON_INSTALL_FLOOR) >= 0)
|
|
2975
|
+
return [];
|
|
2976
|
+
return installFloorWords(m).map((found) => ({
|
|
2977
|
+
path: found.path.split('.').map((part) => (/^\d+$/.test(part) ? Number(part) : part)),
|
|
2978
|
+
message: `"${found.word}" is read by Adminium ${ADD_ON_INSTALL_FLOOR} and later, and compatibility.minAdminiumVersion is ${floor}: set it to ${ADD_ON_INSTALL_FLOOR} or later`,
|
|
2979
|
+
}));
|
|
2980
|
+
}
|
|
2981
|
+
/** The top-level blocks an add-on declares in an app's words. */
|
|
2982
|
+
const ADD_ON_BLOCKS = ['pages', 'roles', 'seeds', 'navGroups', 'optionLists', 'publicAccess', 'publicKeys', 'outbox', 'emailTemplates', 'sampleData', 'documents', 'automations', 'addOns'];
|
|
2983
|
+
/**
|
|
2984
|
+
* Whether an add-on is installed the way an app is: it declares a block an
|
|
2985
|
+
* app declares, its tables are prefixed, or one of its tables carries a rule
|
|
2986
|
+
* (a column rule, states, a limit, a booking rule, a unique set, an index).
|
|
2987
|
+
* An add-on that only keeps plain tables, or none, is not.
|
|
2988
|
+
*/
|
|
2989
|
+
export function installsLikeAnApp(m) {
|
|
2990
|
+
if (m.kind !== 'add-on')
|
|
2991
|
+
return false;
|
|
2992
|
+
if (ADD_ON_BLOCKS.some((block) => m[block] !== undefined))
|
|
2993
|
+
return true;
|
|
2994
|
+
if (m.requiredSchema?.prefixed === true || m.addOn.settingsTable !== undefined || m.addOn.ledgers !== undefined || m.addOn.adjuster !== undefined || m.addOn.words !== undefined || m.addOn.recordTabs !== undefined)
|
|
2995
|
+
return true;
|
|
2996
|
+
return (m.requiredSchema?.tables ?? []).some((table) => table.states !== undefined ||
|
|
2997
|
+
table.capacity !== undefined ||
|
|
2998
|
+
table.booking !== undefined ||
|
|
2999
|
+
table.unique !== undefined ||
|
|
3000
|
+
table.indexes !== undefined ||
|
|
3001
|
+
table.postings !== undefined ||
|
|
3002
|
+
table.adjust !== undefined ||
|
|
3003
|
+
table.columns.some((column) => column.rules !== undefined));
|
|
3004
|
+
}
|
|
3005
|
+
/** The directives a seed row's value may be: the installing person's language, or an earlier seed row. */
|
|
3006
|
+
const SEED_DIRECTIVES = new Set(['@t', '@ref']);
|
|
3007
|
+
/** The most rows one table is seeded with. */
|
|
3008
|
+
export const MAX_SEED_ROWS = 200;
|
|
3009
|
+
/**
|
|
3010
|
+
* Generated pages and code pages share one address space on a connection, so
|
|
3011
|
+
* each of an add-on's is named under its key, and once.
|
|
3012
|
+
*/
|
|
3013
|
+
function addOnPageRefIssues(m) {
|
|
3014
|
+
const out = [];
|
|
3015
|
+
const refs = new Set();
|
|
3016
|
+
const check = (ref, path) => {
|
|
3017
|
+
if (ref !== m.key && !ref.startsWith(`${m.key}-`)) {
|
|
3018
|
+
out.push({ path, message: `a page of "${m.key}" is addressed under its key: name it "${m.key}" or "${m.key}-…", not "${ref}"` });
|
|
3019
|
+
}
|
|
3020
|
+
if (refs.has(ref))
|
|
3021
|
+
out.push({ path, message: `the page ref "${ref}" is used twice` });
|
|
3022
|
+
refs.add(ref);
|
|
3023
|
+
};
|
|
3024
|
+
(m.pages ?? []).forEach((page, p) => check(page.ref, ['pages', p, 'ref']));
|
|
3025
|
+
(m.addOn.pages ?? []).forEach((page, p) => check(page.ref, ['addOn', 'pages', p, 'ref']));
|
|
3026
|
+
return out;
|
|
3027
|
+
}
|
|
3028
|
+
/**
|
|
3029
|
+
* A page's code is the page: it is served only to who may open the page. A
|
|
3030
|
+
* slot's code is served to every signed-in person. One file named as both
|
|
3031
|
+
* would be the page handed out with no permission asked, so a page has a
|
|
3032
|
+
* file no slot names.
|
|
3033
|
+
*/
|
|
3034
|
+
function sharedPageFileIssues(m) {
|
|
3035
|
+
const open = new Set((m.addOn.slots ?? []).map((slot) => slot.client));
|
|
3036
|
+
return (m.addOn.pages ?? []).flatMap((page, p) => open.has(page.client) ? [{ path: ['addOn', 'pages', p, 'client'], message: `the page "${page.ref}" is built into "${page.client}", which a slot loads too: a page's code is served only to who may open the page, so give the page a file of its own` }] : []);
|
|
3037
|
+
}
|
|
3038
|
+
/** An add-on's ledgers as the manifest's own schema reads them; what does not parse is reported and left out. */
|
|
3039
|
+
function parsedLedgers(raw, out) {
|
|
3040
|
+
const parsed = ledgersSchema.safeParse(raw);
|
|
3041
|
+
if (parsed.success)
|
|
3042
|
+
return parsed.data;
|
|
3043
|
+
for (const issue of parsed.error.issues) {
|
|
3044
|
+
out.push({ path: ['addOn', 'ledgers', ...issue.path.map((part) => (typeof part === 'symbol' ? String(part) : part))], message: issue.message });
|
|
3045
|
+
}
|
|
3046
|
+
return [];
|
|
3047
|
+
}
|
|
3048
|
+
/** An add-on's stock words, typed. Empty for an app, and for an add-on that declares none. */
|
|
3049
|
+
export function wordsOf(m) {
|
|
3050
|
+
if (m.kind !== 'add-on' || m.addOn.words === undefined)
|
|
3051
|
+
return [];
|
|
3052
|
+
const parsed = wordsListSchema.safeParse(m.addOn.words);
|
|
3053
|
+
return parsed.success ? parsed.data : [];
|
|
3054
|
+
}
|
|
3055
|
+
/** What an add-on lets a typed code find, typed; null for an app, and for an add-on that declares none. */
|
|
3056
|
+
export function lookUpOf(m) {
|
|
3057
|
+
if (m.kind !== 'add-on' || m.addOn.lookUp === undefined)
|
|
3058
|
+
return null;
|
|
3059
|
+
const parsed = lookUpSchema.safeParse(m.addOn.lookUp);
|
|
3060
|
+
return parsed.success ? parsed.data : null;
|
|
3061
|
+
}
|
|
3062
|
+
/** An add-on's adjuster, typed; null for an app, and for an add-on that declares none. */
|
|
3063
|
+
export function adjusterOf(m) {
|
|
3064
|
+
if (m.kind !== 'add-on' || m.addOn.adjuster === undefined)
|
|
3065
|
+
return null;
|
|
3066
|
+
const parsed = adjusterSchema.safeParse(m.addOn.adjuster);
|
|
3067
|
+
return parsed.success ? parsed.data : null;
|
|
3068
|
+
}
|
|
3069
|
+
/** An add-on's ledgers, typed. Empty for an app, and for an add-on that declares none. */
|
|
3070
|
+
export function ledgersOf(m) {
|
|
3071
|
+
if (m.kind !== 'add-on' || m.addOn.ledgers === undefined)
|
|
3072
|
+
return [];
|
|
3073
|
+
const parsed = ledgersSchema.safeParse(m.addOn.ledgers);
|
|
3074
|
+
return parsed.success ? parsed.data : [];
|
|
3075
|
+
}
|
|
3076
|
+
/** Everything wrong with an add-on that installs like an app, beyond what its blocks say of themselves. */
|
|
3077
|
+
function addOnInstallIssues(m) {
|
|
3078
|
+
const out = [];
|
|
3079
|
+
const tables = m.requiredSchema?.tables ?? [];
|
|
3080
|
+
const byRef = new Map(tables.map((table) => [table.ref, table]));
|
|
3081
|
+
// `addOn.ledgers` is typed loosely in the contracts package (it cannot see these words); it is checked in full here.
|
|
3082
|
+
const ledgers = m.addOn.ledgers === undefined ? [] : parsedLedgers(m.addOn.ledgers, out);
|
|
3083
|
+
out.push(...appReferenceIssues({ ...m, requiredSchema: m.requiredSchema ?? { tables: [] }, ledgers, codePages: (m.addOn.pages ?? []).map((page) => page.ref) }));
|
|
3084
|
+
if (m.pages !== undefined)
|
|
3085
|
+
out.push(...pageCalendarIssues(m.pages, tableIndex(tables)), ...pageConfigIssues(m.pages, tableIndex(tables)));
|
|
3086
|
+
// The look-up is typed loosely in the contracts package: its tables and columns are checked here.
|
|
3087
|
+
if (m.addOn.lookUp !== undefined) {
|
|
3088
|
+
const parsed = lookUpSchema.safeParse(m.addOn.lookUp);
|
|
3089
|
+
if (!parsed.success) {
|
|
3090
|
+
for (const issue of parsed.error.issues)
|
|
3091
|
+
out.push({ path: ['addOn', 'lookUp', ...issue.path.map((part) => (typeof part === 'symbol' ? String(part) : part))], message: issue.message });
|
|
3092
|
+
}
|
|
3093
|
+
else {
|
|
3094
|
+
out.push(...lookUpIssues(parsed.data, tables));
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
// Stock words: a question asked of one of the add-on's own ledgers.
|
|
3098
|
+
if (m.addOn.words !== undefined) {
|
|
3099
|
+
const parsed = wordsListSchema.safeParse(m.addOn.words);
|
|
3100
|
+
if (!parsed.success) {
|
|
3101
|
+
for (const issue of parsed.error.issues)
|
|
3102
|
+
out.push({ path: ['addOn', 'words', ...issue.path.map((part) => (typeof part === 'symbol' ? String(part) : part))], message: issue.message });
|
|
3103
|
+
}
|
|
3104
|
+
else {
|
|
3105
|
+
out.push(...wordsIssues({ words: parsed.data, ledgers, tables: tables, settingsTable: m.addOn.settingsTable }));
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
// Tabs that list the add-on's rows on other tables' record pages: its own tables, columns and words.
|
|
3109
|
+
if (m.addOn.recordTabs !== undefined) {
|
|
3110
|
+
out.push(...recordTabIssues({ tabs: m.addOn.recordTabs, tables: tables, words: wordsOf(m).map((words) => words.id) }));
|
|
3111
|
+
}
|
|
3112
|
+
// The adjuster is typed loosely in the contracts package too: its words are checked here.
|
|
3113
|
+
if (m.addOn.adjuster !== undefined) {
|
|
3114
|
+
const parsed = adjusterSchema.safeParse(m.addOn.adjuster);
|
|
3115
|
+
if (!parsed.success) {
|
|
3116
|
+
for (const issue of parsed.error.issues)
|
|
3117
|
+
out.push({ path: ['addOn', 'adjuster', ...issue.path.map((part) => (typeof part === 'symbol' ? String(part) : part))], message: issue.message });
|
|
3118
|
+
}
|
|
3119
|
+
else {
|
|
3120
|
+
out.push(...adjusterIssues(parsed.data, byRef));
|
|
3121
|
+
}
|
|
3122
|
+
const providers = (m.addOn.provides ?? []).filter((entry) => entry.contract === 'price-adjust');
|
|
3123
|
+
if (providers.length !== 1)
|
|
3124
|
+
out.push({ path: ['addOn', 'provides'], message: 'an add-on with an adjuster provides the contract "price-adjust" exactly once' });
|
|
3125
|
+
}
|
|
3126
|
+
// A ledger is served by the add-on's one `posting-rows` provider, and its receipt table is the one Adminium writes.
|
|
3127
|
+
if (m.addOn.ledgers !== undefined) {
|
|
3128
|
+
const providers = (m.addOn.provides ?? []).filter((entry) => entry.contract === 'posting-rows');
|
|
3129
|
+
if (providers.length !== 1)
|
|
3130
|
+
out.push({ path: ['addOn', 'provides'], message: 'an add-on with ledgers provides the contract "posting-rows" exactly once' });
|
|
3131
|
+
for (const ledger of ledgers) {
|
|
3132
|
+
const receipts = byRef.get(ledger.receipts);
|
|
3133
|
+
if (receipts !== undefined)
|
|
3134
|
+
out.push(...receiptTableIssues(receipts, (...rest) => ['requiredSchema', 'tables', tables.indexOf(receipts), ...rest]));
|
|
3135
|
+
}
|
|
3136
|
+
// A ledger stays inside its own tables: each issue carries its code.
|
|
3137
|
+
for (const issue of ledgerIssues({ tables: tables, ledgers, settingsTable: m.addOn.settingsTable })) {
|
|
3138
|
+
out.push({ path: issue.path, message: issue.message, code: issue.code });
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
// Blocks that name tables need tables to name.
|
|
3142
|
+
if (m.requiredSchema === undefined) {
|
|
3143
|
+
for (const block of ['pages', 'outbox', 'publicAccess', 'sampleData', 'documents', 'seeds']) {
|
|
3144
|
+
if (m[block] !== undefined)
|
|
3145
|
+
out.push({ path: [block], message: `"${block}" names tables, and this add-on declares none (requiredSchema)` });
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
// An entry's stored ref is its table's real name: only a prefixed add-on's can never meet an app's.
|
|
3149
|
+
if (m.publicAccess !== undefined && m.requiredSchema?.prefixed !== true) {
|
|
3150
|
+
out.push({ path: ['publicAccess'], message: 'an add-on with public entries prefixes its tables: set requiredSchema.prefixed to true' });
|
|
3151
|
+
}
|
|
3152
|
+
// Seeds: rows of an own table, in its own columns, with the two directives a seed takes.
|
|
3153
|
+
(m.seeds ?? []).forEach((seed, i) => {
|
|
3154
|
+
const table = byRef.get(seed.table);
|
|
3155
|
+
if (table === undefined) {
|
|
3156
|
+
if (m.requiredSchema !== undefined)
|
|
3157
|
+
out.push({ path: ['seeds', i, 'table'], message: `"${seed.table}" is not one of this add-on's tables` });
|
|
3158
|
+
return;
|
|
3159
|
+
}
|
|
3160
|
+
const columns = new Set(table.columns.map((column) => column.ref));
|
|
3161
|
+
// History is the sample's business, never a seed's: a ledger's rows come from postings.
|
|
3162
|
+
const ledger = ledgers.find((candidate) => candidate.receipts === seed.table || candidate.writes[seed.table] !== undefined);
|
|
3163
|
+
if (ledger !== undefined) {
|
|
3164
|
+
out.push({ path: ['seeds', i, 'table'], message: `"${seed.table}" is ${ledger.receipts === seed.table ? 'the receipt table' : 'a table'} of the ledger "${ledger.id}", which only postings write: a seed never fills it` });
|
|
3165
|
+
}
|
|
3166
|
+
if ((seed.rows?.length ?? 0) > MAX_SEED_ROWS)
|
|
3167
|
+
out.push({ path: ['seeds', i, 'rows'], message: `a table is seeded with at most ${String(MAX_SEED_ROWS)} rows` });
|
|
3168
|
+
(seed.rows ?? []).forEach((row, r) => {
|
|
3169
|
+
for (const [name, value] of Object.entries(row)) {
|
|
3170
|
+
const at = ['seeds', i, 'rows', r, name];
|
|
3171
|
+
if (name === '@label') {
|
|
3172
|
+
if (typeof value !== 'string')
|
|
3173
|
+
out.push({ path: at, message: 'a label is text' });
|
|
3174
|
+
continue;
|
|
3175
|
+
}
|
|
3176
|
+
if (name.startsWith('@')) {
|
|
3177
|
+
out.push({ path: at, message: `a seed row takes "@label" and no other row directive, not "${name}"` });
|
|
3178
|
+
continue;
|
|
3179
|
+
}
|
|
3180
|
+
if (!columns.has(name))
|
|
3181
|
+
out.push({ path: at, message: `"${seed.table}" has no column "${name}"` });
|
|
3182
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
3183
|
+
const directive = Object.keys(value).find((key) => key.startsWith('@'));
|
|
3184
|
+
if (directive !== undefined && !SEED_DIRECTIVES.has(directive)) {
|
|
3185
|
+
out.push({ path: at, message: `a seed value is a plain value, {"@t": {…}} or {"@ref": "<label>"}, not "${directive}"` });
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
});
|
|
3190
|
+
});
|
|
3191
|
+
// The settings table: one row, made from defaults, so every column must have one or may be empty.
|
|
3192
|
+
const settings = m.addOn.settingsTable;
|
|
3193
|
+
if (settings !== undefined) {
|
|
3194
|
+
const table = byRef.get(settings);
|
|
3195
|
+
if (table === undefined) {
|
|
3196
|
+
out.push({ path: ['addOn', 'settingsTable'], message: `"${settings}" is not one of this add-on's tables` });
|
|
3197
|
+
}
|
|
3198
|
+
else {
|
|
3199
|
+
table.columns.forEach((column, c) => {
|
|
3200
|
+
if (column.role !== undefined || column.nullable === true || column.default !== undefined || decidedByRules(column.rules))
|
|
3201
|
+
return;
|
|
3202
|
+
out.push({
|
|
3203
|
+
path: ['requiredSchema', 'tables', tables.indexOf(table), 'columns', c],
|
|
3204
|
+
message: `the settings row is made from defaults at install: give "${settings}.${column.ref}" a default, or make it nullable`,
|
|
3205
|
+
});
|
|
3206
|
+
});
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
// An add-on has no `customer` key of its own: its entries ride its app's key, or its one link key.
|
|
3210
|
+
const keyNames = Object.keys(m.publicKeys ?? {});
|
|
3211
|
+
if (keyNames.length > 1)
|
|
3212
|
+
out.push({ path: ['publicKeys'], message: 'an add-on declares at most one key: a link that opens one row' });
|
|
3213
|
+
const linkKey = keyNames[0];
|
|
3214
|
+
if (linkKey !== undefined) {
|
|
3215
|
+
// `customer` is an app's key: an add-on's entries reach it through the app, never through a key of the add-on's own.
|
|
3216
|
+
if (linkKey === 'customer')
|
|
3217
|
+
out.push({ path: ['publicKeys', linkKey], message: 'an add-on has no "customer" key: its one key is a link\'s, under a name of its own' });
|
|
3218
|
+
const key = (m.publicKeys ?? {})[linkKey];
|
|
3219
|
+
for (const field of ['requiresStaff', 'enabledBy', 'peak']) {
|
|
3220
|
+
if (key?.[field] !== undefined)
|
|
3221
|
+
out.push({ path: ['publicKeys', linkKey, field], message: `an add-on's key opens one row by its link and only reads: it takes no "${field}"` });
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
(m.publicAccess ?? []).forEach((entry, e) => {
|
|
3225
|
+
if (entry.key === undefined)
|
|
3226
|
+
return;
|
|
3227
|
+
if (entry.key !== linkKey) {
|
|
3228
|
+
out.push({ path: ['publicAccess', e, 'key'], message: `an add-on's entry is served through its app's key, or through the add-on's own link key${linkKey === undefined ? '' : ` "${linkKey}"`}: "${entry.key}" is neither` });
|
|
3229
|
+
return;
|
|
3230
|
+
}
|
|
3231
|
+
if (entry.methods.some((method) => method !== 'GET'))
|
|
3232
|
+
out.push({ path: ['publicAccess', e, 'methods'], message: `"${linkKey}" opens a row to whoever holds its link, so it only reads` });
|
|
3233
|
+
const claim = entry.claim;
|
|
3234
|
+
if (claim === undefined) {
|
|
3235
|
+
// Whoever holds a link reads that one row and what hangs under it: nothing on the key is open to everyone.
|
|
3236
|
+
if (entry.visibleWith === undefined && entry.claimedBy === undefined) {
|
|
3237
|
+
out.push({ path: ['publicAccess', e], message: `"${linkKey}" opens one row by its link: an entry on it claims by token, or is read with the row a link opened (visibleWith, claimedBy)` });
|
|
3238
|
+
}
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
if (!('by' in claim) || claim.by !== 'token' || claim.own === true) {
|
|
3242
|
+
out.push({ path: ['publicAccess', e, 'claim'], message: 'an add-on\'s link key claims by token, and never as the row\'s own link that may change it' });
|
|
3243
|
+
return;
|
|
3244
|
+
}
|
|
3245
|
+
const token = byRef.get(entry.table)?.columns.find((column) => column.ref === claim.column);
|
|
3246
|
+
const code = token?.rules?.code;
|
|
3247
|
+
if (code === undefined || code.length < 16 || code.hiddenFromStaff !== true) {
|
|
3248
|
+
out.push({ path: ['publicAccess', e, 'claim', 'column'], message: `the link's token is a code of 16 characters that staff never see: give "${entry.table}.${claim.column}" rules.code {"length": 16, "hiddenFromStaff": true}` });
|
|
3249
|
+
}
|
|
3250
|
+
});
|
|
3251
|
+
// Roles: what an add-on's role may open is its own tables, its own pages and its settings.
|
|
3252
|
+
const generated = new Set((m.pages ?? []).map((page) => page.ref));
|
|
3253
|
+
const code = new Set((m.addOn.pages ?? []).map((page) => page.ref));
|
|
3254
|
+
const settingsOf = new Set([m.key, ...(m.addOns?.suggests ?? []).map((need) => need.key)]);
|
|
3255
|
+
(m.roles ?? []).forEach((role, r) => {
|
|
3256
|
+
if (role.screensOnly !== undefined)
|
|
3257
|
+
out.push({ path: ['roles', r, 'screensOnly'], message: 'an add-on has no screens outside the dashboard, so its roles are never screensOnly' });
|
|
3258
|
+
(role.permissions ?? []).forEach((grant, g) => {
|
|
3259
|
+
const at = ['roles', r, 'permissions', g];
|
|
3260
|
+
const [resource, ref, action] = grant.split(':');
|
|
3261
|
+
if (resource === 'table' && ref?.startsWith('@') === true) {
|
|
3262
|
+
if (m.requiredSchema === undefined)
|
|
3263
|
+
out.push({ path: at, message: `"${grant}" names a table, and this add-on declares none (requiredSchema)` });
|
|
3264
|
+
return; // the table itself is checked with the app's own grants
|
|
3265
|
+
}
|
|
3266
|
+
if (resource === 'page' && ref?.startsWith('@') === true) {
|
|
3267
|
+
const page = ref.slice(1);
|
|
3268
|
+
if (action === 'view' && (generated.has(page) || code.has(page)))
|
|
3269
|
+
return;
|
|
3270
|
+
if (action === 'edit' && generated.has(page))
|
|
3271
|
+
return;
|
|
3272
|
+
out.push({ path: at, message: `"${grant}": a role opens one of this add-on's own pages (view), or edits one of its generated pages` });
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
3275
|
+
if (resource === 'addOn' && action === 'settings' && ref !== undefined && settingsOf.has(ref))
|
|
3276
|
+
return;
|
|
3277
|
+
out.push({ path: at, message: `"${grant}": an add-on's role grants its own tables (table:@<table>:<action>), its own pages (page:@<page>:view) and its settings (addOn:${m.key}:settings)` });
|
|
3278
|
+
});
|
|
3279
|
+
});
|
|
3280
|
+
if (m.sampleData?.skipWhenShared !== undefined) {
|
|
3281
|
+
out.push({ path: ['sampleData', 'skipWhenShared'], message: 'an add-on shares no table with another app, so its sample data skips nothing' });
|
|
3282
|
+
}
|
|
3283
|
+
if (m.sampleData?.addOns !== undefined) {
|
|
3284
|
+
out.push({ path: ['sampleData', 'addOns'], message: 'rows for another add-on are an app\'s to ship: an add-on\'s sample data is its own file' });
|
|
3285
|
+
}
|
|
3286
|
+
// A document another add-on draws may not be there: an email that carries one must be able to go without it.
|
|
3287
|
+
const suggested = new Set((m.addOns?.suggests ?? []).map((need) => need.key));
|
|
3288
|
+
(m.emailTemplates ?? []).forEach((template, t) => {
|
|
3289
|
+
const attach = template.attach;
|
|
3290
|
+
if (attach === undefined || attach.optional === true)
|
|
3291
|
+
return;
|
|
3292
|
+
const drawnBy = (m.documents ?? []).find((document) => document.kind === attach.kind && suggested.has(document.addOn));
|
|
3293
|
+
if (drawnBy !== undefined) {
|
|
3294
|
+
out.push({
|
|
3295
|
+
path: ['emailTemplates', t, 'attach', 'optional'],
|
|
3296
|
+
message: `"${attach.kind}" is drawn by "${drawnBy.addOn}", which this add-on only suggests: write "optional": true, or the email could never be sent without it`,
|
|
3297
|
+
});
|
|
3298
|
+
}
|
|
3299
|
+
});
|
|
3300
|
+
return out;
|
|
3301
|
+
}
|
|
2603
3302
|
/** Narrowing helper — the discriminant is the only thing worth branching on. */
|
|
2604
3303
|
export function isAddOnManifest(m) {
|
|
2605
3304
|
return m.kind === 'add-on';
|