@adminiumjs/manifest 0.3.27 → 0.3.29
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/app-grants.d.ts +134 -0
- package/dist/app-grants.d.ts.map +1 -0
- package/dist/app-grants.js +263 -0
- package/dist/app-grants.js.map +1 -0
- package/dist/automations.d.ts +2255 -58
- package/dist/automations.d.ts.map +1 -1
- package/dist/automations.js +450 -25
- package/dist/automations.js.map +1 -1
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +3 -0
- package/dist/compose.js.map +1 -1
- package/dist/documents.d.ts +1 -0
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +2 -0
- package/dist/documents.js.map +1 -1
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/ledgers.d.ts.map +1 -1
- package/dist/ledgers.js +2 -0
- package/dist/ledgers.js.map +1 -1
- package/dist/modules.d.ts +93 -0
- package/dist/modules.d.ts.map +1 -0
- package/dist/modules.js +489 -0
- package/dist/modules.js.map +1 -0
- package/dist/occupies.d.ts +50 -0
- package/dist/occupies.d.ts.map +1 -0
- package/dist/occupies.js +123 -0
- package/dist/occupies.js.map +1 -0
- package/dist/outbox.d.ts +223 -17
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +125 -13
- package/dist/outbox.js.map +1 -1
- package/dist/page-board.d.ts +144 -0
- package/dist/page-board.d.ts.map +1 -0
- package/dist/page-board.js +280 -0
- package/dist/page-board.js.map +1 -0
- package/dist/plan-context.d.ts.map +1 -1
- package/dist/plan-context.js +3 -0
- package/dist/plan-context.js.map +1 -1
- package/dist/plan-model.d.ts +7 -0
- package/dist/plan-model.d.ts.map +1 -1
- package/dist/public-access.d.ts +6 -0
- package/dist/public-access.d.ts.map +1 -1
- package/dist/public-access.js +75 -1
- package/dist/public-access.js.map +1 -1
- package/dist/rail.d.ts +50 -0
- package/dist/rail.d.ts.map +1 -0
- package/dist/rail.js +122 -0
- package/dist/rail.js.map +1 -0
- package/dist/roles.d.ts +647 -1
- package/dist/roles.d.ts.map +1 -1
- package/dist/roles.js +459 -3
- package/dist/roles.js.map +1 -1
- package/dist/sample.d.ts +62 -0
- package/dist/sample.d.ts.map +1 -1
- package/dist/sample.js +203 -4
- package/dist/sample.js.map +1 -1
- package/dist/schema.d.ts +4349 -169
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +540 -41
- package/dist/schema.js.map +1 -1
- package/dist/shapes.d.ts +3 -2
- package/dist/shapes.d.ts.map +1 -1
- package/dist/shapes.js +76 -7
- package/dist/shapes.js.map +1 -1
- package/dist/state-actions.d.ts +4 -0
- package/dist/state-actions.d.ts.map +1 -1
- package/dist/state-actions.js +7 -0
- package/dist/state-actions.js.map +1 -1
- package/dist/states.d.ts +20 -1
- package/dist/states.d.ts.map +1 -1
- package/dist/states.js +47 -5
- package/dist/states.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +110 -3
- package/dist/validate.js.map +1 -1
- package/dist/words.d.ts +1 -1
- package/dist/words.d.ts.map +1 -1
- package/dist/words.js +332 -1
- package/dist/words.js.map +1 -1
- package/package.json +2 -2
package/dist/schema.js
CHANGED
|
@@ -11,16 +11,21 @@
|
|
|
11
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
|
+
import { NAV_LINK_SCREENS, RECORD_ADDRESS_MESSAGE, SIDE_PATH_MESSAGE, isRecordAddress, isSidePath, railIssues } from './rail.js';
|
|
15
|
+
import { addOnModuleIssues, moduleIdSchema, moduleIssues, modulesSchema, optionalShapeAddOns, roleNamesSchema, shapeIsOptional, withoutModules } from './modules.js';
|
|
14
16
|
import { bookingIssues, bookingSchema } from './booking.js';
|
|
17
|
+
import { occupiesIssues, occupiesSchema } from './occupies.js';
|
|
15
18
|
import { capacityIssues, capacitySchema, isLegacyCapacity, kindOf, rulesOf, viaIndexIssues } from './capacity.js';
|
|
16
19
|
import { appDocumentIssues, appDocumentSchema, mappingIssues } from './documents.js';
|
|
17
20
|
import { formulaColumns, formulaExprSchema, tableFormulaIssues } from './formula.js';
|
|
18
21
|
import { lookUpIssues, lookUpSchema } from './look-up.js';
|
|
22
|
+
import { pageBoardIssues } from './page-board.js';
|
|
19
23
|
import { pageCalendarIssues } from './page-calendar.js';
|
|
20
24
|
import { pageConfigIssues } from './page-config.js';
|
|
21
25
|
import { emailTemplateSchema, outboxIssues, outboxProducerSchema, outboxSchema } from './outbox.js';
|
|
22
26
|
import { codeWhereSchema, personalColumn, publicAccessIssues, publicAccessSchema, publicKeysSchema, shareCodeColumns, unlistedColumn } from './public-access.js';
|
|
23
|
-
import {
|
|
27
|
+
import { appGrantsIssues, appGrantsSchema, roleTableHold } from './app-grants.js';
|
|
28
|
+
import { belongsIssues, belongsSchema, privateTableIssues, roleAddOnTableIssues, roleAddOnTablesSchema, roleLimitIssues, roleLimitsSchema, rowTablesOf, privateShapeIssues, storedTotalIssues } from './roles.js';
|
|
24
29
|
import { conditionIssues, stateConditionSchema, statesIssues, statesSchema } from './states.js';
|
|
25
30
|
import { MOMENT_LIMITS, clockTimeSchema, momentIssues, momentSchema, settingRefSchema } from './refs.js';
|
|
26
31
|
import { NUMERIC_TYPES, labelsSchema, refSchema, scalarSchema, settingSourceSchema, tableIndex, textOrLabels, valueFits, } from './refs.js';
|
|
@@ -207,6 +212,18 @@ export const COLUMN_SEMANTICS = [
|
|
|
207
212
|
'geo-lat',
|
|
208
213
|
'geo-lng',
|
|
209
214
|
];
|
|
215
|
+
/**
|
|
216
|
+
* The kinds of file a file column may take (`rules.file.accept`), by the
|
|
217
|
+
* names the server's content check gives them: what a file's bytes are, never
|
|
218
|
+
* what its name or its sender says. Two of the server's kinds are never taken
|
|
219
|
+
* by a column: SVG is a document that can carry script, and a HEIC picture is
|
|
220
|
+
* one whose camera notes (the place, the device) cannot be taken out of it.
|
|
221
|
+
*/
|
|
222
|
+
export const FILE_COLUMN_KINDS = ['png', 'jpeg', 'webp', 'gif', 'pdf', 'office', 'zip', 'csv', 'text', 'markdown', 'json', 'mp4', 'webm', 'mp3', 'wav', 'ogg'];
|
|
223
|
+
/** The most a file column's `maxBytes` may say: 25 MiB. */
|
|
224
|
+
export const FILE_COLUMN_MAX_BYTES = 25 * 1024 * 1024;
|
|
225
|
+
/** The most files a json file column may hold. */
|
|
226
|
+
export const FILE_COLUMN_MAX_COUNT = 20;
|
|
210
227
|
/** Structural role markers. */
|
|
211
228
|
export const COLUMN_ROLES = ['pk', 'created_at', 'updated_at'];
|
|
212
229
|
export { labelsSchema };
|
|
@@ -463,12 +480,16 @@ export const columnRulesSchema = z
|
|
|
463
480
|
* A running number. `gapless` numbers are taken inside the write that
|
|
464
481
|
* creates the row, so the series never skips or repeats (an invoice's);
|
|
465
482
|
* `scope` numbers per parent row (a deliverable's versions v1, v2 …);
|
|
466
|
-
* `startSetting` reads the first number from a setting.
|
|
483
|
+
* `startSetting` reads the first number from a setting. `sample` lets the
|
|
484
|
+
* app's sample rows take numbers in the series (a job's J-01): the next
|
|
485
|
+
* real row follows them. Never a tax document's series, which no sample
|
|
486
|
+
* row may leave a hole in.
|
|
467
487
|
*/
|
|
468
488
|
sequence: z
|
|
469
489
|
.object({
|
|
470
490
|
start: z.number().int().min(1).optional(),
|
|
471
491
|
gapless: z.literal(true).optional(),
|
|
492
|
+
sample: z.literal(true).optional(),
|
|
472
493
|
startSetting: settingSourceSchema.optional(),
|
|
473
494
|
scope: refSchema.optional(),
|
|
474
495
|
})
|
|
@@ -557,6 +578,15 @@ export const columnRulesSchema = z
|
|
|
557
578
|
/** The child column to add up; or, instead, `count` the child rows. */
|
|
558
579
|
sum: refSchema.optional(),
|
|
559
580
|
count: z.literal(true).optional(),
|
|
581
|
+
/**
|
|
582
|
+
* Or, instead of a total, the newest child row's value: the column
|
|
583
|
+
* `of` of the row whose `by` (a date, a time or a number) is the
|
|
584
|
+
* greatest — a deal's amount is its last sent quote's total. A row
|
|
585
|
+
* whose `by` is empty is left out; of two rows with the same `by`,
|
|
586
|
+
* the one with the greater key. Empty while no row qualifies. With
|
|
587
|
+
* `row`, a plain column of this table (not a link) holds that row's key.
|
|
588
|
+
*/
|
|
589
|
+
latest: z.object({ of: refSchema, by: refSchema, row: refSchema.optional() }).strict().optional(),
|
|
560
590
|
/** Multiplied into `sum` per child row, e.g. `qty`. */
|
|
561
591
|
times: refSchema.optional(),
|
|
562
592
|
/** A child row whose column holds a value is left out — a voided line (`voided_at`). */
|
|
@@ -645,6 +675,36 @@ export const columnRulesSchema = z
|
|
|
645
675
|
* the table is renamed.
|
|
646
676
|
*/
|
|
647
677
|
tableRef: z.literal(true).optional(),
|
|
678
|
+
/**
|
|
679
|
+
* A text column that holds a staff member: the id of a person who signs
|
|
680
|
+
* in to Adminium, or nothing. The only kind of column a table's `belongs`
|
|
681
|
+
* reads a person from, so a role's rows can be "the ones this person
|
|
682
|
+
* owns". A sample row names a sample person, never an id.
|
|
683
|
+
*/
|
|
684
|
+
staff: z.literal(true).optional(),
|
|
685
|
+
/**
|
|
686
|
+
* A text column in which people are mentioned: `@[Dana Whitlock](user:<id>)`
|
|
687
|
+
* names a staff member, who is told that they were mentioned, by whom and
|
|
688
|
+
* on which record — never the text. A save that names somebody who is not
|
|
689
|
+
* a person here is refused, and the text is kept. `about` is a link column
|
|
690
|
+
* of the same table: the notice then names the row it links to (a comment
|
|
691
|
+
* on a deal says the deal).
|
|
692
|
+
*/
|
|
693
|
+
mentions: z.union([z.literal(true), z.object({ about: refSchema }).strict()]).optional(),
|
|
694
|
+
/**
|
|
695
|
+
* A json column that holds a position on the map: `{ lat, lng, by }`,
|
|
696
|
+
* where `by` says how it is known. `"town"` is the middle of a town from
|
|
697
|
+
* Adminium's own list of places (`placeId`), filled in by the server, so
|
|
698
|
+
* it is never more than "somewhere in this town"; `"exact"` is a point a
|
|
699
|
+
* person gave (typed, pasted, or dropped on the map). Every write is read
|
|
700
|
+
* and checked; a value that is not a position is refused.
|
|
701
|
+
*/
|
|
702
|
+
place: z.literal(true).optional(),
|
|
703
|
+
/**
|
|
704
|
+
* A json column that holds an area on the map: a GeoJSON `Polygon` or
|
|
705
|
+
* `MultiPolygon`, with closed rings, at most 2,000 points and 64 KB.
|
|
706
|
+
*/
|
|
707
|
+
area: z.literal(true).optional(),
|
|
648
708
|
/**
|
|
649
709
|
* A formula column over a total of its own row whose change is told
|
|
650
710
|
* after the save commits (a stock level going low), so a rule or a
|
|
@@ -664,6 +724,45 @@ export const columnRulesSchema = z
|
|
|
664
724
|
customerKey: z.object({ of: refSchema }).strict().optional(),
|
|
665
725
|
/** The last four characters of the code in `of`, written by Adminium: what a list shows of a code it never shows. */
|
|
666
726
|
codeLast4: z.object({ of: refSchema }).strict().optional(),
|
|
727
|
+
/**
|
|
728
|
+
* A foreign key that may name only a row meeting these conditions (a
|
|
729
|
+
* price-list line still offered): judged when a new row names one and
|
|
730
|
+
* when a change moves the link, whoever writes. A row already naming one
|
|
731
|
+
* that later stops meeting them keeps it while its other columns change.
|
|
732
|
+
* `same`: a column of the named row holds what this row holds in `as`, a
|
|
733
|
+
* column of its own or one of the row a link of it names (a visit's
|
|
734
|
+
* contact works for the company of the visit's deal); judged also when a
|
|
735
|
+
* change moves what `as` reads.
|
|
736
|
+
*/
|
|
737
|
+
linkable: z
|
|
738
|
+
.object({
|
|
739
|
+
where: z.array(stateConditionSchema).min(1).max(8).optional(),
|
|
740
|
+
same: z
|
|
741
|
+
.array(z.object({ column: refSchema, as: z.string().regex(/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/, 'a column, or <link column>.<column>') }).strict())
|
|
742
|
+
.min(1)
|
|
743
|
+
.max(4)
|
|
744
|
+
.optional(),
|
|
745
|
+
})
|
|
746
|
+
.strict()
|
|
747
|
+
.refine((rule) => rule.where !== undefined || rule.same !== undefined, { message: 'a link says which rows it may name: "where", "same", or both' })
|
|
748
|
+
.optional(),
|
|
749
|
+
/**
|
|
750
|
+
* A column that holds files a signed-in person uploaded for it: a text
|
|
751
|
+
* column one, a json column a list of up to `maxCount`. What it holds is
|
|
752
|
+
* Adminium's own file id (`file_…`), never a link. Only the kinds in
|
|
753
|
+
* `accept`, judged by the file's bytes, each at most `maxBytes`; a picture
|
|
754
|
+
* loses what a camera writes beside it (the place, the device) as it is
|
|
755
|
+
* uploaded. A write may name only a file its writer uploaded and no record
|
|
756
|
+
* holds yet; a file a record holds is changed only through the record.
|
|
757
|
+
*/
|
|
758
|
+
file: z
|
|
759
|
+
.object({
|
|
760
|
+
accept: z.array(z.enum(FILE_COLUMN_KINDS)).min(1).max(FILE_COLUMN_KINDS.length),
|
|
761
|
+
maxBytes: z.number().int().min(1024).max(FILE_COLUMN_MAX_BYTES),
|
|
762
|
+
maxCount: z.number().int().min(1).max(FILE_COLUMN_MAX_COUNT).optional(),
|
|
763
|
+
})
|
|
764
|
+
.strict()
|
|
765
|
+
.optional(),
|
|
667
766
|
})
|
|
668
767
|
.strict();
|
|
669
768
|
/**
|
|
@@ -817,6 +916,8 @@ export const requiredTableSchema = z
|
|
|
817
916
|
capacity: capacitySchema.optional(),
|
|
818
917
|
/** Booking people: no two counted rows of one resource may overlap (see `booking.ts`). */
|
|
819
918
|
booking: bookingSchema.optional(),
|
|
919
|
+
/** A row takes a resource for a time: no two overlap, and it lies within the resource's hours (see `occupies.ts`). */
|
|
920
|
+
occupies: occupiesSchema.optional(),
|
|
820
921
|
/** States, the moves between them, locks and no-delete (see `states.ts`). */
|
|
821
922
|
states: statesSchema.optional(),
|
|
822
923
|
/**
|
|
@@ -832,6 +933,8 @@ export const requiredTableSchema = z
|
|
|
832
933
|
.regex(/^[a-z][a-z0-9-]{1,79}\/[a-z][a-z0-9-]*@[1-9]\d*$/, 'builtOn is <add-on key>/<shape name>@<version>')
|
|
833
934
|
.optional(),
|
|
834
935
|
part: z.string().regex(/^[a-z][a-z0-9_]*$/, 'a part of the shape').optional(),
|
|
936
|
+
/** The part of the app the table belongs to (see `modules.ts`): a role of that part loses its grant here while the part is off. */
|
|
937
|
+
module: moduleIdSchema.optional(),
|
|
835
938
|
/**
|
|
836
939
|
* What a person calls ONE row ("Category") and the table ("Categories"),
|
|
837
940
|
* in every language the app speaks: a form's title and button, a page's
|
|
@@ -857,6 +960,12 @@ export const requiredTableSchema = z
|
|
|
857
960
|
postings: postingsSchema.optional(),
|
|
858
961
|
/** Where an add-on's offers and codes lower this order's price (see `adjust.ts`). */
|
|
859
962
|
adjust: adjustSchema.optional(),
|
|
963
|
+
/** How a person belongs to a row, so a role's rows can be limited to their own or their team's (see `roles.ts`). */
|
|
964
|
+
belongs: belongsSchema.optional(),
|
|
965
|
+
/** Rows read by the people they belong to and by nobody and nothing else: no rule, public entry, document or export. */
|
|
966
|
+
private: z.literal(true).optional(),
|
|
967
|
+
/** An add-on's table: the most an app's role may be given of it (see `app-grants.ts`). */
|
|
968
|
+
appGrants: appGrantsSchema.optional(),
|
|
860
969
|
})
|
|
861
970
|
.strict()
|
|
862
971
|
.refine((t) => new Set(t.columns.map((c) => c.ref)).size === t.columns.length, { message: 'duplicate column ref in table', path: ['columns'] })
|
|
@@ -1080,7 +1189,7 @@ export function shapeDefinitionIssues(addOn, settings, shapes) {
|
|
|
1080
1189
|
out.push(...mappingIssues(profile.part, profile.mapping, index, at));
|
|
1081
1190
|
});
|
|
1082
1191
|
(shape.outbox?.producers ?? []).forEach((producer, p) => {
|
|
1083
|
-
const source = 'onCreate' in producer ? producer.onCreate : 'onChange' in producer ? producer.onChange : producer.before;
|
|
1192
|
+
const source = 'onCreate' in producer ? producer.onCreate : 'onChange' in producer ? producer.onChange : 'onDelete' in producer ? producer.onDelete : producer.before;
|
|
1084
1193
|
if (shape.parts[source.table] === undefined) {
|
|
1085
1194
|
out.push({ path: ['addOn', 'shapes', s, 'outbox', 'producers', p], message: `"${id}" has no part "${source.table}"` });
|
|
1086
1195
|
}
|
|
@@ -1094,13 +1203,25 @@ export function shapeDefinitionIssues(addOn, settings, shapes) {
|
|
|
1094
1203
|
return out;
|
|
1095
1204
|
}
|
|
1096
1205
|
// ── pages ────────────────────────────────────────────────────────────────────
|
|
1097
|
-
|
|
1206
|
+
/** Where an entry of the app's section sits: its group, its icon, its place in the order. */
|
|
1207
|
+
export const railPlaceSchema = z
|
|
1098
1208
|
.object({
|
|
1099
1209
|
group: z.string().min(1).max(80),
|
|
1100
1210
|
icon: z.string().min(1).max(60),
|
|
1101
1211
|
order: z.number().int(),
|
|
1102
1212
|
})
|
|
1103
1213
|
.strict();
|
|
1214
|
+
export const pageNavSchema = railPlaceSchema
|
|
1215
|
+
.extend({
|
|
1216
|
+
/**
|
|
1217
|
+
* The page is kept off the sidebar. It still exists: it opens by its
|
|
1218
|
+
* address for whoever may view it, and search, the command palette and
|
|
1219
|
+
* the assistant still find it for them. Somebody who may not view it
|
|
1220
|
+
* finds it nowhere, as with any page.
|
|
1221
|
+
*/
|
|
1222
|
+
offRail: z.literal(true).optional(),
|
|
1223
|
+
})
|
|
1224
|
+
.strict();
|
|
1104
1225
|
export const pageSchema = z
|
|
1105
1226
|
.object({
|
|
1106
1227
|
ref: z.string().regex(/^[a-z][a-z0-9-]*$/, 'page ref must be a kebab-case identifier'),
|
|
@@ -1120,6 +1241,8 @@ export const pageSchema = z
|
|
|
1120
1241
|
.optional(),
|
|
1121
1242
|
/** The page shows only while this feature's add-ons are there (see `addOns.features`). */
|
|
1122
1243
|
feature: z.string().regex(/^[a-z][a-z0-9-]{0,39}$/, 'a feature id').optional(),
|
|
1244
|
+
/** The part of the app the page belongs to: hidden and refused while it is off (see `modules.ts`). */
|
|
1245
|
+
module: moduleIdSchema.optional(),
|
|
1123
1246
|
})
|
|
1124
1247
|
.strict();
|
|
1125
1248
|
// ── roles ────────────────────────────────────────────────────────────────────
|
|
@@ -1127,6 +1250,10 @@ export const roleSchema = z
|
|
|
1127
1250
|
.object({
|
|
1128
1251
|
key: z.string().regex(/^[a-z][a-z0-9-]*$/, 'role key must be a kebab-case identifier'),
|
|
1129
1252
|
name: z.string().min(1).max(80),
|
|
1253
|
+
/** The name in other languages, keyed by BCP 47 tag; `name` stays the English. Shown until the owner renames the role. */
|
|
1254
|
+
names: roleNamesSchema.optional(),
|
|
1255
|
+
/** The parts of the app the role belongs to: offered while any of them is on (see `modules.ts`). */
|
|
1256
|
+
modules: z.array(moduleIdSchema).min(1).max(16).optional(),
|
|
1130
1257
|
cloneFrom: z.string().optional(),
|
|
1131
1258
|
// grant strings validated against the RBAC grammar at install time.
|
|
1132
1259
|
permissions: z.array(z.string().min(1)).optional(),
|
|
@@ -1256,6 +1383,16 @@ export const frontendSchema = z
|
|
|
1256
1383
|
placement: z.enum(['internal', 'external']).optional(),
|
|
1257
1384
|
/** Whether the side starts switched on. Absent means on. */
|
|
1258
1385
|
enabled: z.boolean().optional(),
|
|
1386
|
+
/**
|
|
1387
|
+
* Where a record opens on this side, by table: `{ "deals": "/deals/{id}" }`.
|
|
1388
|
+
* A notice about such a record, its e-mail's button and a search hit then
|
|
1389
|
+
* open the app's own screen for somebody who may open this side, and the
|
|
1390
|
+
* dashboard's record page for everybody else. The staff side's alone.
|
|
1391
|
+
*/
|
|
1392
|
+
records: z
|
|
1393
|
+
.record(z.string().regex(/^[a-z][a-z0-9_]*$/, 'a table ref'), z.string().max(200).refine(isRecordAddress, RECORD_ADDRESS_MESSAGE))
|
|
1394
|
+
.refine((value) => Object.keys(value).length > 0, 'name at least one table')
|
|
1395
|
+
.optional(),
|
|
1259
1396
|
})
|
|
1260
1397
|
.strict();
|
|
1261
1398
|
// ── the app's sidebar, public access and sample data ─────────────────────────
|
|
@@ -1283,6 +1420,26 @@ export const navGroupSchema = z
|
|
|
1283
1420
|
order: z.number().int(),
|
|
1284
1421
|
})
|
|
1285
1422
|
.strict();
|
|
1423
|
+
/**
|
|
1424
|
+
* An entry of the app's sidebar section that is not a page. It opens one of a
|
|
1425
|
+
* closed list of places, never an address the manifest writes out: a screen of
|
|
1426
|
+
* the dashboard narrowed to this app, or a screen of the app's own staff side.
|
|
1427
|
+
*/
|
|
1428
|
+
export const navLinkSchema = z
|
|
1429
|
+
.object({
|
|
1430
|
+
ref: z.string().regex(/^[a-z][a-z0-9-]*$/, 'a link ref is kebab-case').max(60),
|
|
1431
|
+
label: labelsSchema,
|
|
1432
|
+
nav: railPlaceSchema,
|
|
1433
|
+
to: z.union([
|
|
1434
|
+
/** A dashboard screen showing this app's own: its rules, its e-mails, its settings (parts, sample data). */
|
|
1435
|
+
z.object({ screen: z.enum(NAV_LINK_SCREENS) }).strict(),
|
|
1436
|
+
/** A screen of the app's staff side; no `path` is its first screen. */
|
|
1437
|
+
z.object({ side: z.literal('staff'), path: z.string().max(200).refine(isSidePath, SIDE_PATH_MESSAGE).optional() }).strict(),
|
|
1438
|
+
]),
|
|
1439
|
+
/** The part of the app the entry belongs to: it leaves the sidebar while the part is off. */
|
|
1440
|
+
module: moduleIdSchema.optional(),
|
|
1441
|
+
})
|
|
1442
|
+
.strict();
|
|
1286
1443
|
/** What the app's public screens may do (see `public-access.ts`). */
|
|
1287
1444
|
export { publicAccessSchema, publicKeysSchema };
|
|
1288
1445
|
/** The bundled sample data file, inside the package's `seeds/` folder. */
|
|
@@ -1310,6 +1467,40 @@ export const sampleDataSchema = z
|
|
|
1310
1467
|
addOns: z
|
|
1311
1468
|
.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())
|
|
1312
1469
|
.optional(),
|
|
1470
|
+
/**
|
|
1471
|
+
* The people of the sample business: a rep per pipeline, a crew per van.
|
|
1472
|
+
* "Add sample data" makes each a staff member who cannot sign in and is
|
|
1473
|
+
* marked a sample person, with one of the app's own roles; a sample row
|
|
1474
|
+
* names one by key (`{"@person": "dana"}`, see `sample.ts`), and "Remove
|
|
1475
|
+
* sample data" takes them away with their rows. A name is a person's
|
|
1476
|
+
* name: data, written once, in no language.
|
|
1477
|
+
*/
|
|
1478
|
+
people: z
|
|
1479
|
+
.array(z
|
|
1480
|
+
.object({
|
|
1481
|
+
key: z.string().regex(/^[a-z][a-z0-9_-]{0,39}$/, 'a short key: lower-case letters, digits, _ and -'),
|
|
1482
|
+
name: z.string().min(1).max(120),
|
|
1483
|
+
/** Shown beside the name; never mailed. Left out, or taken already, Adminium gives the person an address of its own. */
|
|
1484
|
+
email: z.string().max(200).regex(/^[^@\s]+@[^@\s]+$/, 'an email address').optional(),
|
|
1485
|
+
/** One of this app's roles, by its key. */
|
|
1486
|
+
role: z.string().min(1).max(64).optional(),
|
|
1487
|
+
})
|
|
1488
|
+
.strict())
|
|
1489
|
+
.min(1)
|
|
1490
|
+
.max(200)
|
|
1491
|
+
.optional(),
|
|
1492
|
+
/** Teams of sample people: `lead` reaches the rows of the `members` (and is one of the team). */
|
|
1493
|
+
teams: z
|
|
1494
|
+
.array(z
|
|
1495
|
+
.object({
|
|
1496
|
+
name: z.string().min(1).max(60),
|
|
1497
|
+
lead: z.string().min(1).max(40).optional(),
|
|
1498
|
+
members: z.array(z.string().min(1).max(40)).min(1).max(200),
|
|
1499
|
+
})
|
|
1500
|
+
.strict())
|
|
1501
|
+
.min(1)
|
|
1502
|
+
.max(50)
|
|
1503
|
+
.optional(),
|
|
1313
1504
|
})
|
|
1314
1505
|
.strict();
|
|
1315
1506
|
/** Hosted-only and offline-required are mutually exclusive. */
|
|
@@ -1376,14 +1567,72 @@ export function prefixFor(appKey) {
|
|
|
1376
1567
|
export const MAX_TABLE_NAME = 63;
|
|
1377
1568
|
/** Column types a count is kept in: a whole number. */
|
|
1378
1569
|
const WHOLE_TYPES = ['int', 'bigint'];
|
|
1570
|
+
/** What "the newest" may be read by: a moment, a day, or a number that only grows. */
|
|
1571
|
+
const LATEST_BY_TYPES = ['timestamptz', 'timestamp', 'datetime', 'date', 'int', 'bigint', 'decimal'];
|
|
1572
|
+
/**
|
|
1573
|
+
* The newest child row's value (`rollup.latest`): `of` and `by` are the
|
|
1574
|
+
* child's columns, the value's column is this table's and may be empty (no
|
|
1575
|
+
* row qualifies yet), of the type it copies; `row` is a plain column of this
|
|
1576
|
+
* table that holds the child's key and nothing else decides.
|
|
1577
|
+
*/
|
|
1578
|
+
function rollupLatestIssues(index, table, column, from, latest, decide, here) {
|
|
1579
|
+
const out = [];
|
|
1580
|
+
const of = index.column(from, latest.of);
|
|
1581
|
+
const by = index.column(from, latest.by);
|
|
1582
|
+
if (of === undefined)
|
|
1583
|
+
out.push({ path: here('rollup', 'latest', 'of'), message: `"${from}" has no column "${latest.of}"` });
|
|
1584
|
+
else if (of.type !== column.type)
|
|
1585
|
+
out.push({ path: here('rollup', 'latest', 'of'), message: `"${from}.${latest.of}" is ${of.type} and "${table.ref}.${column.ref}" is ${column.type}: the newest row's value is kept in a column of its own type` });
|
|
1586
|
+
if (by === undefined)
|
|
1587
|
+
out.push({ path: here('rollup', 'latest', 'by'), message: `"${from}" has no column "${latest.by}"` });
|
|
1588
|
+
else if (!LATEST_BY_TYPES.includes(by.type))
|
|
1589
|
+
out.push({ path: here('rollup', 'latest', 'by'), message: `"${from}.${latest.by}" is ${by.type}: the newest row is told by a date, a time or a number` });
|
|
1590
|
+
if (column.nullable !== true)
|
|
1591
|
+
out.push({ path: here('rollup', 'latest'), message: `"${table.ref}.${column.ref}" is empty while no row qualifies: make it nullable` });
|
|
1592
|
+
if (latest.row !== undefined) {
|
|
1593
|
+
const row = index.column(table.ref, latest.row);
|
|
1594
|
+
decide(table.ref, latest.row);
|
|
1595
|
+
if (row === undefined)
|
|
1596
|
+
out.push({ path: here('rollup', 'latest', 'row'), message: `"${table.ref}" has no column "${latest.row}"` });
|
|
1597
|
+
else {
|
|
1598
|
+
if (latest.row === column.ref)
|
|
1599
|
+
out.push({ path: here('rollup', 'latest', 'row'), message: 'the row\'s key is kept in a column of its own' });
|
|
1600
|
+
if (row.rules !== undefined)
|
|
1601
|
+
out.push({ path: here('rollup', 'latest', 'row'), message: `"${latest.row}" holds the key of the row Adminium took; it takes no rules of its own` });
|
|
1602
|
+
if (row.nullable !== true)
|
|
1603
|
+
out.push({ path: here('rollup', 'latest', 'row'), message: `"${table.ref}.${latest.row}" is empty while no row qualifies: make it nullable` });
|
|
1604
|
+
// Not a link: the database would refuse to delete the very row the column names, before Adminium could let go of it.
|
|
1605
|
+
if (row.type === 'fk') {
|
|
1606
|
+
out.push({ path: here('rollup', 'latest', 'row'), message: `"${table.ref}.${latest.row}" is a link: the key of the row Adminium took is kept as a plain column (the type of "${from}"'s key), so that row can still be deleted` });
|
|
1607
|
+
}
|
|
1608
|
+
else if (!WHOLE_TYPES.includes(row.type) && row.type !== 'text' && row.type !== 'uuid') {
|
|
1609
|
+
out.push({ path: here('rollup', 'latest', 'row'), message: `"${table.ref}.${latest.row}" holds a key of "${from}": a column of its key's type` });
|
|
1610
|
+
}
|
|
1611
|
+
else {
|
|
1612
|
+
// Held to the key it names: a whole number against a uuid is refused by the database on every write of the total.
|
|
1613
|
+
const key = index.pk(from);
|
|
1614
|
+
if (key === undefined) {
|
|
1615
|
+
out.push({ path: here('rollup', 'latest', 'row'), message: `"${from}" has no key of one column, so the row that was taken cannot be named: leave "row" out` });
|
|
1616
|
+
}
|
|
1617
|
+
else if (!(WHOLE_TYPES.includes(row.type) && WHOLE_TYPES.includes(key.type)) && row.type !== key.type) {
|
|
1618
|
+
out.push({ path: here('rollup', 'latest', 'row'), message: `"${table.ref}.${latest.row}" is ${row.type} and the key of "${from}" is ${key.type}: the key of the row Adminium took is kept in a column of that key's type` });
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return out;
|
|
1624
|
+
}
|
|
1379
1625
|
/**
|
|
1380
1626
|
* A total adds a column up (`sum`) or counts the rows (`count: true`), and a
|
|
1381
1627
|
* count is a whole number with nothing multiplied into it and no balance.
|
|
1382
1628
|
*/
|
|
1383
1629
|
function rollupCountIssues(r, type, here) {
|
|
1384
1630
|
const out = [];
|
|
1385
|
-
if (
|
|
1386
|
-
out.push({ path: here('rollup'), message: 'a total adds up `sum
|
|
1631
|
+
if ([r.sum, r.count, r.latest].filter((kind) => kind !== undefined).length !== 1) {
|
|
1632
|
+
out.push({ path: here('rollup'), message: 'a total adds up `sum`, counts rows (`count: true`) or takes the newest row\'s value (`latest`): one of the three' });
|
|
1633
|
+
}
|
|
1634
|
+
if (r.latest !== undefined && (r.times !== undefined || r.balance !== undefined || r.cap !== undefined || r.capUnless !== undefined)) {
|
|
1635
|
+
out.push({ path: here('rollup', 'latest'), message: 'the newest row\'s value takes no `times`, `balance` or `cap`' });
|
|
1387
1636
|
}
|
|
1388
1637
|
if (r.count === true) {
|
|
1389
1638
|
if (r.times !== undefined || r.balance !== undefined || r.cap !== undefined) {
|
|
@@ -1408,6 +1657,8 @@ function totalsOf(table) {
|
|
|
1408
1657
|
out.set(column.ref, new Set([column.ref]));
|
|
1409
1658
|
if (r.balance !== undefined)
|
|
1410
1659
|
out.set(r.balance.column, new Set([column.ref]));
|
|
1660
|
+
if (r.latest?.row !== undefined)
|
|
1661
|
+
out.set(r.latest.row, new Set([column.ref]));
|
|
1411
1662
|
}
|
|
1412
1663
|
const formulas = table.columns.filter((column) => column.rules?.formula !== undefined);
|
|
1413
1664
|
for (let changed = true; changed;) {
|
|
@@ -1442,7 +1693,7 @@ function rollupChainIssues(tables) {
|
|
|
1442
1693
|
const r = column.rules?.rollup;
|
|
1443
1694
|
if (r === undefined)
|
|
1444
1695
|
return;
|
|
1445
|
-
const read = [r.sum, r.times, r.unlessSet, r.where?.column].filter((ref) => ref !== undefined);
|
|
1696
|
+
const read = [r.sum, r.times, r.unlessSet, r.where?.column, r.latest?.of, r.latest?.by].filter((ref) => ref !== undefined);
|
|
1446
1697
|
const childTotals = totals.get(r.from) ?? new Map();
|
|
1447
1698
|
const reads = new Set();
|
|
1448
1699
|
for (const ref of read)
|
|
@@ -1578,7 +1829,7 @@ function followIssues(tables, table, own, copy, here) {
|
|
|
1578
1829
|
if (rollup?.from !== table.ref)
|
|
1579
1830
|
return false;
|
|
1580
1831
|
// The link a total groups its rows by counts too: a copy that moves a row to another parent moves it between that table's totals.
|
|
1581
|
-
return [rollup.via, rollup.sum, rollup.times, rollup.unlessSet, rollup.where?.column].some((ref) => ref !== undefined && written.has(ref));
|
|
1832
|
+
return [rollup.via, rollup.sum, rollup.times, rollup.unlessSet, rollup.where?.column, rollup.latest?.of, rollup.latest?.by].some((ref) => ref !== undefined && written.has(ref));
|
|
1582
1833
|
});
|
|
1583
1834
|
if (reads) {
|
|
1584
1835
|
out.push({ path: here('copy', 'follow'), message: `"${table.ref}" totals into "${other.ref}" too, so its copies cannot follow "${parent.ref}": a total "${other.ref}" keeps reads what the follow writes` });
|
|
@@ -1609,7 +1860,7 @@ function figureColumns(tables) {
|
|
|
1609
1860
|
mark(table.ref, input);
|
|
1610
1861
|
const r = rules?.rollup;
|
|
1611
1862
|
if (r !== undefined)
|
|
1612
|
-
for (const input of [r.sum, r.times])
|
|
1863
|
+
for (const input of [r.sum, r.times, r.latest?.of])
|
|
1613
1864
|
if (input !== undefined)
|
|
1614
1865
|
mark(r.from, input);
|
|
1615
1866
|
}
|
|
@@ -1759,6 +2010,13 @@ shapeOf) {
|
|
|
1759
2010
|
const index = tableIndex(m.requiredSchema.tables);
|
|
1760
2011
|
const tables = new Map(m.requiredSchema.tables.map((t) => [t.ref, t]));
|
|
1761
2012
|
const has = index.has;
|
|
2013
|
+
/** Whether the rule at `path` is of a table that sits in a part whose feature needs `addOn`: the table is there only while the add-on is. */
|
|
2014
|
+
const inPartOf = (path, addOn) => {
|
|
2015
|
+
if (path[0] !== 'requiredSchema' || path[1] !== 'tables' || typeof path[2] !== 'number')
|
|
2016
|
+
return false;
|
|
2017
|
+
const table = m.requiredSchema.tables[path[2]];
|
|
2018
|
+
return table !== undefined && shapeIsOptional(m, table, addOn);
|
|
2019
|
+
};
|
|
1762
2020
|
/** A setting a rule reads must be there when the rule runs. */
|
|
1763
2021
|
const settingIssue = (source, path) => {
|
|
1764
2022
|
if ('addOn' in source) {
|
|
@@ -1768,7 +2026,7 @@ shapeOf) {
|
|
|
1768
2026
|
else if (!shapeOf.settings.includes(source.setting))
|
|
1769
2027
|
out.push({ path, message: `"${source.addOn}" has no setting "${source.setting}"` });
|
|
1770
2028
|
}
|
|
1771
|
-
else if (!requiresAddOn(m.addOns, source.addOn)) {
|
|
2029
|
+
else if (!requiresAddOn(m.addOns, source.addOn) && !inPartOf(path, source.addOn)) {
|
|
1772
2030
|
out.push({ path, message: `"${source.addOn}" is not required by the app (addOns.requires), so its setting may not be there` });
|
|
1773
2031
|
}
|
|
1774
2032
|
}
|
|
@@ -1889,6 +2147,56 @@ shapeOf) {
|
|
|
1889
2147
|
if (Array.isArray(bound.strict))
|
|
1890
2148
|
bound.strict.forEach((condition, w) => out.push(...conditionIssues(table.ref, condition, index, here(side, 'strict', w))));
|
|
1891
2149
|
}
|
|
2150
|
+
if (rules.linkable !== undefined) {
|
|
2151
|
+
// The conditions read the row the link names, so the column is a link and they name that table's columns.
|
|
2152
|
+
if (column.type !== 'fk' || column.references === undefined) {
|
|
2153
|
+
out.push({ path: here('linkable'), message: 'only a foreign key names a row, so only one takes linkable' });
|
|
2154
|
+
}
|
|
2155
|
+
else {
|
|
2156
|
+
const linked = column.references;
|
|
2157
|
+
(rules.linkable.where ?? []).forEach((condition, w) => {
|
|
2158
|
+
out.push(...conditionIssues(linked, condition, index, here('linkable', 'where', w)));
|
|
2159
|
+
// A refusal says whether the named row meets the condition: never asked of a value kept from every reader.
|
|
2160
|
+
if (tables.get(linked)?.columns.find((x) => x.ref === condition.column)?.rules?.secret === true) {
|
|
2161
|
+
out.push({ path: here('linkable', 'where', w, 'column'), message: `"${linked}.${condition.column}" is a secret, and a link is never judged by one` });
|
|
2162
|
+
}
|
|
2163
|
+
});
|
|
2164
|
+
// What a column holds as a value to compare: a link holds the key of the row it names.
|
|
2165
|
+
const held = (of) => (of.type === 'fk' && of.references !== undefined ? (index.pk(of.references)?.type ?? 'fk') : of.type).replace(/^bigint$/, 'int');
|
|
2166
|
+
(rules.linkable.same ?? []).forEach((pair, p) => {
|
|
2167
|
+
const at = (...rest) => here('linkable', 'same', p, ...rest);
|
|
2168
|
+
const theirs = index.column(linked, pair.column);
|
|
2169
|
+
if (theirs === undefined)
|
|
2170
|
+
out.push({ path: at('column'), message: `"${linked}" has no column "${pair.column}"` });
|
|
2171
|
+
else if (theirs.rules?.secret === true)
|
|
2172
|
+
out.push({ path: at('column'), message: `"${linked}.${pair.column}" is a secret, and a link is never judged by one` });
|
|
2173
|
+
// This row's side: a column of its own, or one of the row a link of it names.
|
|
2174
|
+
const [first, second] = pair.as.split('.');
|
|
2175
|
+
const own = index.column(table.ref, first);
|
|
2176
|
+
let ours;
|
|
2177
|
+
if (own === undefined)
|
|
2178
|
+
out.push({ path: at('as'), message: `"${table.ref}" has no column "${first}"` });
|
|
2179
|
+
else if (second === undefined) {
|
|
2180
|
+
if (own.ref === column.ref)
|
|
2181
|
+
out.push({ path: at('as'), message: 'a link is compared with another column, not with itself' });
|
|
2182
|
+
else
|
|
2183
|
+
ours = own;
|
|
2184
|
+
}
|
|
2185
|
+
else if (own.type !== 'fk' || own.references === undefined)
|
|
2186
|
+
out.push({ path: at('as'), message: `"${table.ref}.${first}" is not a foreign key, so it names no row to read "${second}" of` });
|
|
2187
|
+
else {
|
|
2188
|
+
ours = index.column(own.references, second);
|
|
2189
|
+
if (ours === undefined)
|
|
2190
|
+
out.push({ path: at('as'), message: `"${own.references}" has no column "${second}"` });
|
|
2191
|
+
else if (ours.rules?.secret === true)
|
|
2192
|
+
out.push({ path: at('as'), message: `"${own.references}.${second}" is a secret, and a link is never judged by one` });
|
|
2193
|
+
}
|
|
2194
|
+
if (theirs !== undefined && ours !== undefined && held(theirs) !== held(ours)) {
|
|
2195
|
+
out.push({ path: at('as'), message: `"${linked}.${pair.column}" holds ${held(theirs)} and "${pair.as}" holds ${held(ours)}: they never hold the same value` });
|
|
2196
|
+
}
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
1892
2200
|
if (rules.requiredWhen !== undefined) {
|
|
1893
2201
|
const when = rules.requiredWhen;
|
|
1894
2202
|
const other = index.column(table.ref, when.column);
|
|
@@ -1939,6 +2247,13 @@ shapeOf) {
|
|
|
1939
2247
|
else if (seq.scope !== undefined || seq.startSetting !== undefined) {
|
|
1940
2248
|
out.push({ path: here('sequence'), message: 'scope and startSetting number without gaps: add gapless' });
|
|
1941
2249
|
}
|
|
2250
|
+
if (seq.sample === true) {
|
|
2251
|
+
if (seq.gapless !== true)
|
|
2252
|
+
out.push({ path: here('sequence', 'sample'), message: 'only a number without gaps is kept from sample rows: a plain running number needs no "sample"' });
|
|
2253
|
+
// A document built on an add-on's shape is numbered for the tax office: a sample row removed later would leave a hole in it.
|
|
2254
|
+
if (table.builtOn !== undefined)
|
|
2255
|
+
out.push({ path: here('sequence', 'sample'), message: `"${table.ref}" is built on an add-on's document: its numbers are never a sample row's` });
|
|
2256
|
+
}
|
|
1942
2257
|
if (seq.startSetting !== undefined)
|
|
1943
2258
|
settingIssue(seq.startSetting, here('sequence', 'startSetting'));
|
|
1944
2259
|
if (seq.scope !== undefined) {
|
|
@@ -2031,6 +2346,8 @@ shapeOf) {
|
|
|
2031
2346
|
if (!has(r.from, name))
|
|
2032
2347
|
out.push({ path: here('rollup'), message: `"${r.from}" has no column "${name}"` });
|
|
2033
2348
|
}
|
|
2349
|
+
if (r.latest !== undefined)
|
|
2350
|
+
out.push(...rollupLatestIssues(index, table, column, r.from, r.latest, decide, here));
|
|
2034
2351
|
if (r.where !== undefined) {
|
|
2035
2352
|
const filter = index.column(r.from, r.where.column);
|
|
2036
2353
|
if (filter === undefined)
|
|
@@ -2326,6 +2643,56 @@ shapeOf) {
|
|
|
2326
2643
|
if (rules.tableRef !== undefined && (column.type !== 'text' || column.maxLength === undefined)) {
|
|
2327
2644
|
out.push({ path: here('tableRef'), message: 'a table name is kept in a text column with maxLength' });
|
|
2328
2645
|
}
|
|
2646
|
+
// A person's id is text on every database: a number or a uuid column would compare with nothing.
|
|
2647
|
+
if (rules.mentions !== undefined) {
|
|
2648
|
+
if (column.type !== 'text')
|
|
2649
|
+
out.push({ path: here('mentions'), message: `people are mentioned in a text column, and "${table.ref}.${column.ref}" is ${column.type}` });
|
|
2650
|
+
if (table.private === true)
|
|
2651
|
+
out.push({ path: here('mentions'), message: 'a private table tells nobody of what is written in it: no mentions' });
|
|
2652
|
+
const about = typeof rules.mentions === 'object' ? rules.mentions.about : undefined;
|
|
2653
|
+
const link = about === undefined ? undefined : table.columns.find((candidate) => candidate.ref === about);
|
|
2654
|
+
if (about !== undefined && (link === undefined || link.type !== 'fk'))
|
|
2655
|
+
out.push({ path: here('mentions', 'about'), message: `"${table.ref}.${about}" is not a link of this table` });
|
|
2656
|
+
}
|
|
2657
|
+
if (rules.staff !== undefined && column.type !== 'text') {
|
|
2658
|
+
out.push({ path: here('staff'), message: 'a staff member is kept in a text column' });
|
|
2659
|
+
}
|
|
2660
|
+
// A position and an area are kept whole, as JSON: two number columns or a text column are not one.
|
|
2661
|
+
if (rules.place !== undefined && column.type !== 'json') {
|
|
2662
|
+
out.push({ path: here('place'), message: `a place is kept in a json column, and "${table.ref}.${column.ref}" is ${column.type}` });
|
|
2663
|
+
}
|
|
2664
|
+
if (rules.area !== undefined && column.type !== 'json') {
|
|
2665
|
+
out.push({ path: here('area'), message: `an area is kept in a json column, and "${table.ref}.${column.ref}" is ${column.type}` });
|
|
2666
|
+
}
|
|
2667
|
+
if (rules.place !== undefined && rules.area !== undefined) {
|
|
2668
|
+
out.push({ path: here('area'), message: 'a column holds a place or an area, not both' });
|
|
2669
|
+
}
|
|
2670
|
+
if (rules.file !== undefined) {
|
|
2671
|
+
// One file is its id in a text column; a list of them is kept whole, as JSON.
|
|
2672
|
+
if (column.type === 'text') {
|
|
2673
|
+
if (rules.file.maxCount !== undefined)
|
|
2674
|
+
out.push({ path: here('file', 'maxCount'), message: 'a text column holds one file; keep a list of them in a json column' });
|
|
2675
|
+
if (column.maxLength !== undefined && column.maxLength < 31)
|
|
2676
|
+
out.push({ path: here('file'), message: "a file's id is 31 characters, so maxLength is at least 31" });
|
|
2677
|
+
}
|
|
2678
|
+
else if (column.type === 'json') {
|
|
2679
|
+
if (rules.file.maxCount === undefined)
|
|
2680
|
+
out.push({ path: here('file', 'maxCount'), message: 'say how many files the list may hold (maxCount)' });
|
|
2681
|
+
}
|
|
2682
|
+
else {
|
|
2683
|
+
out.push({ path: here('file'), message: `a file is kept in a text column, a list of files in a json column, and "${table.ref}.${column.ref}" is ${column.type}` });
|
|
2684
|
+
}
|
|
2685
|
+
if (new Set(rules.file.accept).size !== rules.file.accept.length)
|
|
2686
|
+
out.push({ path: here('file', 'accept'), message: 'each kind of file is named once' });
|
|
2687
|
+
// What the column holds is a file a person uploaded: no rule of Adminium's makes one, and no two rows share one.
|
|
2688
|
+
const others = Object.keys(rules).filter((name) => !['file', 'personal', 'required', 'requiredWhen'].includes(name));
|
|
2689
|
+
if (others.length > 0)
|
|
2690
|
+
out.push({ path: here('file'), message: `a file column holds what a person uploaded, not also ${others.join(', ')}` });
|
|
2691
|
+
if (column.unique === true)
|
|
2692
|
+
out.push({ path: at('columns', c, 'unique'), message: 'a file column holds a file nobody else holds already: it is not also unique' });
|
|
2693
|
+
if (column.default !== undefined)
|
|
2694
|
+
out.push({ path: at('columns', c, 'default'), message: 'a file column takes no default: a file is uploaded' });
|
|
2695
|
+
}
|
|
2329
2696
|
if (rules.plainText !== undefined && column.type !== 'text') {
|
|
2330
2697
|
out.push({ path: here('plainText'), message: 'plain text is a rule of a text column' });
|
|
2331
2698
|
}
|
|
@@ -2449,6 +2816,14 @@ shapeOf) {
|
|
|
2449
2816
|
if (table.booking.cancel?.flag !== undefined)
|
|
2450
2817
|
decide(table.ref, table.booking.cancel.flag);
|
|
2451
2818
|
}
|
|
2819
|
+
if (table.occupies !== undefined) {
|
|
2820
|
+
out.push(...occupiesIssues(table, table.occupies, index, at));
|
|
2821
|
+
// One lock a write: a table's rows are placed by one of the three rules, never two.
|
|
2822
|
+
if (table.booking !== undefined)
|
|
2823
|
+
out.push({ path: at('occupies'), message: 'a table carries "booking" or "occupies", not both' });
|
|
2824
|
+
if (table.capacity !== undefined)
|
|
2825
|
+
out.push({ path: at('occupies'), message: 'a table carries "capacity" or "occupies", not both' });
|
|
2826
|
+
}
|
|
2452
2827
|
out.push(...tableFormulaIssues(table.columns, (c, ...rest) => at('columns', c, ...rest)));
|
|
2453
2828
|
if (table.states !== undefined) {
|
|
2454
2829
|
out.push(...statesIssues(table.ref, table.states, {
|
|
@@ -2459,7 +2834,7 @@ shapeOf) {
|
|
|
2459
2834
|
decided: (column) => decided.get(table.ref)?.has(column) === true,
|
|
2460
2835
|
bookingCancel: table.booking?.cancel === undefined ? undefined : table.booking.cancel.when,
|
|
2461
2836
|
statesOf: (ref) => tables.get(ref)?.states,
|
|
2462
|
-
bookedOf: (ref) => tables.get(ref)?.booking !== undefined,
|
|
2837
|
+
bookedOf: (ref) => tables.get(ref)?.booking !== undefined || tables.get(ref)?.occupies !== undefined,
|
|
2463
2838
|
lineOf: (ref) => [...tables.values()].find((other) => other.states?.children?.[ref] !== undefined)?.ref,
|
|
2464
2839
|
outboxTable: m.outbox?.table,
|
|
2465
2840
|
pages: new Set((m.pages ?? []).flatMap((page) => ('ref' in page && typeof page.ref === 'string' ? [page.ref] : []))),
|
|
@@ -2472,8 +2847,11 @@ shapeOf) {
|
|
|
2472
2847
|
}
|
|
2473
2848
|
if (table.builtOn !== undefined && shapeOf === undefined) {
|
|
2474
2849
|
const addOn = table.builtOn.split('/')[0];
|
|
2475
|
-
if (!requiresAddOn(m.addOns, addOn)) {
|
|
2476
|
-
out.push({
|
|
2850
|
+
if (!requiresAddOn(m.addOns, addOn) && !shapeIsOptional(m, table, addOn)) {
|
|
2851
|
+
out.push({
|
|
2852
|
+
path: at('builtOn'),
|
|
2853
|
+
message: `"${addOn}" defines the shape, so the app requires it (addOns.requires), or the table sits in a module whose feature needs "${addOn}"`,
|
|
2854
|
+
});
|
|
2477
2855
|
}
|
|
2478
2856
|
}
|
|
2479
2857
|
});
|
|
@@ -2494,7 +2872,34 @@ shapeOf) {
|
|
|
2494
2872
|
out.push(...settingTableIssues(m));
|
|
2495
2873
|
out.push(...slotPartyIssues(m));
|
|
2496
2874
|
out.push(...outboxIssues(m, index));
|
|
2497
|
-
|
|
2875
|
+
const staffColumn = (table, column) => tables.get(table)?.columns.find((c) => c.ref === column)?.rules?.staff === true;
|
|
2876
|
+
out.push(...belongsIssues(m.requiredSchema.tables, index, staffColumn));
|
|
2877
|
+
out.push(...roleLimitIssues(m.roles ?? [], index, rowTablesOf(m.requiredSchema.tables)));
|
|
2878
|
+
out.push(...privateTableIssues(m, m.requiredSchema.tables.filter((table) => table.private === true).map((table) => table.ref)));
|
|
2879
|
+
out.push(...storedTotalIssues(m.requiredSchema.tables, (m.roles ?? [])));
|
|
2880
|
+
out.push(...privateShapeIssues(m.requiredSchema.tables));
|
|
2881
|
+
// Whose a row is, is an app's to say for now: an add-on's tables are shared by every app that names it.
|
|
2882
|
+
if (m.kind === 'add-on') {
|
|
2883
|
+
m.requiredSchema.tables.forEach((table, t) => {
|
|
2884
|
+
for (const key of ['belongs', 'private']) {
|
|
2885
|
+
if (table[key] !== undefined)
|
|
2886
|
+
out.push({ path: ['requiredSchema', 'tables', t, key], message: `"${key}" is not read on an add-on's table yet` });
|
|
2887
|
+
}
|
|
2888
|
+
table.columns.forEach((column, c) => {
|
|
2889
|
+
if (column.rules?.staff !== undefined)
|
|
2890
|
+
out.push({ path: ['requiredSchema', 'tables', t, 'columns', c, 'rules', 'staff'], message: 'a staff column is not read on an add-on\'s table yet' });
|
|
2891
|
+
});
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
// What an app's role may be given of an add-on's table: the add-on's to say, of its own tables.
|
|
2895
|
+
if (m.kind === 'add-on')
|
|
2896
|
+
out.push(...appGrantsIssues(m.requiredSchema.tables, m.ledgers ?? []));
|
|
2897
|
+
else {
|
|
2898
|
+
m.requiredSchema.tables.forEach((table, t) => {
|
|
2899
|
+
if (table.appGrants !== undefined)
|
|
2900
|
+
out.push({ path: ['requiredSchema', 'tables', t, 'appGrants'], message: 'an app\'s table is granted by its own roles: "appGrants" is how an add-on says what an app may be given of its tables' });
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2498
2903
|
// A role's grants on an add-on's tables: an app's to give, on the add-ons it names.
|
|
2499
2904
|
if ((m.roles ?? []).some((role) => role.tables !== undefined)) {
|
|
2500
2905
|
const roles = (m.roles ?? []);
|
|
@@ -2603,7 +3008,7 @@ function settingTableIssues(m) {
|
|
|
2603
3008
|
const table = tables.find((t) => t.ref === ref);
|
|
2604
3009
|
if (table === undefined)
|
|
2605
3010
|
return true; // a table the manifest lacks is refused where the rule is checked
|
|
2606
|
-
if (table.states !== undefined || table.capacity !== undefined || table.booking !== undefined)
|
|
3011
|
+
if (table.states !== undefined || table.capacity !== undefined || table.booking !== undefined || table.occupies !== undefined)
|
|
2607
3012
|
return false;
|
|
2608
3013
|
if (table.columns.some((column) => column.type === 'fk'))
|
|
2609
3014
|
return false;
|
|
@@ -2731,7 +3136,7 @@ function slotPartyIssues(m) {
|
|
|
2731
3136
|
return;
|
|
2732
3137
|
out.push({
|
|
2733
3138
|
path: ['publicAccess', e],
|
|
2734
|
-
message: `"${table.ref}.${amount}" is the party a guest asks about: give it a largest value (validation.max), or a page asking ever larger parties learns how full each time is`,
|
|
3139
|
+
message: `"${table.ref}.${amount}" is the party a guest asks about: give it a largest value (rules.validation.max), or a page asking ever larger parties learns how full each time is`,
|
|
2735
3140
|
});
|
|
2736
3141
|
});
|
|
2737
3142
|
return out;
|
|
@@ -2948,8 +3353,18 @@ export const appManifestSchema = z
|
|
|
2948
3353
|
* required-singular shape however their `requiredSchema` was repaired.
|
|
2949
3354
|
*/
|
|
2950
3355
|
frontends: z.array(frontendSchema).min(1),
|
|
3356
|
+
/** Entries of the app's sidebar section that are not pages (see `rail.ts`). */
|
|
3357
|
+
navLinks: z.array(navLinkSchema).max(24).optional(),
|
|
2951
3358
|
/** The add-ons the app needs, suggests, or needs for a feature (see `add-ons.ts`). */
|
|
2952
3359
|
addOns: addOnsSchema.optional(),
|
|
3360
|
+
/** The parts of the app its owner switches on and off (see `modules.ts`). */
|
|
3361
|
+
modules: modulesSchema.optional(),
|
|
3362
|
+
/**
|
|
3363
|
+
* The app shows maps. Its install asks once, for the whole install,
|
|
3364
|
+
* whether maps are shown (a map's pictures come from a map server); the
|
|
3365
|
+
* first app that says this asks, and no later one asks again.
|
|
3366
|
+
*/
|
|
3367
|
+
maps: z.literal(true).optional(),
|
|
2953
3368
|
})
|
|
2954
3369
|
.strict()
|
|
2955
3370
|
.refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
|
|
@@ -2958,15 +3373,27 @@ export const appManifestSchema = z
|
|
|
2958
3373
|
.superRefine((m, ctx) => {
|
|
2959
3374
|
for (const issue of phoneSideIssues(m))
|
|
2960
3375
|
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
3376
|
+
for (const issue of railIssues(m))
|
|
3377
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
2961
3378
|
for (const issue of installFloorIssues(m))
|
|
2962
3379
|
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
2963
3380
|
for (const issue of appReferenceIssues(m)) {
|
|
2964
3381
|
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
2965
3382
|
}
|
|
3383
|
+
for (const issue of appSeedIssues(m))
|
|
3384
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
3385
|
+
for (const issue of moduleIssues(m))
|
|
3386
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
3387
|
+
for (const issue of leftOutIssues(m))
|
|
3388
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
2966
3389
|
// A calendar page's named columns, against the app's own tables.
|
|
2967
3390
|
for (const issue of pageCalendarIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
|
|
2968
3391
|
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
2969
3392
|
}
|
|
3393
|
+
// A board page's named columns, against the app's own tables.
|
|
3394
|
+
for (const issue of pageBoardIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
|
|
3395
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
3396
|
+
}
|
|
2970
3397
|
// A records page's tab words, filters and bulk actions, against the app's own tables.
|
|
2971
3398
|
for (const issue of pageConfigIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
|
|
2972
3399
|
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
@@ -3015,6 +3442,8 @@ export const addOnManifestSchema = z
|
|
|
3015
3442
|
.superRefine((m, ctx) => {
|
|
3016
3443
|
for (const issue of installFloorIssues(m))
|
|
3017
3444
|
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
3445
|
+
for (const issue of addOnModuleIssues(m))
|
|
3446
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
3018
3447
|
// Add-ons released before the floor keep the page refs they shipped with.
|
|
3019
3448
|
if (m.pages !== undefined || compareSemver(m.compatibility.minAdminiumVersion, ADD_ON_INSTALL_FLOOR) >= 0) {
|
|
3020
3449
|
for (const issue of addOnPageRefIssues(m))
|
|
@@ -3057,6 +3486,32 @@ export const addOnManifestSchema = z
|
|
|
3057
3486
|
export const manifestSchema = z.preprocess((v) => typeof v === 'object' && v !== null && !Array.isArray(v) && v.kind === undefined
|
|
3058
3487
|
? { ...v, kind: 'app' }
|
|
3059
3488
|
: v, z.discriminatedUnion('kind', [appManifestSchema, addOnManifestSchema]));
|
|
3489
|
+
/**
|
|
3490
|
+
* An app that installs without an add-on one of its tables is built on is
|
|
3491
|
+
* installed with that table's module left out. What is left must be a whole
|
|
3492
|
+
* manifest of its own: each such add-on is taken away in turn and the rest
|
|
3493
|
+
* checked as the app is.
|
|
3494
|
+
*/
|
|
3495
|
+
function leftOutIssues(m) {
|
|
3496
|
+
const out = [];
|
|
3497
|
+
const optional = [...optionalShapeAddOns(m)];
|
|
3498
|
+
// Each alone, and all of them at once: a role of two such parts is gone only when both are.
|
|
3499
|
+
const cases = optional.map(([addOn, modules]) => [`"${addOn}"`, modules]);
|
|
3500
|
+
if (optional.length > 1)
|
|
3501
|
+
cases.push([optional.map(([addOn]) => `"${addOn}"`).join(' and '), [...new Set(optional.flatMap(([, modules]) => modules))]]);
|
|
3502
|
+
for (const [without, modules] of cases) {
|
|
3503
|
+
const parsed = appManifestSchema.safeParse(withoutModules(m, modules));
|
|
3504
|
+
if (parsed.success)
|
|
3505
|
+
continue;
|
|
3506
|
+
for (const issue of parsed.error.issues.slice(0, 20)) {
|
|
3507
|
+
out.push({
|
|
3508
|
+
path: ['modules'],
|
|
3509
|
+
message: `installed without ${without} (module ${modules.map((id) => `"${id}"`).join(', ')} left out): ${issue.path.map(String).join('.')}: ${issue.message}`,
|
|
3510
|
+
});
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
return out;
|
|
3514
|
+
}
|
|
3060
3515
|
/**
|
|
3061
3516
|
* A word only a newer Adminium reads, under a floor that admits an older one:
|
|
3062
3517
|
* that server would refuse the manifest as an unknown key, so the floor is
|
|
@@ -3098,6 +3553,7 @@ export function installsLikeAnApp(m) {
|
|
|
3098
3553
|
return (m.requiredSchema?.tables ?? []).some((table) => table.states !== undefined ||
|
|
3099
3554
|
table.capacity !== undefined ||
|
|
3100
3555
|
table.booking !== undefined ||
|
|
3556
|
+
table.occupies !== undefined ||
|
|
3101
3557
|
table.unique !== undefined ||
|
|
3102
3558
|
table.indexes !== undefined ||
|
|
3103
3559
|
table.postings !== undefined ||
|
|
@@ -3108,6 +3564,68 @@ export function installsLikeAnApp(m) {
|
|
|
3108
3564
|
const SEED_DIRECTIVES = new Set(['@t', '@ref']);
|
|
3109
3565
|
/** The most rows one table is seeded with. */
|
|
3110
3566
|
export const MAX_SEED_ROWS = 200;
|
|
3567
|
+
/** One seed's rows: how many, the one row directive, own columns, the two value directives. */
|
|
3568
|
+
function seedRowIssues(seed, i, columns) {
|
|
3569
|
+
const out = [];
|
|
3570
|
+
if ((seed.rows?.length ?? 0) > MAX_SEED_ROWS)
|
|
3571
|
+
out.push({ path: ['seeds', i, 'rows'], message: `a table is seeded with at most ${String(MAX_SEED_ROWS)} rows` });
|
|
3572
|
+
(seed.rows ?? []).forEach((row, r) => {
|
|
3573
|
+
for (const [name, value] of Object.entries(row)) {
|
|
3574
|
+
const at = ['seeds', i, 'rows', r, name];
|
|
3575
|
+
if (name === '@label') {
|
|
3576
|
+
if (typeof value !== 'string')
|
|
3577
|
+
out.push({ path: at, message: 'a label is text' });
|
|
3578
|
+
continue;
|
|
3579
|
+
}
|
|
3580
|
+
if (name.startsWith('@')) {
|
|
3581
|
+
out.push({ path: at, message: `a seed row takes "@label" and no other row directive, not "${name}"` });
|
|
3582
|
+
continue;
|
|
3583
|
+
}
|
|
3584
|
+
if (!columns.has(name))
|
|
3585
|
+
out.push({ path: at, message: `"${seed.table}" has no column "${name}"` });
|
|
3586
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
3587
|
+
const directive = Object.keys(value).find((key) => key.startsWith('@'));
|
|
3588
|
+
if (directive !== undefined && !SEED_DIRECTIVES.has(directive)) {
|
|
3589
|
+
out.push({ path: at, message: `a seed value is a plain value, {"@t": {…}} or {"@ref": "<label>"}, not "${directive}"` });
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
});
|
|
3594
|
+
return out;
|
|
3595
|
+
}
|
|
3596
|
+
/**
|
|
3597
|
+
* An app's starting rows: rows of a table of its own, written once by its
|
|
3598
|
+
* install into a table that is still empty. Not of a table built on an
|
|
3599
|
+
* add-on's shape (the add-on numbers those), not of a private table (nothing
|
|
3600
|
+
* but its two people writes one), and never a staff member: who works here is
|
|
3601
|
+
* not known where a manifest is written.
|
|
3602
|
+
*/
|
|
3603
|
+
export function appSeedIssues(m) {
|
|
3604
|
+
const out = [];
|
|
3605
|
+
if (m.seeds === undefined)
|
|
3606
|
+
return out;
|
|
3607
|
+
const byRef = new Map((m.requiredSchema?.tables ?? []).map((table) => [table.ref, table]));
|
|
3608
|
+
m.seeds.forEach((seed, i) => {
|
|
3609
|
+
const table = byRef.get(seed.table);
|
|
3610
|
+
if (table === undefined) {
|
|
3611
|
+
out.push({ path: ['seeds', i, 'table'], message: `"${seed.table}" is not one of this app's tables` });
|
|
3612
|
+
return;
|
|
3613
|
+
}
|
|
3614
|
+
if (table.builtOn !== undefined)
|
|
3615
|
+
out.push({ path: ['seeds', i, 'table'], message: `"${seed.table}" is built on an add-on's shape, which numbers its own rows: a seed never fills it` });
|
|
3616
|
+
if (table.private === true)
|
|
3617
|
+
out.push({ path: ['seeds', i, 'table'], message: `"${seed.table}" is private: only the people its rows belong to write it, so a seed never fills it` });
|
|
3618
|
+
out.push(...seedRowIssues(seed, i, new Set(table.columns.map((column) => column.ref))));
|
|
3619
|
+
const staff = new Set(table.columns.filter((column) => column.rules?.staff === true).map((column) => column.ref));
|
|
3620
|
+
(seed.rows ?? []).forEach((row, r) => {
|
|
3621
|
+
for (const [name, value] of Object.entries(row)) {
|
|
3622
|
+
if (staff.has(name) && value !== null)
|
|
3623
|
+
out.push({ path: ['seeds', i, 'rows', r, name], message: `"${name}" holds a staff member, and a seed names none: leave it out` });
|
|
3624
|
+
}
|
|
3625
|
+
});
|
|
3626
|
+
});
|
|
3627
|
+
return out;
|
|
3628
|
+
}
|
|
3111
3629
|
/**
|
|
3112
3630
|
* Generated pages and code pages share one address space on a connection, so
|
|
3113
3631
|
* each of an add-on's is named under its key, and once.
|
|
@@ -3175,6 +3693,10 @@ export function ledgersOf(m) {
|
|
|
3175
3693
|
const parsed = ledgersSchema.safeParse(m.addOn.ledgers);
|
|
3176
3694
|
return parsed.success ? parsed.data : [];
|
|
3177
3695
|
}
|
|
3696
|
+
/** What an app's grant on one of this add-on's tables comes to: its shape, its columns and its `appGrants` (see `app-grants.ts`). */
|
|
3697
|
+
export function addOnRoleTableHold(entry, addOn) {
|
|
3698
|
+
return roleTableHold(entry, { tables: (addOn.requiredSchema?.tables ?? []), ledgers: ledgersOf(addOn) });
|
|
3699
|
+
}
|
|
3178
3700
|
/** Everything wrong with an add-on that installs like an app, beyond what its blocks say of themselves. */
|
|
3179
3701
|
function addOnInstallIssues(m) {
|
|
3180
3702
|
const out = [];
|
|
@@ -3184,7 +3706,7 @@ function addOnInstallIssues(m) {
|
|
|
3184
3706
|
const ledgers = m.addOn.ledgers === undefined ? [] : parsedLedgers(m.addOn.ledgers, out);
|
|
3185
3707
|
out.push(...appReferenceIssues({ ...m, requiredSchema: m.requiredSchema ?? { tables: [] }, ledgers, codePages: (m.addOn.pages ?? []).map((page) => page.ref) }));
|
|
3186
3708
|
if (m.pages !== undefined)
|
|
3187
|
-
out.push(...pageCalendarIssues(m.pages, tableIndex(tables)), ...pageConfigIssues(m.pages, tableIndex(tables)));
|
|
3709
|
+
out.push(...pageCalendarIssues(m.pages, tableIndex(tables)), ...pageBoardIssues(m.pages, tableIndex(tables)), ...pageConfigIssues(m.pages, tableIndex(tables)));
|
|
3188
3710
|
// The look-up is typed loosely in the contracts package: its tables and columns are checked here.
|
|
3189
3711
|
if (m.addOn.lookUp !== undefined) {
|
|
3190
3712
|
const parsed = lookUpSchema.safeParse(m.addOn.lookUp);
|
|
@@ -3272,30 +3794,7 @@ function addOnInstallIssues(m) {
|
|
|
3272
3794
|
if (ledger !== undefined) {
|
|
3273
3795
|
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` });
|
|
3274
3796
|
}
|
|
3275
|
-
|
|
3276
|
-
out.push({ path: ['seeds', i, 'rows'], message: `a table is seeded with at most ${String(MAX_SEED_ROWS)} rows` });
|
|
3277
|
-
(seed.rows ?? []).forEach((row, r) => {
|
|
3278
|
-
for (const [name, value] of Object.entries(row)) {
|
|
3279
|
-
const at = ['seeds', i, 'rows', r, name];
|
|
3280
|
-
if (name === '@label') {
|
|
3281
|
-
if (typeof value !== 'string')
|
|
3282
|
-
out.push({ path: at, message: 'a label is text' });
|
|
3283
|
-
continue;
|
|
3284
|
-
}
|
|
3285
|
-
if (name.startsWith('@')) {
|
|
3286
|
-
out.push({ path: at, message: `a seed row takes "@label" and no other row directive, not "${name}"` });
|
|
3287
|
-
continue;
|
|
3288
|
-
}
|
|
3289
|
-
if (!columns.has(name))
|
|
3290
|
-
out.push({ path: at, message: `"${seed.table}" has no column "${name}"` });
|
|
3291
|
-
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
3292
|
-
const directive = Object.keys(value).find((key) => key.startsWith('@'));
|
|
3293
|
-
if (directive !== undefined && !SEED_DIRECTIVES.has(directive)) {
|
|
3294
|
-
out.push({ path: at, message: `a seed value is a plain value, {"@t": {…}} or {"@ref": "<label>"}, not "${directive}"` });
|
|
3295
|
-
}
|
|
3296
|
-
}
|
|
3297
|
-
}
|
|
3298
|
-
});
|
|
3797
|
+
out.push(...seedRowIssues(seed, i, columns));
|
|
3299
3798
|
});
|
|
3300
3799
|
// The settings table: one row, made from defaults, so every column must have one or may be empty.
|
|
3301
3800
|
const settings = m.addOn.settingsTable;
|