@adminiumjs/manifest 0.3.0-rc.3 → 0.3.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/booking.d.ts +100 -0
- package/dist/booking.d.ts.map +1 -0
- package/dist/booking.js +237 -0
- package/dist/booking.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/outbox.d.ts +198 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +355 -0
- package/dist/outbox.js.map +1 -0
- package/dist/page-calendar.d.ts +42 -0
- package/dist/page-calendar.d.ts.map +1 -0
- package/dist/page-calendar.js +95 -0
- package/dist/page-calendar.js.map +1 -0
- package/dist/plan-context.d.ts +51 -0
- package/dist/plan-context.d.ts.map +1 -0
- package/dist/plan-context.js +356 -0
- package/dist/plan-context.js.map +1 -0
- package/dist/plan-model.d.ts +197 -0
- package/dist/plan-model.d.ts.map +1 -0
- package/dist/plan-model.js +11 -0
- package/dist/plan-model.js.map +1 -0
- package/dist/plan-types.d.ts +12 -0
- package/dist/plan-types.d.ts.map +1 -0
- package/dist/plan-types.js +71 -0
- package/dist/plan-types.js.map +1 -0
- package/dist/plan.d.ts +9 -90
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +48 -1
- package/dist/plan.js.map +1 -1
- package/dist/public-access.d.ts +167 -0
- package/dist/public-access.d.ts.map +1 -0
- package/dist/public-access.js +475 -0
- package/dist/public-access.js.map +1 -0
- package/dist/refs.d.ts +74 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +70 -0
- package/dist/refs.js.map +1 -0
- package/dist/roles.d.ts +44 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +75 -0
- package/dist/roles.js.map +1 -0
- package/dist/sample.d.ts +200 -0
- package/dist/sample.d.ts.map +1 -0
- package/dist/sample.js +208 -0
- package/dist/sample.js.map +1 -0
- package/dist/schema.d.ts +2031 -51
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +559 -18
- package/dist/schema.js.map +1 -1
- package/dist/semver.d.ts +28 -0
- package/dist/semver.d.ts.map +1 -0
- package/dist/semver.js +85 -0
- package/dist/semver.js.map +1 -0
- package/package.json +2 -2
package/dist/schema.js
CHANGED
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { addOnBlockSchema, addOnCategorySchema, i18nMessageSchema, isSlotId, } from '@adminium/add-on-contracts';
|
|
13
13
|
import { z } from 'zod';
|
|
14
|
+
import { bookingIssues, bookingSchema } from './booking.js';
|
|
15
|
+
import { pageCalendarIssues } from './page-calendar.js';
|
|
16
|
+
import { emailTemplateSchema, outboxIssues, outboxSchema } from './outbox.js';
|
|
17
|
+
import { publicAccessIssues, publicAccessSchema, publicKeysSchema } from './public-access.js';
|
|
18
|
+
import { roleLimitIssues, roleLimitsSchema } from './roles.js';
|
|
19
|
+
import { NUMERIC_TYPES, labelsSchema, numberOrSetting, refSchema, scalarSchema, tableIndex, textOrLabels, valueFits, } from './refs.js';
|
|
20
|
+
import { compareSemver, parseSemverRange } from './semver.js';
|
|
21
|
+
export { compareSemver };
|
|
14
22
|
/** Integer spec version, frozen at 1 for Adminium 1.x. */
|
|
15
23
|
export const MANIFEST_VERSION = 1;
|
|
16
24
|
/**
|
|
@@ -134,6 +142,21 @@ export const compatibilitySchema = z
|
|
|
134
142
|
*/
|
|
135
143
|
engines: z.array(z.enum(MANIFEST_ENGINES)).min(1).optional(),
|
|
136
144
|
requires: z.array(capabilitySchema).optional(),
|
|
145
|
+
/**
|
|
146
|
+
* The installed versions this release can update in place, as a semver
|
|
147
|
+
* range (`>=0.2.0`). An install outside it is not offered the update and
|
|
148
|
+
* is refused one: a release whose tables changed shape says so here, and
|
|
149
|
+
* the operator is told to uninstall first rather than left with an update
|
|
150
|
+
* that fails half-way. Absent means any older version.
|
|
151
|
+
*/
|
|
152
|
+
updatesFrom: z
|
|
153
|
+
.string()
|
|
154
|
+
.min(1)
|
|
155
|
+
.max(120)
|
|
156
|
+
.refine((range) => parseSemverRange(range) !== null, {
|
|
157
|
+
message: 'a semver range such as ">=0.2.0", "^0.2.0" or ">=0.2.0 <1.0.0"',
|
|
158
|
+
})
|
|
159
|
+
.optional(),
|
|
137
160
|
})
|
|
138
161
|
.strict();
|
|
139
162
|
// ── requiredSchema (create-or-map) ───────────────────────────────────────────
|
|
@@ -167,6 +190,198 @@ export const COLUMN_SEMANTICS = [
|
|
|
167
190
|
];
|
|
168
191
|
/** Structural role markers. */
|
|
169
192
|
export const COLUMN_ROLES = ['pk', 'created_at', 'updated_at'];
|
|
193
|
+
export { labelsSchema };
|
|
194
|
+
/**
|
|
195
|
+
* The rules an app asks Adminium to keep on a column (written as column-rule
|
|
196
|
+
* overrides at install). The first five are the rules an operator can set in
|
|
197
|
+
* the column inspector; the rest are the ones Adminium DECIDES for a public
|
|
198
|
+
* write, so a browser never picks a price, a number or a code:
|
|
199
|
+
*
|
|
200
|
+
* - `copy`: take the value from the linked row (`via` is this table's
|
|
201
|
+
* foreign-key column, `from` a column of the row it points at).
|
|
202
|
+
* - `sequence`: the next number in this column's own counter.
|
|
203
|
+
* - `code`: a short random code (Crockford base 32), unique in the column.
|
|
204
|
+
* - `rollup`: a total over child rows, kept in step as they change.
|
|
205
|
+
* - `venueLocal`: a wall time with no zone is read in the venue's zone.
|
|
206
|
+
* - `personal`: whether the column is personal data, overriding the guess
|
|
207
|
+
* Adminium makes from its name.
|
|
208
|
+
*/
|
|
209
|
+
export const columnRulesSchema = z
|
|
210
|
+
.object({
|
|
211
|
+
options: z
|
|
212
|
+
.union([
|
|
213
|
+
z.object({ list: z.string().min(1).max(120) }).strict(),
|
|
214
|
+
z
|
|
215
|
+
.object({
|
|
216
|
+
values: z
|
|
217
|
+
.array(z
|
|
218
|
+
.object({ value: z.string().min(1).max(256), label: textOrLabels.optional(), tone: z.string().max(32).optional() })
|
|
219
|
+
.strict())
|
|
220
|
+
.min(1)
|
|
221
|
+
.max(500),
|
|
222
|
+
})
|
|
223
|
+
.strict(),
|
|
224
|
+
])
|
|
225
|
+
.optional(),
|
|
226
|
+
enumLabels: z
|
|
227
|
+
.object({
|
|
228
|
+
labels: z.record(z.string().min(1), textOrLabels),
|
|
229
|
+
tones: z.record(z.string().min(1), z.string().max(32)).optional(),
|
|
230
|
+
})
|
|
231
|
+
.strict()
|
|
232
|
+
.optional(),
|
|
233
|
+
required: z.literal(true).optional(),
|
|
234
|
+
validation: z
|
|
235
|
+
.object({
|
|
236
|
+
format: z.enum(['email', 'url', 'phone']).optional(),
|
|
237
|
+
min: z.number().optional(),
|
|
238
|
+
max: z.number().optional(),
|
|
239
|
+
minLength: z.number().int().nonnegative().optional(),
|
|
240
|
+
maxLength: z.number().int().positive().optional(),
|
|
241
|
+
})
|
|
242
|
+
.strict()
|
|
243
|
+
.optional(),
|
|
244
|
+
copy: z
|
|
245
|
+
.object({ via: refSchema, from: refSchema, mode: z.enum(['default', 'always']).optional() })
|
|
246
|
+
.strict()
|
|
247
|
+
.optional(),
|
|
248
|
+
sequence: z.object({ start: z.number().int().min(1).optional() }).strict().optional(),
|
|
249
|
+
code: z
|
|
250
|
+
.object({
|
|
251
|
+
prefix: z.string().regex(/^[A-Z][A-Z0-9]{0,5}-?$/, 'an upper-case prefix, e.g. MR-').optional(),
|
|
252
|
+
length: z.number().int().min(4).max(12),
|
|
253
|
+
})
|
|
254
|
+
.strict()
|
|
255
|
+
.optional(),
|
|
256
|
+
rollup: z
|
|
257
|
+
.object({
|
|
258
|
+
/** The child table, its foreign key back to this row, and what to add up. */
|
|
259
|
+
from: refSchema,
|
|
260
|
+
via: refSchema,
|
|
261
|
+
sum: refSchema,
|
|
262
|
+
/** Multiplied into `sum` per child row, e.g. `qty`. */
|
|
263
|
+
times: refSchema.optional(),
|
|
264
|
+
/** A child row whose column holds a value is left out — a voided line (`voided_at`). */
|
|
265
|
+
unlessSet: refSchema.optional(),
|
|
266
|
+
/** Only child rows whose column equals the value are added up (`voided = false`). */
|
|
267
|
+
where: z.object({ column: refSchema, eq: scalarSchema }).strict().optional(),
|
|
268
|
+
/**
|
|
269
|
+
* A second column of this row kept in step: `of − Σminus − total`
|
|
270
|
+
* (`balance = fee − waived − paid`). Adminium writes it; nobody else may.
|
|
271
|
+
*/
|
|
272
|
+
balance: z
|
|
273
|
+
.object({ column: refSchema, of: refSchema, minus: z.array(refSchema).max(4).optional() })
|
|
274
|
+
.strict()
|
|
275
|
+
.optional(),
|
|
276
|
+
/** A child write that would take the balance below zero is refused. */
|
|
277
|
+
cap: z.literal(true).optional(),
|
|
278
|
+
})
|
|
279
|
+
.strict()
|
|
280
|
+
.optional(),
|
|
281
|
+
/**
|
|
282
|
+
* A value Adminium writes when something happens: the moment, or who did
|
|
283
|
+
* it, on a create or when another column changes to one of `values`
|
|
284
|
+
* (`checked_in_at` when `status` becomes `checked_in`). `byOrigin` writes
|
|
285
|
+
* one value for a public write and another for staff. A public write never
|
|
286
|
+
* stamps a person: a browser key is nobody.
|
|
287
|
+
*/
|
|
288
|
+
stamp: z
|
|
289
|
+
.object({
|
|
290
|
+
set: z.union([
|
|
291
|
+
z.enum(['now', 'user-name', 'user-id']),
|
|
292
|
+
z.object({ byOrigin: z.object({ public: z.string().min(1), staff: z.string().min(1) }).strict() }).strict(),
|
|
293
|
+
]),
|
|
294
|
+
on: z.union([
|
|
295
|
+
z.literal('create'),
|
|
296
|
+
z.object({ column: refSchema, values: z.array(scalarSchema).min(1).max(16) }).strict(),
|
|
297
|
+
]),
|
|
298
|
+
})
|
|
299
|
+
.strict()
|
|
300
|
+
.optional(),
|
|
301
|
+
venueLocal: z.literal(true).optional(),
|
|
302
|
+
/**
|
|
303
|
+
* Whether the column holds personal data, when the app knows better than
|
|
304
|
+
* a guess from its name: a venue's own `phone` and `address` are a
|
|
305
|
+
* business's, and its guest page may show them. The operator can still
|
|
306
|
+
* mark it otherwise.
|
|
307
|
+
*/
|
|
308
|
+
personal: z.boolean().optional(),
|
|
309
|
+
})
|
|
310
|
+
.strict();
|
|
311
|
+
/**
|
|
312
|
+
* A limit on how much of a slot rows may take — the booking guard. Only rows
|
|
313
|
+
* whose `countWhere` column holds one of its values count (a cancelled booking
|
|
314
|
+
* holds no seats).
|
|
315
|
+
*/
|
|
316
|
+
export const capacitySchema = z
|
|
317
|
+
.object({
|
|
318
|
+
slot: refSchema,
|
|
319
|
+
amount: refSchema,
|
|
320
|
+
perSlot: numberOrSetting,
|
|
321
|
+
countWhere: z.object({ column: refSchema, values: z.array(z.string().min(1)).min(1) }).strict().optional(),
|
|
322
|
+
slotMinutes: numberOrSetting,
|
|
323
|
+
windowDays: numberOrSetting.optional(),
|
|
324
|
+
opens: z.union([z.string().regex(/^\d{2}:\d{2}$/), z.object({ table: refSchema, column: refSchema }).strict()]).optional(),
|
|
325
|
+
closes: z.union([z.string().regex(/^\d{2}:\d{2}$/), z.object({ table: refSchema, column: refSchema }).strict()]).optional(),
|
|
326
|
+
/** A table, a room: the limit applies per value of this column too. */
|
|
327
|
+
resource: refSchema.optional(),
|
|
328
|
+
/**
|
|
329
|
+
* How many hours before its time a guest may still cancel through the
|
|
330
|
+
* public API; later, only the venue can. Staff are never held to it.
|
|
331
|
+
*/
|
|
332
|
+
cancelHours: numberOrSetting.optional(),
|
|
333
|
+
})
|
|
334
|
+
.strict();
|
|
335
|
+
/** The longest `maxLength` a text column may ask for (see `maxLength` below). */
|
|
336
|
+
export const MAX_TEXT_LENGTH = 1000;
|
|
337
|
+
/**
|
|
338
|
+
* Why a column's `default` cannot be created, or `null` when it can.
|
|
339
|
+
*
|
|
340
|
+
* The rules are the ones every one of the three databases can honour with the
|
|
341
|
+
* same meaning, so a manifest that validates installs the same everywhere:
|
|
342
|
+
*
|
|
343
|
+
* - `now` only on a `timestamptz`, and a timestamp takes nothing else. A
|
|
344
|
+
* literal timestamp default is a fixed moment, which is never what a
|
|
345
|
+
* manifest author means.
|
|
346
|
+
* - `text` needs `maxLength`: MySQL gives an unbounded TEXT column no literal
|
|
347
|
+
* default, and a manifest must not install on two engines and fail on the
|
|
348
|
+
* third.
|
|
349
|
+
* - `json`, `blob`, `date`, keys and foreign keys take none.
|
|
350
|
+
* - an enum's default is one of its values, a number is a number, a boolean a
|
|
351
|
+
* boolean.
|
|
352
|
+
*/
|
|
353
|
+
export function defaultIssue(c) {
|
|
354
|
+
const value = c.default;
|
|
355
|
+
if (value === undefined)
|
|
356
|
+
return null;
|
|
357
|
+
if (c.role === 'pk')
|
|
358
|
+
return 'a primary key takes no default; an int key numbers itself';
|
|
359
|
+
switch (c.type) {
|
|
360
|
+
case 'timestamptz':
|
|
361
|
+
return value === 'now' ? null : 'a timestamptz default must be "now"';
|
|
362
|
+
case 'text':
|
|
363
|
+
if (typeof value !== 'string')
|
|
364
|
+
return 'a text default must be a string';
|
|
365
|
+
if (c.maxLength === undefined)
|
|
366
|
+
return 'a text default needs maxLength (MySQL gives TEXT no default)';
|
|
367
|
+
return value.length <= c.maxLength ? null : 'the default is longer than maxLength';
|
|
368
|
+
case 'enum':
|
|
369
|
+
return typeof value === 'string' && (c.enum ?? []).includes(value)
|
|
370
|
+
? null
|
|
371
|
+
: 'an enum default must be one of its values';
|
|
372
|
+
case 'int':
|
|
373
|
+
case 'bigint':
|
|
374
|
+
return typeof value === 'number' && Number.isInteger(value) ? null : 'an integer default must be a whole number';
|
|
375
|
+
case 'decimal':
|
|
376
|
+
case 'money':
|
|
377
|
+
case 'float':
|
|
378
|
+
return typeof value === 'number' ? null : 'a numeric default must be a number';
|
|
379
|
+
case 'bool':
|
|
380
|
+
return typeof value === 'boolean' ? null : 'a bool default must be true or false';
|
|
381
|
+
default:
|
|
382
|
+
return `a ${c.type} column takes no default`;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
170
385
|
export const requiredColumnSchema = z
|
|
171
386
|
.object({
|
|
172
387
|
ref: z.string().regex(/^[a-z][a-z0-9_]*$/, 'column ref must be a snake_case identifier'),
|
|
@@ -177,6 +392,38 @@ export const requiredColumnSchema = z
|
|
|
177
392
|
// enum values when `type: 'enum'`; fk target ref when `type: 'fk'`.
|
|
178
393
|
enum: z.array(z.string().min(1)).optional(),
|
|
179
394
|
references: z.string().optional(),
|
|
395
|
+
/**
|
|
396
|
+
* The value the DATABASE fills when an insert leaves the column out. A
|
|
397
|
+
* literal of the column's own type, or the string `now` on a
|
|
398
|
+
* `timestamptz` column. Without one, a NOT NULL column refuses every insert
|
|
399
|
+
* that omits it — which is every insert an operator makes from a form that
|
|
400
|
+
* does not show that column.
|
|
401
|
+
*
|
|
402
|
+
* Additive and optional: an older server refuses a manifest carrying it
|
|
403
|
+
* (every block is `.strict()`), so an app that uses it raises
|
|
404
|
+
* `minAdminiumVersion` to the release that reads it.
|
|
405
|
+
*/
|
|
406
|
+
default: z.union([z.string(), z.number().finite(), z.boolean()]).optional(),
|
|
407
|
+
/**
|
|
408
|
+
* `text` only: a `varchar(n)` instead of unbounded `text`. Short text is
|
|
409
|
+
* what a form, a unique index and MySQL's key limit all want. At most 1000
|
|
410
|
+
* characters — MySQL counts four bytes per character against one 65,535-byte
|
|
411
|
+
* row, so a few long columns would refuse the table.
|
|
412
|
+
*/
|
|
413
|
+
maxLength: z.number().int().min(1).max(MAX_TEXT_LENGTH).optional(),
|
|
414
|
+
/**
|
|
415
|
+
* No two rows may hold the same value (empty values excepted). A text
|
|
416
|
+
* column needs `maxLength`: MySQL indexes no unbounded text.
|
|
417
|
+
*/
|
|
418
|
+
unique: z.literal(true).optional(),
|
|
419
|
+
/** Rules Adminium keeps on the column once installed (see `columnRulesSchema`). */
|
|
420
|
+
rules: columnRulesSchema.optional(),
|
|
421
|
+
/**
|
|
422
|
+
* What a person calls the column — a form's field, a list's heading — in
|
|
423
|
+
* every language the app speaks. Installed as the column's label, which
|
|
424
|
+
* the operator can rename like any other.
|
|
425
|
+
*/
|
|
426
|
+
label: textOrLabels.optional(),
|
|
180
427
|
})
|
|
181
428
|
.strict()
|
|
182
429
|
.refine((c) => c.type !== 'enum' || (c.enum !== undefined && c.enum.length > 0), {
|
|
@@ -186,17 +433,66 @@ export const requiredColumnSchema = z
|
|
|
186
433
|
.refine((c) => c.type !== 'fk' || c.references !== undefined, {
|
|
187
434
|
message: 'a fk column must name its references target',
|
|
188
435
|
path: ['references'],
|
|
436
|
+
})
|
|
437
|
+
.refine((c) => c.maxLength === undefined || c.type === 'text', {
|
|
438
|
+
message: 'maxLength applies to a text column only',
|
|
439
|
+
path: ['maxLength'],
|
|
440
|
+
})
|
|
441
|
+
.refine((c) => c.unique === undefined || (c.role !== 'pk' && !['json', 'blob'].includes(c.type) && (c.type !== 'text' || c.maxLength !== undefined)), {
|
|
442
|
+
message: 'unique needs a column that can be indexed: not the key, not json or blob, text with maxLength',
|
|
443
|
+
path: ['unique'],
|
|
444
|
+
})
|
|
445
|
+
.superRefine((c, ctx) => {
|
|
446
|
+
const issue = defaultIssue(c);
|
|
447
|
+
if (issue !== null)
|
|
448
|
+
ctx.addIssue({ code: 'custom', message: issue, path: ['default'] });
|
|
189
449
|
});
|
|
190
450
|
export const requiredTableSchema = z
|
|
191
451
|
.object({
|
|
192
452
|
ref: z.string().regex(/^[a-z][a-z0-9_]*$/, 'table ref must be a snake_case identifier'),
|
|
193
453
|
columns: z.array(requiredColumnSchema).min(1),
|
|
454
|
+
/**
|
|
455
|
+
* A shape other apps may share, `<name>@<version>` (`menu@1`). Two apps
|
|
456
|
+
* that declare the same shape can use one table between them.
|
|
457
|
+
*/
|
|
458
|
+
shape: z.string().regex(/^[a-z][a-z0-9-]*@\d+$/, 'a shape is <name>@<version>').optional(),
|
|
459
|
+
capacity: capacitySchema.optional(),
|
|
460
|
+
/** Booking people: no two counted rows of one resource may overlap (see `booking.ts`). */
|
|
461
|
+
booking: bookingSchema.optional(),
|
|
462
|
+
/**
|
|
463
|
+
* What a person calls ONE row ("Category") and the table ("Categories"),
|
|
464
|
+
* in every language the app speaks: a form's title and button, a page's
|
|
465
|
+
* empty state, a link's field. Installed as the table's label. Without
|
|
466
|
+
* them Adminium names the table from its real name (`pos_menu_categories`).
|
|
467
|
+
*/
|
|
468
|
+
label: textOrLabels.optional(),
|
|
469
|
+
labelPlural: textOrLabels.optional(),
|
|
470
|
+
/** The column that names a row wherever another table links to it (a category's `name`). */
|
|
471
|
+
keyField: z.string().regex(/^[a-z][a-z0-9_]*$/, 'a column ref').optional(),
|
|
194
472
|
})
|
|
195
473
|
.strict()
|
|
196
|
-
.refine((t) => new Set(t.columns.map((c) => c.ref)).size === t.columns.length, { message: 'duplicate column ref in table', path: ['columns'] })
|
|
474
|
+
.refine((t) => new Set(t.columns.map((c) => c.ref)).size === t.columns.length, { message: 'duplicate column ref in table', path: ['columns'] })
|
|
475
|
+
.refine((t) => t.keyField === undefined || t.columns.some((c) => c.ref === t.keyField), {
|
|
476
|
+
message: 'keyField must name one of the table’s columns',
|
|
477
|
+
path: ['keyField'],
|
|
478
|
+
})
|
|
479
|
+
.refine((t) => t.labelPlural === undefined || t.label !== undefined, {
|
|
480
|
+
message: 'labelPlural needs a label',
|
|
481
|
+
path: ['labelPlural'],
|
|
482
|
+
})
|
|
483
|
+
.refine((t) => t.capacity === undefined || t.booking === undefined, {
|
|
484
|
+
message: 'a table has a capacity or a booking rule, not both',
|
|
485
|
+
path: ['booking'],
|
|
486
|
+
});
|
|
197
487
|
export const requiredSchemaSchema = z
|
|
198
488
|
.object({
|
|
199
489
|
tables: z.array(requiredTableSchema).min(1),
|
|
490
|
+
/**
|
|
491
|
+
* Adminium names every table `<app key>_<ref>` (`pos_tickets`), and tells
|
|
492
|
+
* the app the real names at boot. Opt-in, because an app built before it
|
|
493
|
+
* hard-codes its table names. Apps only: an add-on uses its host's tables.
|
|
494
|
+
*/
|
|
495
|
+
prefixed: z.literal(true).optional(),
|
|
200
496
|
})
|
|
201
497
|
.strict()
|
|
202
498
|
.refine((s) => new Set(s.tables.map((t) => t.ref)).size === s.tables.length, {
|
|
@@ -220,6 +516,14 @@ export const pageSchema = z
|
|
|
220
516
|
// page-local table ref → requiredSchema table ref (resolved at install).
|
|
221
517
|
bindings: z.record(z.string(), z.string()).optional(),
|
|
222
518
|
config: z.record(z.string(), z.unknown()).optional(),
|
|
519
|
+
/**
|
|
520
|
+
* The page's title in other languages, keyed by BCP 47 tag (`de-DE`)
|
|
521
|
+
*. `title.fallback` stays the English; the sidebar shows the
|
|
522
|
+
* operator's language until the operator renames the page.
|
|
523
|
+
*/
|
|
524
|
+
titles: z
|
|
525
|
+
.record(z.string().regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, 'titles are keyed by BCP 47 tag'), z.string().min(1).max(120))
|
|
526
|
+
.optional(),
|
|
223
527
|
})
|
|
224
528
|
.strict();
|
|
225
529
|
// ── roles ────────────────────────────────────────────────────────────────────
|
|
@@ -230,6 +534,10 @@ export const roleSchema = z
|
|
|
230
534
|
cloneFrom: z.string().optional(),
|
|
231
535
|
// grant strings validated against the RBAC grammar at install time.
|
|
232
536
|
permissions: z.array(z.string().min(1)).optional(),
|
|
537
|
+
/** Opens the app's own screens and never the dashboard (a till cashier). */
|
|
538
|
+
screensOnly: z.boolean().optional(),
|
|
539
|
+
/** Per table, what the role's update there may write (roles.ts). */
|
|
540
|
+
limits: roleLimitsSchema.optional(),
|
|
233
541
|
})
|
|
234
542
|
.strict();
|
|
235
543
|
// ── settings ─────────────────────────────────────────────────────────────────
|
|
@@ -334,6 +642,46 @@ export const frontendSchema = z
|
|
|
334
642
|
* permanently uncheckable.
|
|
335
643
|
*/
|
|
336
644
|
routes: z.record(z.string(), z.string()).optional(),
|
|
645
|
+
/**
|
|
646
|
+
* Where a STAFF side opens by default: inside the dashboard, or on its own
|
|
647
|
+
* address (a till). The operator can change it; absent means inside.
|
|
648
|
+
*/
|
|
649
|
+
placement: z.enum(['internal', 'external']).optional(),
|
|
650
|
+
/** Whether the side starts switched on. Absent means on. */
|
|
651
|
+
enabled: z.boolean().optional(),
|
|
652
|
+
})
|
|
653
|
+
.strict();
|
|
654
|
+
// ── the app's sidebar, public access and sample data ─────────────────────────
|
|
655
|
+
/** A heading in the app's own sidebar section; pages name it in `nav.group`. */
|
|
656
|
+
/**
|
|
657
|
+
* A list of answers the app ships, for a column's `options: {list}`. Installed
|
|
658
|
+
* as the option list `<appKey>-<name>`, which the operator can then edit like
|
|
659
|
+
* any other; a list of that key they already made is left alone.
|
|
660
|
+
*/
|
|
661
|
+
export const optionListSchema = z
|
|
662
|
+
.object({
|
|
663
|
+
label: textOrLabels,
|
|
664
|
+
values: z
|
|
665
|
+
.array(z
|
|
666
|
+
.object({ value: z.string().min(1).max(256), label: textOrLabels.optional(), tone: z.string().max(32).optional() })
|
|
667
|
+
.strict())
|
|
668
|
+
.min(1)
|
|
669
|
+
.max(500),
|
|
670
|
+
})
|
|
671
|
+
.strict();
|
|
672
|
+
export const navGroupSchema = z
|
|
673
|
+
.object({
|
|
674
|
+
key: z.string().regex(/^[a-z][a-z0-9-]*$/, 'a nav group key is kebab-case'),
|
|
675
|
+
label: labelsSchema,
|
|
676
|
+
order: z.number().int(),
|
|
677
|
+
})
|
|
678
|
+
.strict();
|
|
679
|
+
/** What the app's public screens may do (see `public-access.ts`). */
|
|
680
|
+
export { publicAccessSchema, publicKeysSchema };
|
|
681
|
+
/** The bundled sample data file, inside the package's `seeds/` folder. */
|
|
682
|
+
export const sampleDataSchema = z
|
|
683
|
+
.object({
|
|
684
|
+
file: z.string().regex(/^seeds\/[a-z0-9][a-z0-9._-]*\.json$/, 'a file in seeds/, ending .json'),
|
|
337
685
|
})
|
|
338
686
|
.strict();
|
|
339
687
|
/** Hosted-only and offline-required are mutually exclusive. */
|
|
@@ -371,6 +719,190 @@ const SIDES_MESSAGE = {
|
|
|
371
719
|
message: 'each side may appear at most once in `frontends`',
|
|
372
720
|
path: ['frontends'],
|
|
373
721
|
};
|
|
722
|
+
/** The prefix Adminium gives an app's tables: its key, `-` as `_`, then `_`. */
|
|
723
|
+
export function prefixFor(appKey) {
|
|
724
|
+
return `${appKey.replace(/-/g, '_')}_`;
|
|
725
|
+
}
|
|
726
|
+
/** The longest real table name every engine accepts (Postgres: 63 bytes). */
|
|
727
|
+
export const MAX_TABLE_NAME = 63;
|
|
728
|
+
/**
|
|
729
|
+
* Every name a manifest's own blocks use must name something the manifest
|
|
730
|
+
* declares: a rule's columns, a capacity's or a booking's columns and tables,
|
|
731
|
+
* a public table and the columns it reads and writes, the outbox and its
|
|
732
|
+
* templates. Checked here so an app's CI refuses a typo long before an
|
|
733
|
+
* operator's install would.
|
|
734
|
+
*/
|
|
735
|
+
export function appReferenceIssues(m) {
|
|
736
|
+
const out = [];
|
|
737
|
+
const index = tableIndex(m.requiredSchema.tables);
|
|
738
|
+
const tables = new Map(m.requiredSchema.tables.map((t) => [t.ref, t]));
|
|
739
|
+
const has = index.has;
|
|
740
|
+
/** Per table, the columns Adminium decides and no writer may set. */
|
|
741
|
+
const decided = new Map();
|
|
742
|
+
const decide = (table, column) => {
|
|
743
|
+
const set = decided.get(table) ?? new Set();
|
|
744
|
+
set.add(column);
|
|
745
|
+
decided.set(table, set);
|
|
746
|
+
};
|
|
747
|
+
m.requiredSchema.tables.forEach((table, t) => {
|
|
748
|
+
const at = (...rest) => ['requiredSchema', 'tables', t, ...rest];
|
|
749
|
+
if (m.requiredSchema.prefixed === true && prefixFor(m.key).length + table.ref.length > MAX_TABLE_NAME) {
|
|
750
|
+
out.push({ path: at('ref'), message: `"${prefixFor(m.key)}${table.ref}" is longer than ${String(MAX_TABLE_NAME)} characters` });
|
|
751
|
+
}
|
|
752
|
+
const balances = new Map();
|
|
753
|
+
table.columns.forEach((column) => {
|
|
754
|
+
const balance = column.rules?.rollup?.balance;
|
|
755
|
+
if (balance !== undefined)
|
|
756
|
+
balances.set(balance.column, column.ref);
|
|
757
|
+
});
|
|
758
|
+
table.columns.forEach((column, c) => {
|
|
759
|
+
const rules = column.rules;
|
|
760
|
+
if (rules === undefined)
|
|
761
|
+
return;
|
|
762
|
+
const here = (...rest) => at('columns', c, 'rules', ...rest);
|
|
763
|
+
if (rules.copy !== undefined || rules.sequence !== undefined || rules.code !== undefined || rules.rollup !== undefined || rules.stamp !== undefined) {
|
|
764
|
+
decide(table.ref, column.ref);
|
|
765
|
+
}
|
|
766
|
+
// A list the app ships, or one Adminium has built in; nothing else exists
|
|
767
|
+
// on every install.
|
|
768
|
+
if (rules.options !== undefined && 'list' in rules.options) {
|
|
769
|
+
const list = rules.options.list;
|
|
770
|
+
if (!list.startsWith('builtin:') && m.optionLists?.[list] === undefined) {
|
|
771
|
+
out.push({ path: here('options', 'list'), message: `"${list}" is not one of the app's option lists` });
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (rules.copy !== undefined) {
|
|
775
|
+
const via = table.columns.find((x) => x.ref === rules.copy.via);
|
|
776
|
+
if (via?.type !== 'fk' || via.references === undefined) {
|
|
777
|
+
out.push({ path: here('copy', 'via'), message: `"${rules.copy.via}" is not a foreign key of "${table.ref}"` });
|
|
778
|
+
}
|
|
779
|
+
else if (!has(via.references, rules.copy.from)) {
|
|
780
|
+
out.push({ path: here('copy', 'from'), message: `"${via.references}" has no column "${rules.copy.from}"` });
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
if (rules.rollup !== undefined) {
|
|
784
|
+
const r = rules.rollup;
|
|
785
|
+
const child = tables.get(r.from);
|
|
786
|
+
const via = child?.columns.find((x) => x.ref === r.via);
|
|
787
|
+
if (child === undefined) {
|
|
788
|
+
out.push({ path: here('rollup', 'from'), message: `"${r.from}" is not a table of this app` });
|
|
789
|
+
}
|
|
790
|
+
else if (via?.type !== 'fk' || via.references !== table.ref) {
|
|
791
|
+
out.push({ path: here('rollup', 'via'), message: `"${r.from}.${r.via}" does not point at "${table.ref}"` });
|
|
792
|
+
}
|
|
793
|
+
else {
|
|
794
|
+
for (const name of [r.sum, ...(r.times === undefined ? [] : [r.times]), ...(r.unlessSet === undefined ? [] : [r.unlessSet])]) {
|
|
795
|
+
if (!has(r.from, name))
|
|
796
|
+
out.push({ path: here('rollup'), message: `"${r.from}" has no column "${name}"` });
|
|
797
|
+
}
|
|
798
|
+
if (r.where !== undefined) {
|
|
799
|
+
const filter = index.column(r.from, r.where.column);
|
|
800
|
+
if (filter === undefined)
|
|
801
|
+
out.push({ path: here('rollup', 'where'), message: `"${r.from}" has no column "${r.where.column}"` });
|
|
802
|
+
else if (!valueFits(filter, r.where.eq))
|
|
803
|
+
out.push({ path: here('rollup', 'where'), message: `${JSON.stringify(r.where.eq)} is not a value of "${r.from}.${r.where.column}"` });
|
|
804
|
+
// An empty value equals nothing: a row left empty would drop out of the total unseen.
|
|
805
|
+
else if (filter.nullable === true)
|
|
806
|
+
out.push({ path: here('rollup', 'where'), message: `"${r.from}.${r.where.column}" may be empty, so a row could drop out of the total; make it not nullable` });
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (r.balance !== undefined) {
|
|
810
|
+
const b = r.balance;
|
|
811
|
+
decide(table.ref, b.column);
|
|
812
|
+
for (const [name, ref] of [['column', b.column], ['of', b.of], ...(b.minus ?? []).map((x) => ['minus', x])]) {
|
|
813
|
+
const found = index.column(table.ref, ref);
|
|
814
|
+
if (found === undefined)
|
|
815
|
+
out.push({ path: here('rollup', 'balance', name), message: `"${table.ref}" has no column "${ref}"` });
|
|
816
|
+
else if (!NUMERIC_TYPES.includes(found.type))
|
|
817
|
+
out.push({ path: here('rollup', 'balance', name), message: `"${table.ref}.${ref}" is not a number` });
|
|
818
|
+
}
|
|
819
|
+
if ([b.of, ...(b.minus ?? [])].includes(b.column) || b.column === column.ref) {
|
|
820
|
+
out.push({ path: here('rollup', 'balance', 'column'), message: 'the balance is a column of its own' });
|
|
821
|
+
}
|
|
822
|
+
if (index.column(table.ref, b.column)?.rules !== undefined) {
|
|
823
|
+
out.push({ path: here('rollup', 'balance', 'column'), message: `"${b.column}" is the balance Adminium keeps; it takes no rules of its own` });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// A cap holds a balance at zero: this rollup's, or the one that takes
|
|
827
|
+
// this total away (a write-off is capped by the balance it lowers).
|
|
828
|
+
if (r.cap === true && r.balance === undefined) {
|
|
829
|
+
const capped = table.columns.some((x) => x.rules?.rollup?.balance?.minus?.includes(column.ref) === true);
|
|
830
|
+
if (!capped)
|
|
831
|
+
out.push({ path: here('rollup', 'cap'), message: 'a cap needs a balance: declare one here, or subtract this total in one' });
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (rules.stamp !== undefined) {
|
|
835
|
+
const stamp = rules.stamp;
|
|
836
|
+
const set = stamp.set;
|
|
837
|
+
if (set === 'now' && column.type !== 'timestamptz') {
|
|
838
|
+
out.push({ path: here('stamp', 'set'), message: 'a "now" stamp needs a timestamptz column' });
|
|
839
|
+
}
|
|
840
|
+
else if ((set === 'user-name' || set === 'user-id') && column.type !== 'text') {
|
|
841
|
+
out.push({ path: here('stamp', 'set'), message: `a "${set}" stamp needs a text column` });
|
|
842
|
+
}
|
|
843
|
+
else if (typeof set === 'object') {
|
|
844
|
+
for (const value of [set.byOrigin.public, set.byOrigin.staff]) {
|
|
845
|
+
if (!valueFits(column, value))
|
|
846
|
+
out.push({ path: here('stamp', 'set'), message: `"${value}" is not a value of "${table.ref}.${column.ref}"` });
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (stamp.on !== 'create') {
|
|
850
|
+
const watched = index.column(table.ref, stamp.on.column);
|
|
851
|
+
if (watched === undefined) {
|
|
852
|
+
out.push({ path: here('stamp', 'on', 'column'), message: `"${table.ref}" has no column "${stamp.on.column}"` });
|
|
853
|
+
}
|
|
854
|
+
else if (watched.ref === column.ref) {
|
|
855
|
+
out.push({ path: here('stamp', 'on', 'column'), message: 'a stamp watches another column' });
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
for (const value of stamp.on.values) {
|
|
859
|
+
if (!valueFits(watched, value))
|
|
860
|
+
out.push({ path: here('stamp', 'on', 'values'), message: `${JSON.stringify(value)} is not a value of "${table.ref}.${watched.ref}"` });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
const others = ['copy', 'sequence', 'code', 'rollup'].filter((name) => rules[name] !== undefined);
|
|
865
|
+
if (others.length > 0)
|
|
866
|
+
out.push({ path: here('stamp'), message: `a stamped column is not also decided by ${others.join(', ')}` });
|
|
867
|
+
}
|
|
868
|
+
if ((rules.sequence !== undefined || rules.code !== undefined) && column.role === 'pk') {
|
|
869
|
+
out.push({ path: here(), message: 'a primary key numbers itself; it takes no sequence or code rule' });
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
const cap = table.capacity;
|
|
873
|
+
if (cap !== undefined) {
|
|
874
|
+
for (const [name, value] of [['slot', cap.slot], ['amount', cap.amount], ['resource', cap.resource], ['countWhere', cap.countWhere?.column]]) {
|
|
875
|
+
if (value !== undefined && !has(table.ref, value)) {
|
|
876
|
+
out.push({ path: at('capacity', name), message: `"${table.ref}" has no column "${value}"` });
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
for (const [name, value] of Object.entries(cap)) {
|
|
880
|
+
if (typeof value === 'object' && value !== null && 'table' in value && 'column' in value) {
|
|
881
|
+
const setting = value;
|
|
882
|
+
if (!has(setting.table, setting.column)) {
|
|
883
|
+
out.push({ path: at('capacity', name), message: `"${setting.table}" has no column "${setting.column}"` });
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (table.booking !== undefined) {
|
|
889
|
+
out.push(...bookingIssues(table, table.booking, index, at));
|
|
890
|
+
// The late flag is Adminium's to set.
|
|
891
|
+
if (table.booking.cancel?.flag !== undefined)
|
|
892
|
+
decide(table.ref, table.booking.cancel.flag);
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
out.push(...publicAccessIssues(m.publicAccess ?? [], {
|
|
896
|
+
index,
|
|
897
|
+
decided: (table) => decided.get(table) ?? new Set(),
|
|
898
|
+
answersAvailability: (table) => tables.get(table)?.capacity !== undefined || tables.get(table)?.booking !== undefined,
|
|
899
|
+
publicKeys: m.publicKeys,
|
|
900
|
+
roles: m.roles ?? [],
|
|
901
|
+
}));
|
|
902
|
+
out.push(...outboxIssues(m, index));
|
|
903
|
+
out.push(...roleLimitIssues(m.roles ?? [], index));
|
|
904
|
+
return out;
|
|
905
|
+
}
|
|
374
906
|
export const appManifestSchema = z
|
|
375
907
|
.object({
|
|
376
908
|
kind: z.literal('app'),
|
|
@@ -394,11 +926,31 @@ export const appManifestSchema = z
|
|
|
394
926
|
* required-singular shape however their `requiredSchema` was repaired.
|
|
395
927
|
*/
|
|
396
928
|
frontends: z.array(frontendSchema).min(1),
|
|
929
|
+
navGroups: z.array(navGroupSchema).max(12).optional(),
|
|
930
|
+
/** Keyed by a kebab-case name; a column names one with `options: {list: name}`. */
|
|
931
|
+
optionLists: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/, 'a list name is kebab-case'), optionListSchema).optional(),
|
|
932
|
+
publicAccess: z.array(publicAccessSchema).max(32).optional(),
|
|
933
|
+
/** Browser keys besides the app's own `customer` key (see `public-access.ts`). */
|
|
934
|
+
publicKeys: publicKeysSchema.optional(),
|
|
935
|
+
/** The app's emails: its outbox table and what queues rows in it (see `outbox.ts`). */
|
|
936
|
+
outbox: outboxSchema.optional(),
|
|
937
|
+
/** The templates the outbox sends, in each language the app ships. */
|
|
938
|
+
emailTemplates: z.array(emailTemplateSchema).max(16).optional(),
|
|
939
|
+
sampleData: sampleDataSchema.optional(),
|
|
397
940
|
})
|
|
398
941
|
.strict()
|
|
399
942
|
.refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
|
|
400
943
|
.refine(compatibilityWindowOrdered, { ...WINDOW_MESSAGE, path: [...WINDOW_MESSAGE.path] })
|
|
401
|
-
.refine(sidesAreDistinct, { ...SIDES_MESSAGE, path: [...SIDES_MESSAGE.path] })
|
|
944
|
+
.refine(sidesAreDistinct, { ...SIDES_MESSAGE, path: [...SIDES_MESSAGE.path] })
|
|
945
|
+
.superRefine((m, ctx) => {
|
|
946
|
+
for (const issue of appReferenceIssues(m)) {
|
|
947
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
948
|
+
}
|
|
949
|
+
// A calendar page's named columns, against the app's own tables.
|
|
950
|
+
for (const issue of pageCalendarIssues(m.pages, tableIndex(m.requiredSchema.tables))) {
|
|
951
|
+
ctx.addIssue({ code: 'custom', message: issue.message, path: issue.path });
|
|
952
|
+
}
|
|
953
|
+
});
|
|
402
954
|
/**
|
|
403
955
|
* `pages`, `roles` and `frontends` are absent from this branch on purpose, and
|
|
404
956
|
* leaving the fields off a `.strict()` schema entirely is a stronger guarantee
|
|
@@ -431,7 +983,11 @@ export const addOnManifestSchema = z
|
|
|
431
983
|
})
|
|
432
984
|
.strict()
|
|
433
985
|
.refine(capabilitiesNotContradictory, { ...CAPS_MESSAGE, path: [...CAPS_MESSAGE.path] })
|
|
434
|
-
.refine(compatibilityWindowOrdered, { ...WINDOW_MESSAGE, path: [...WINDOW_MESSAGE.path] })
|
|
986
|
+
.refine(compatibilityWindowOrdered, { ...WINDOW_MESSAGE, path: [...WINDOW_MESSAGE.path] })
|
|
987
|
+
.refine((m) => m.requiredSchema?.prefixed !== true, {
|
|
988
|
+
message: 'an add-on uses its host app\'s tables, so its own cannot be prefixed',
|
|
989
|
+
path: ['requiredSchema', 'prefixed'],
|
|
990
|
+
});
|
|
435
991
|
/**
|
|
436
992
|
* The envelope. A document with no `kind` is treated as an app before the union
|
|
437
993
|
* discriminates, which is what keeps every pre-wave-4 manifest valid.
|
|
@@ -544,19 +1100,4 @@ export function addOnIssues(m, ctx = {}) {
|
|
|
544
1100
|
});
|
|
545
1101
|
return out;
|
|
546
1102
|
}
|
|
547
|
-
/**
|
|
548
|
-
* Numeric semver compare on the release triple (pre-release/build ignored —
|
|
549
|
-
* enough for the compatibility-window and upgrade ordering checks). Returns
|
|
550
|
-
* <0, 0, >0. Exported for the installer's upgrade rule.
|
|
551
|
-
*/
|
|
552
|
-
export function compareSemver(a, b) {
|
|
553
|
-
const triple = (v) => v
|
|
554
|
-
.split('+')[0]
|
|
555
|
-
.split('-')[0]
|
|
556
|
-
.split('.')
|
|
557
|
-
.map((n) => Number.parseInt(n, 10));
|
|
558
|
-
const [a1 = 0, a2 = 0, a3 = 0] = triple(a);
|
|
559
|
-
const [b1 = 0, b2 = 0, b3 = 0] = triple(b);
|
|
560
|
-
return a1 - b1 || a2 - b2 || a3 - b3;
|
|
561
|
-
}
|
|
562
1103
|
//# sourceMappingURL=schema.js.map
|