@brydio/manifest 0.1.0-alpha.32 → 0.1.0-alpha.34
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/package.json +2 -2
- package/src/field-types.d.ts +6 -0
- package/src/field-types.js +2 -2
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/open-schema.d.ts +128 -0
- package/src/open-schema.js +444 -0
- package/src/schema.d.ts +54 -1
- package/src/schema.js +87 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brydio/manifest",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.34",
|
|
4
4
|
"description": "The shape of a Brydio app's manifest, its collections' schemas, and the checks that say what is wrong with one.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"url": "https://github.com/nakel-ola/brydio-sdk",
|
|
11
11
|
"directory": "packages/manifest"
|
|
12
12
|
},
|
|
13
|
-
"gitHead": "
|
|
13
|
+
"gitHead": "4da27394c9c0243956b58a6dfdc983b691e67ef6",
|
|
14
14
|
"exports": {
|
|
15
15
|
".": {
|
|
16
16
|
"types": "./src/index.d.ts",
|
package/src/field-types.d.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface FieldType {
|
|
|
34
34
|
* check, and a tool's change to it merges into the live text.
|
|
35
35
|
*/
|
|
36
36
|
coedit?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Kept in the plain `fields` column although its kind is not (P5): an
|
|
39
|
+
* open-schema table's multi-select and link lists, and the field naming a
|
|
40
|
+
* row's table. Set by the host only, never parsed from a manifest.
|
|
41
|
+
*/
|
|
42
|
+
plain?: boolean;
|
|
37
43
|
}
|
|
38
44
|
/**
|
|
39
45
|
* The bounds a schema and a record are held to (A3-F01-S03).
|
package/src/field-types.js
CHANGED
|
@@ -209,9 +209,9 @@ function parseEnumeration(values) {
|
|
|
209
209
|
return { kind: 'enum', optional: false, values: values };
|
|
210
210
|
}
|
|
211
211
|
/** True for a type whose value lands in the plain `fields` column. */
|
|
212
|
-
export const isStructured = (type) => STRUCTURED.has(type.kind);
|
|
212
|
+
export const isStructured = (type) => STRUCTURED.has(type.kind) || type.plain === true;
|
|
213
213
|
/** True for a type a list can be ordered by: structured, and one value. */
|
|
214
|
-
export const isSortable = (type) => STRUCTURED.has(type.kind);
|
|
214
|
+
export const isSortable = (type) => STRUCTURED.has(type.kind) || (type.plain === true && type.kind !== 'string[]');
|
|
215
215
|
export const isSearchable = (type) => SEARCHABLE.has(type.kind);
|
|
216
216
|
/**
|
|
217
217
|
* The names of a schema's structured fields: the ones kept in plain beside the
|
package/src/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export { BRAND_FIELDS, BRAND_LIMITS, brandFilePath, brandPathsOf, brandProblems,
|
|
|
8
8
|
export { BUNDLE_MANIFEST, BUNDLE_MAX_BYTES, BUNDLE_PATH_MAX_CHARS, bundleBytes, bundleHash, brandOf, bundleProblem, codeOf, isBundlePath, isScriptPath, sizeOf, type BundleFiles, type BundleProblem, type BundleProblemCode, } from './bundle.js';
|
|
9
9
|
export { COLOUR_TOKENS, coeditedFields, FIELD_LIMITS, RESERVED_FIELDS, describeType, isCoedited, isSearchable, isSortable, isStructured, parseFieldType, structuredFields, valueProblem, valueSchema, FieldTypeInvalid, type DocumentOf, type FieldKind, type FieldType, type FieldValue, type FieldsOf, } from './field-types.js';
|
|
10
10
|
export { appManifestSchema, collectionOf, collectionsOf, dataProblems, effectivePlacementKey, HOME_CARD_SIZES, labelOf, manifestExtensionsSchema, newAppManifestSchema, SIZED_PLACEMENT_KINDS, MAX_APP_SECRETS, MAX_SECRET_CHARS, PLACEMENT_KEY, SECRET_NAME, storedExtensionsSchema, type AppManifestWithData, type CollectionSpec, type DataProblem, type DataProblemCode, type DataProblemOptions, type HomeCardSize, type ManifestExtensions, type SecretSpec, } from './schema.js';
|
|
11
|
+
export { OPEN_LIMITS, OPEN_TYPES, choicesProblem, definitionOf, definitionTypeProblem, describeField, fieldTypeOf, hasChoices, isPlainOpenType, keyFromName, keyProblem, missingRequired, moveValue, openSchemaProblems, openSpec, openValueProblem, type Moved, type OpenField, type OpenFieldChange, type OpenFieldValue, type OpenProblem, type OpenProblemCode, type OpenSchemaFlag, type OpenType, } from './open-schema.js';
|
|
11
12
|
export { DOCUMENT_LIMITS } from './document-limits.js';
|
|
12
13
|
export { HOST_CAPABILITIES, KNOWN_HOST_GRANTS, isHostCapability, unknownHostGrant } from './grants.js';
|
|
13
14
|
export { diffSchemas, migrationProblems, migrationSchema, migrationStepSchema, migrationsSchema, publishedMigrationProblems, schemaOf, type Migration, type MigrationCode, type MigrationProblem, type MigrationStep, type SchemaChange, } from './migrations.js';
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { BRAND_FIELDS, BRAND_LIMITS, brandFilePath, brandPathsOf, brandProblems,
|
|
|
8
8
|
export { BUNDLE_MANIFEST, BUNDLE_MAX_BYTES, BUNDLE_PATH_MAX_CHARS, bundleBytes, bundleHash, brandOf, bundleProblem, codeOf, isBundlePath, isScriptPath, sizeOf, } from "./bundle.js";
|
|
9
9
|
export { COLOUR_TOKENS, coeditedFields, FIELD_LIMITS, RESERVED_FIELDS, describeType, isCoedited, isSearchable, isSortable, isStructured, parseFieldType, structuredFields, valueProblem, valueSchema, FieldTypeInvalid, } from "./field-types.js";
|
|
10
10
|
export { appManifestSchema, collectionOf, collectionsOf, dataProblems, effectivePlacementKey, HOME_CARD_SIZES, labelOf, manifestExtensionsSchema, newAppManifestSchema, SIZED_PLACEMENT_KINDS, MAX_APP_SECRETS, MAX_SECRET_CHARS, PLACEMENT_KEY, SECRET_NAME, storedExtensionsSchema, } from "./schema.js";
|
|
11
|
+
export { OPEN_LIMITS, OPEN_TYPES, choicesProblem, definitionOf, definitionTypeProblem, describeField, fieldTypeOf, hasChoices, isPlainOpenType, keyFromName, keyProblem, missingRequired, moveValue, openSchemaProblems, openSpec, openValueProblem, } from "./open-schema.js";
|
|
11
12
|
export { DOCUMENT_LIMITS } from "./document-limits.js";
|
|
12
13
|
export { HOST_CAPABILITIES, KNOWN_HOST_GRANTS, isHostCapability, unknownHostGrant } from "./grants.js";
|
|
13
14
|
export { diffSchemas, migrationProblems, migrationSchema, migrationStepSchema, migrationsSchema, publishedMigrationProblems, schemaOf, } from "./migrations.js";
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type FieldType } from './field-types.js';
|
|
2
|
+
import type { CollectionSpec } from './schema.js';
|
|
3
|
+
/**
|
|
4
|
+
* Open-schema collections (P5).
|
|
5
|
+
*
|
|
6
|
+
* A collection flagged `openSchema: { fields: "columns", table?: "table" }`
|
|
7
|
+
* keeps the fields its manifest declares, and also the fields its companion
|
|
8
|
+
* collection's records define, per table. This file is the manifest half:
|
|
9
|
+
* which types a definition may name, what the companion must declare, how a
|
|
10
|
+
* definition reads as a `FieldType` the store already knows, and how a value
|
|
11
|
+
* moves when its field changes type. The store half is
|
|
12
|
+
* `apps/data/open-schema.ts`.
|
|
13
|
+
*/
|
|
14
|
+
/** The types a field definition may name. */
|
|
15
|
+
export declare const OPEN_TYPES: readonly ["text", "long_text", "number", "currency", "percent", "rating", "date", "datetime", "checkbox", "select", "multi_select", "person", "link", "url", "email", "phone", "attachment"];
|
|
16
|
+
export type OpenType = (typeof OPEN_TYPES)[number];
|
|
17
|
+
export declare const OPEN_LIMITS: {
|
|
18
|
+
/** Live rows one table may hold (DB-Q2, owner 30 Sep): measured at this size. */
|
|
19
|
+
readonly rowsPerTable: 50000;
|
|
20
|
+
/** Live field definitions one table may hold. */
|
|
21
|
+
readonly fieldsPerTable: 200;
|
|
22
|
+
/** The highest a rating may be. */
|
|
23
|
+
readonly ratingMax: 10;
|
|
24
|
+
};
|
|
25
|
+
/** The flag as the manifest writes it. */
|
|
26
|
+
export interface OpenSchemaFlag {
|
|
27
|
+
/** The companion collection whose records define the fields. */
|
|
28
|
+
fields: string;
|
|
29
|
+
/** A `string` field on both collections naming a row's or a definition's table. */
|
|
30
|
+
table?: string;
|
|
31
|
+
}
|
|
32
|
+
/** One live field, read from a companion record. */
|
|
33
|
+
export interface OpenField {
|
|
34
|
+
/** The companion record's id. */
|
|
35
|
+
id: string;
|
|
36
|
+
/** Stable across renames: what rows, tools, filters and sorts use. */
|
|
37
|
+
key: string;
|
|
38
|
+
name: string;
|
|
39
|
+
type: OpenType;
|
|
40
|
+
/** Null for a collection with no `table`. */
|
|
41
|
+
table: string | null;
|
|
42
|
+
choices: string[];
|
|
43
|
+
required: boolean;
|
|
44
|
+
currency?: string;
|
|
45
|
+
precision?: number;
|
|
46
|
+
linkTo?: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
}
|
|
49
|
+
export type OpenProblemCode = 'data_open_fields_unknown' | 'data_open_fields_shape' | 'data_open_table_unknown';
|
|
50
|
+
export interface OpenProblem {
|
|
51
|
+
code: OpenProblemCode;
|
|
52
|
+
field?: string;
|
|
53
|
+
message: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Everything wrong with one collection's `openSchema`, given every
|
|
57
|
+
* collection's raw schema. Empty when it is right.
|
|
58
|
+
*/
|
|
59
|
+
export declare function openSchemaProblems(collection: string, flag: OpenSchemaFlag, data: Readonly<Record<string, {
|
|
60
|
+
schema: Record<string, unknown>;
|
|
61
|
+
openSchema?: OpenSchemaFlag;
|
|
62
|
+
}>>): OpenProblem[];
|
|
63
|
+
/** A definition's type as the store's own field type: what `checked` and the list path read. */
|
|
64
|
+
export declare function fieldTypeOf(field: OpenField): FieldType;
|
|
65
|
+
/** True for a type whose values a definition must list. */
|
|
66
|
+
export declare const hasChoices: (type: OpenType) => boolean;
|
|
67
|
+
/** A definition in a few words, for an answer or a tool: `Amount (deal_size): currency, USD`. */
|
|
68
|
+
export declare function describeField(field: OpenField): string;
|
|
69
|
+
/**
|
|
70
|
+
* A key made from a name: `"Deal size"` → `deal_size`, suffixed when taken.
|
|
71
|
+
* Always a field name the tools can use, never a reserved one.
|
|
72
|
+
*/
|
|
73
|
+
export declare function keyFromName(name: string, taken: ReadonlySet<string>): string;
|
|
74
|
+
/** Why a key cannot be a field's key here, or null. */
|
|
75
|
+
export declare function keyProblem(key: string, fixed: ReadonlySet<string>, taken: ReadonlySet<string>): string | null;
|
|
76
|
+
/** The choices a definition lists, checked: some, short, distinct. */
|
|
77
|
+
export declare function choicesProblem(type: OpenType, choices: readonly string[]): string | null;
|
|
78
|
+
/**
|
|
79
|
+
* What the store's own field type cannot say about an open value: a web
|
|
80
|
+
* address, an email, a rating's range, a date with no time, the choices of a
|
|
81
|
+
* multi-select. Null when it is fine; `checked` then checks the rest.
|
|
82
|
+
*/
|
|
83
|
+
export declare function openValueProblem(field: OpenField, value: unknown): string | null;
|
|
84
|
+
/** True when a create leaves a required field out. */
|
|
85
|
+
export declare const missingRequired: (field: OpenField, value: unknown) => boolean;
|
|
86
|
+
/** What happened to one value when its field changed. */
|
|
87
|
+
export type Moved = {
|
|
88
|
+
outcome: 'kept' | 'converted';
|
|
89
|
+
value: unknown;
|
|
90
|
+
} | {
|
|
91
|
+
outcome: 'cleared';
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* One value moved from a field's old definition to its new one (P5 retype):
|
|
95
|
+
* kept when it is still right, converted when it can be read as the new type,
|
|
96
|
+
* else cleared. Never invents a choice.
|
|
97
|
+
*/
|
|
98
|
+
export declare function moveValue(value: unknown, from: OpenField, to: OpenField): Moved;
|
|
99
|
+
/**
|
|
100
|
+
* What a field definition's change did to its table's rows: counts, never
|
|
101
|
+
* values. `rows` is how many held a value for the field. A definition's
|
|
102
|
+
* `update` or `delete` answer carries it as `fieldChange` when values moved.
|
|
103
|
+
*/
|
|
104
|
+
export interface OpenFieldChange {
|
|
105
|
+
field: string;
|
|
106
|
+
op: 'retype' | 'choices' | 'delete';
|
|
107
|
+
rows: number;
|
|
108
|
+
kept: number;
|
|
109
|
+
converted: number;
|
|
110
|
+
cleared: number;
|
|
111
|
+
}
|
|
112
|
+
/** One value an open field may hold, as a row hands it out. */
|
|
113
|
+
export type OpenFieldValue = string | number | boolean | string[] | null;
|
|
114
|
+
/** Open types whose values are kept in the plain column: filtered, and all but the two lists sorted. */
|
|
115
|
+
export declare const isPlainOpenType: (type: OpenType) => boolean;
|
|
116
|
+
/**
|
|
117
|
+
* One companion record as a definition, or null when it cannot be one.
|
|
118
|
+
* `table` is the field naming its table, if the collection has tables.
|
|
119
|
+
*/
|
|
120
|
+
export declare function definitionOf(id: string, body: Record<string, unknown>, table: string | null): OpenField | null;
|
|
121
|
+
/**
|
|
122
|
+
* The collection's spec with one table's live fields folded in: what a row
|
|
123
|
+
* is checked against and what a list filters and sorts on. A fixed field
|
|
124
|
+
* always wins over a definition of the same key.
|
|
125
|
+
*/
|
|
126
|
+
export declare function openSpec(base: CollectionSpec, fields: readonly OpenField[]): CollectionSpec;
|
|
127
|
+
/** Why a definition's type and choices don't go together, or null. */
|
|
128
|
+
export declare function definitionTypeProblem(body: Record<string, unknown>): string | null;
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// A copy of Brydio's `apps/api/src/apps/manifest/open-schema.ts`, kept exact so
|
|
2
|
+
// a companion the SDK accepts is one the server accepts, and a value moved by
|
|
3
|
+
// the fake host is moved as the real store moves it. Only the part after
|
|
4
|
+
// "The SDK's own" at the end is not the server's.
|
|
5
|
+
import { FIELD_LIMITS, FIELD_NAME, isSortable, isStructured, parseFieldType, quoted, RESERVED_FIELDS, } from "./field-types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Open-schema collections (P5).
|
|
8
|
+
*
|
|
9
|
+
* A collection flagged `openSchema: { fields: "columns", table?: "table" }`
|
|
10
|
+
* keeps the fields its manifest declares, and also the fields its companion
|
|
11
|
+
* collection's records define, per table. This file is the manifest half:
|
|
12
|
+
* which types a definition may name, what the companion must declare, how a
|
|
13
|
+
* definition reads as a `FieldType` the store already knows, and how a value
|
|
14
|
+
* moves when its field changes type. The store half is
|
|
15
|
+
* `apps/data/open-schema.ts`.
|
|
16
|
+
*/
|
|
17
|
+
/** The types a field definition may name. */
|
|
18
|
+
export const OPEN_TYPES = [
|
|
19
|
+
'text',
|
|
20
|
+
'long_text',
|
|
21
|
+
'number',
|
|
22
|
+
'currency',
|
|
23
|
+
'percent',
|
|
24
|
+
'rating',
|
|
25
|
+
'date',
|
|
26
|
+
'datetime',
|
|
27
|
+
'checkbox',
|
|
28
|
+
'select',
|
|
29
|
+
'multi_select',
|
|
30
|
+
'person',
|
|
31
|
+
'link',
|
|
32
|
+
'url',
|
|
33
|
+
'email',
|
|
34
|
+
'phone',
|
|
35
|
+
'attachment',
|
|
36
|
+
];
|
|
37
|
+
export const OPEN_LIMITS = {
|
|
38
|
+
/** Live rows one table may hold (DB-Q2, owner 30 Sep): measured at this size. */
|
|
39
|
+
rowsPerTable: 50_000,
|
|
40
|
+
/** Live field definitions one table may hold. */
|
|
41
|
+
fieldsPerTable: 200,
|
|
42
|
+
/** The highest a rating may be. */
|
|
43
|
+
ratingMax: 10,
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* What the companion must declare: the three it must have, and the kinds of
|
|
47
|
+
* the ones the host reads when they are there. Anything else is the app's own.
|
|
48
|
+
*/
|
|
49
|
+
const REQUIRED_COMPANION = { key: 'string', name: 'string', type: 'enum' };
|
|
50
|
+
const OPTIONAL_COMPANION = {
|
|
51
|
+
choices: ['string[]'],
|
|
52
|
+
required: ['boolean'],
|
|
53
|
+
currency: ['string'],
|
|
54
|
+
precision: ['number'],
|
|
55
|
+
linkTo: ['string'],
|
|
56
|
+
description: ['string', 'text'],
|
|
57
|
+
};
|
|
58
|
+
const kindOf = (raw) => {
|
|
59
|
+
try {
|
|
60
|
+
return parseFieldType(raw);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Its own problem is reported by the field check; nothing to add here.
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Everything wrong with one collection's `openSchema`, given every
|
|
69
|
+
* collection's raw schema. Empty when it is right.
|
|
70
|
+
*/
|
|
71
|
+
export function openSchemaProblems(collection, flag, data) {
|
|
72
|
+
const companion = data[flag.fields];
|
|
73
|
+
if (!companion || flag.fields === collection) {
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
code: 'data_open_fields_unknown',
|
|
77
|
+
message: `${collection}.openSchema.fields names ${flag.fields}, which must be another collection of this app.`,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
if (companion.openSchema) {
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
code: 'data_open_fields_unknown',
|
|
85
|
+
message: `${flag.fields} defines ${collection}'s fields, so it cannot have open fields itself.`,
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
const problems = [];
|
|
90
|
+
const shape = `${flag.fields} defines ${collection}'s fields`;
|
|
91
|
+
for (const [field, kind] of Object.entries(REQUIRED_COMPANION)) {
|
|
92
|
+
const type = field in companion.schema ? kindOf(companion.schema[field]) : null;
|
|
93
|
+
if (!type || type.kind !== kind || (field !== 'key' && type.optional)) {
|
|
94
|
+
problems.push({
|
|
95
|
+
code: 'data_open_fields_shape',
|
|
96
|
+
field,
|
|
97
|
+
message: kind === 'enum'
|
|
98
|
+
? `${shape}, so it needs type: a choice of some of ${quoted(OPEN_TYPES)}.`
|
|
99
|
+
: `${shape}, so it needs ${field}: "${kind}${field === 'key' ? '?' : ''}".`,
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (kind === 'enum') {
|
|
104
|
+
const unknown = (type.values ?? []).find(value => !OPEN_TYPES.includes(value));
|
|
105
|
+
if (unknown) {
|
|
106
|
+
problems.push({
|
|
107
|
+
code: 'data_open_fields_shape',
|
|
108
|
+
field,
|
|
109
|
+
message: `${flag.fields}.type offers "${unknown}", which is not an open field type; use some of ${quoted(OPEN_TYPES)}.`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const [field, kinds] of Object.entries(OPTIONAL_COMPANION)) {
|
|
115
|
+
if (!(field in companion.schema))
|
|
116
|
+
continue;
|
|
117
|
+
const type = kindOf(companion.schema[field]);
|
|
118
|
+
if (type && !kinds.includes(type.kind)) {
|
|
119
|
+
problems.push({
|
|
120
|
+
code: 'data_open_fields_shape',
|
|
121
|
+
field,
|
|
122
|
+
message: `${flag.fields}.${field} is read by Brydio as ${kinds.join(' or ')}; declare it so.`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (flag.table !== undefined) {
|
|
127
|
+
for (const [name, schema] of [
|
|
128
|
+
[collection, data[collection]?.schema ?? {}],
|
|
129
|
+
[flag.fields, companion.schema],
|
|
130
|
+
]) {
|
|
131
|
+
const type = flag.table in schema ? kindOf(schema[flag.table]) : null;
|
|
132
|
+
if (!type || type.kind !== 'string' || type.optional) {
|
|
133
|
+
problems.push({
|
|
134
|
+
code: 'data_open_table_unknown',
|
|
135
|
+
field: flag.table,
|
|
136
|
+
message: `${collection}.openSchema.table is ${flag.table}, so ${name} needs ${flag.table}: "string", naming each record's table.`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return problems;
|
|
142
|
+
}
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Definitions
|
|
145
|
+
/** A definition's type as the store's own field type: what `checked` and the list path read. */
|
|
146
|
+
export function fieldTypeOf(field) {
|
|
147
|
+
switch (field.type) {
|
|
148
|
+
case 'text':
|
|
149
|
+
case 'url':
|
|
150
|
+
case 'email':
|
|
151
|
+
case 'phone':
|
|
152
|
+
return { kind: 'string', optional: true };
|
|
153
|
+
case 'long_text':
|
|
154
|
+
return { kind: 'text', optional: true };
|
|
155
|
+
case 'number':
|
|
156
|
+
case 'currency':
|
|
157
|
+
case 'percent':
|
|
158
|
+
case 'rating':
|
|
159
|
+
return { kind: 'number', optional: true };
|
|
160
|
+
case 'date':
|
|
161
|
+
case 'datetime':
|
|
162
|
+
return { kind: 'date', optional: true };
|
|
163
|
+
case 'checkbox':
|
|
164
|
+
return { kind: 'boolean', optional: true };
|
|
165
|
+
case 'select':
|
|
166
|
+
return { kind: 'enum', optional: true, values: field.choices };
|
|
167
|
+
case 'multi_select':
|
|
168
|
+
return { kind: 'string[]', optional: true, plain: true, values: field.choices };
|
|
169
|
+
case 'person':
|
|
170
|
+
return { kind: 'member', optional: true };
|
|
171
|
+
case 'link':
|
|
172
|
+
return { kind: 'string[]', optional: true, plain: true };
|
|
173
|
+
case 'attachment':
|
|
174
|
+
return { kind: 'string[]', optional: true };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** True for a type whose values a definition must list. */
|
|
178
|
+
export const hasChoices = (type) => type === 'select' || type === 'multi_select';
|
|
179
|
+
/** A definition in a few words, for an answer or a tool: `Amount (deal_size): currency, USD`. */
|
|
180
|
+
export function describeField(field) {
|
|
181
|
+
const extra = hasChoices(field.type)
|
|
182
|
+
? `, one of ${quoted(field.choices)}`
|
|
183
|
+
: field.type === 'currency' && field.currency
|
|
184
|
+
? `, ${field.currency}`
|
|
185
|
+
: '';
|
|
186
|
+
return `${field.key} ("${field.name}"): ${field.type.replace('_', ' ')}${extra}${field.required ? ', required' : ''}`;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* A key made from a name: `"Deal size"` → `deal_size`, suffixed when taken.
|
|
190
|
+
* Always a field name the tools can use, never a reserved one.
|
|
191
|
+
*/
|
|
192
|
+
export function keyFromName(name, taken) {
|
|
193
|
+
const slug = name
|
|
194
|
+
.normalize('NFKD')
|
|
195
|
+
.replace(/[̀-ͯ]/g, '')
|
|
196
|
+
.toLowerCase()
|
|
197
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
198
|
+
.replace(/^_+|_+$/g, '')
|
|
199
|
+
.slice(0, FIELD_LIMITS.nameChars - 4) || 'field';
|
|
200
|
+
const base = /^[a-z]/.test(slug) ? slug : `f_${slug}`.slice(0, FIELD_LIMITS.nameChars - 4);
|
|
201
|
+
const free = (key) => !taken.has(key) && !RESERVED_FIELDS.has(key);
|
|
202
|
+
if (free(base))
|
|
203
|
+
return base;
|
|
204
|
+
for (let n = 2;; n += 1) {
|
|
205
|
+
if (free(`${base}_${n}`))
|
|
206
|
+
return `${base}_${n}`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Why a key cannot be a field's key here, or null. */
|
|
210
|
+
export function keyProblem(key, fixed, taken) {
|
|
211
|
+
if (!FIELD_NAME.test(key) || key.length > FIELD_LIMITS.nameChars) {
|
|
212
|
+
return `key "${key}" must be letters, digits and _, starting with a lower-case letter, at most ${FIELD_LIMITS.nameChars} characters.`;
|
|
213
|
+
}
|
|
214
|
+
if (RESERVED_FIELDS.has(key) || fixed.has(key))
|
|
215
|
+
return `key "${key}" is a field Brydio or the app already keeps.`;
|
|
216
|
+
if (taken.has(key))
|
|
217
|
+
return `key "${key}" is already a field of this table.`;
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
/** The choices a definition lists, checked: some, short, distinct. */
|
|
221
|
+
export function choicesProblem(type, choices) {
|
|
222
|
+
if (!hasChoices(type))
|
|
223
|
+
return null;
|
|
224
|
+
if (!choices.length)
|
|
225
|
+
return `A ${type.replace('_', ' ')} field needs at least one choice.`;
|
|
226
|
+
if (choices.length > FIELD_LIMITS.enumValues)
|
|
227
|
+
return `A field may offer at most ${FIELD_LIMITS.enumValues} choices.`;
|
|
228
|
+
if (choices.some(choice => !choice.trim() || choice.length > FIELD_LIMITS.enumValueChars)) {
|
|
229
|
+
return `Each choice is 1 to ${FIELD_LIMITS.enumValueChars} characters.`;
|
|
230
|
+
}
|
|
231
|
+
if (new Set(choices).size !== choices.length)
|
|
232
|
+
return 'A field offers the same choice twice.';
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Values
|
|
237
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
238
|
+
const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
239
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
240
|
+
const PHONE = /^[+()0-9 .\-/]{3,40}$/;
|
|
241
|
+
const isEmpty = (value) => value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0);
|
|
242
|
+
/**
|
|
243
|
+
* What the store's own field type cannot say about an open value: a web
|
|
244
|
+
* address, an email, a rating's range, a date with no time, the choices of a
|
|
245
|
+
* multi-select. Null when it is fine; `checked` then checks the rest.
|
|
246
|
+
*/
|
|
247
|
+
export function openValueProblem(field, value) {
|
|
248
|
+
if (value === null || value === undefined)
|
|
249
|
+
return null;
|
|
250
|
+
const named = `${field.key} ("${field.name}")`;
|
|
251
|
+
switch (field.type) {
|
|
252
|
+
case 'url':
|
|
253
|
+
return typeof value === 'string' && isWebAddress(value) ? null : `${named} must be a web address, starting http:// or https://.`;
|
|
254
|
+
case 'email':
|
|
255
|
+
return typeof value === 'string' && EMAIL.test(value) ? null : `${named} must be an email address.`;
|
|
256
|
+
case 'phone':
|
|
257
|
+
return typeof value === 'string' && PHONE.test(value) ? null : `${named} must be a phone number.`;
|
|
258
|
+
case 'rating':
|
|
259
|
+
return Number.isInteger(value) && value >= 0 && value <= OPEN_LIMITS.ratingMax
|
|
260
|
+
? null
|
|
261
|
+
: `${named} must be a whole number from 0 to ${OPEN_LIMITS.ratingMax}.`;
|
|
262
|
+
case 'date':
|
|
263
|
+
return typeof value === 'string' && DATE_ONLY.test(value) && !Number.isNaN(Date.parse(value))
|
|
264
|
+
? null
|
|
265
|
+
: `${named} must be a date, written YYYY-MM-DD.`;
|
|
266
|
+
case 'datetime':
|
|
267
|
+
return typeof value === 'string' && DATE_TIME.test(value) && !Number.isNaN(Date.parse(value))
|
|
268
|
+
? null
|
|
269
|
+
: `${named} must be a date and time, written YYYY-MM-DDTHH:MM:SSZ.`;
|
|
270
|
+
case 'multi_select': {
|
|
271
|
+
if (!Array.isArray(value))
|
|
272
|
+
return null;
|
|
273
|
+
const wrong = value.find(one => typeof one !== 'string' || !field.choices.includes(one));
|
|
274
|
+
if (wrong !== undefined)
|
|
275
|
+
return `${named} may hold only ${quoted(field.choices)}.`;
|
|
276
|
+
return new Set(value).size === value.length ? null : `${named} names the same choice twice.`;
|
|
277
|
+
}
|
|
278
|
+
default:
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** True when a create leaves a required field out. */
|
|
283
|
+
export const missingRequired = (field, value) => field.required && isEmpty(value);
|
|
284
|
+
function isWebAddress(value) {
|
|
285
|
+
try {
|
|
286
|
+
const url = new URL(value);
|
|
287
|
+
return (url.protocol === 'http:' || url.protocol === 'https:') && value.length <= FIELD_LIMITS.stringChars;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const textOf = (value) => {
|
|
294
|
+
if (typeof value === 'string')
|
|
295
|
+
return value;
|
|
296
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
297
|
+
return String(value);
|
|
298
|
+
if (typeof value === 'boolean')
|
|
299
|
+
return value ? 'true' : 'false';
|
|
300
|
+
if (Array.isArray(value))
|
|
301
|
+
return value.map(one => textOf(one) ?? '').filter(Boolean).join(', ');
|
|
302
|
+
return null;
|
|
303
|
+
};
|
|
304
|
+
const choiceOf = (choices, value) => choices.find(choice => choice === value) ?? choices.find(choice => choice.toLowerCase() === value.trim().toLowerCase());
|
|
305
|
+
/**
|
|
306
|
+
* One value moved from a field's old definition to its new one (P5 retype):
|
|
307
|
+
* kept when it is still right, converted when it can be read as the new type,
|
|
308
|
+
* else cleared. Never invents a choice.
|
|
309
|
+
*/
|
|
310
|
+
export function moveValue(value, from, to) {
|
|
311
|
+
const moved = convert(value, from, to);
|
|
312
|
+
if (moved === undefined || isEmpty(moved))
|
|
313
|
+
return { outcome: 'cleared' };
|
|
314
|
+
const settled = openValueProblem(to, moved) === null;
|
|
315
|
+
if (!settled)
|
|
316
|
+
return { outcome: 'cleared' };
|
|
317
|
+
return { outcome: JSON.stringify(moved) === JSON.stringify(value) ? 'kept' : 'converted', value: moved };
|
|
318
|
+
}
|
|
319
|
+
function convert(value, from, to) {
|
|
320
|
+
const text = textOf(value);
|
|
321
|
+
switch (to.type) {
|
|
322
|
+
case 'text':
|
|
323
|
+
return text === null ? undefined : text.slice(0, FIELD_LIMITS.stringChars);
|
|
324
|
+
case 'long_text':
|
|
325
|
+
return text === null ? undefined : text.slice(0, FIELD_LIMITS.textChars);
|
|
326
|
+
case 'url':
|
|
327
|
+
case 'email':
|
|
328
|
+
case 'phone':
|
|
329
|
+
return text === null ? undefined : text.trim();
|
|
330
|
+
case 'number':
|
|
331
|
+
case 'currency':
|
|
332
|
+
case 'percent':
|
|
333
|
+
case 'rating': {
|
|
334
|
+
const number = typeof value === 'number'
|
|
335
|
+
? value
|
|
336
|
+
: typeof value === 'boolean'
|
|
337
|
+
? Number(value)
|
|
338
|
+
: typeof value === 'string' && value.trim()
|
|
339
|
+
? Number(value.trim().replace(/[\s,%$€£¥]/g, ''))
|
|
340
|
+
: Number.NaN;
|
|
341
|
+
if (!Number.isFinite(number))
|
|
342
|
+
return undefined;
|
|
343
|
+
return to.type === 'rating' ? Math.round(number) : number;
|
|
344
|
+
}
|
|
345
|
+
case 'checkbox':
|
|
346
|
+
if (typeof value === 'boolean')
|
|
347
|
+
return value;
|
|
348
|
+
if (typeof value === 'number')
|
|
349
|
+
return value !== 0;
|
|
350
|
+
if (typeof value === 'string') {
|
|
351
|
+
if (/^(true|yes|y|1|x|checked|done)$/i.test(value.trim()))
|
|
352
|
+
return true;
|
|
353
|
+
if (/^(false|no|n|0|unchecked)$/i.test(value.trim()))
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
return undefined;
|
|
357
|
+
case 'date':
|
|
358
|
+
case 'datetime': {
|
|
359
|
+
if (typeof value !== 'string')
|
|
360
|
+
return undefined;
|
|
361
|
+
const head = value.trim();
|
|
362
|
+
if (to.type === 'date' && /^\d{4}-\d{2}-\d{2}/.test(head))
|
|
363
|
+
return head.slice(0, 10);
|
|
364
|
+
const at = Date.parse(DATE_ONLY.test(head) ? `${head}T00:00:00Z` : head);
|
|
365
|
+
if (Number.isNaN(at))
|
|
366
|
+
return undefined;
|
|
367
|
+
return to.type === 'date' ? new Date(at).toISOString().slice(0, 10) : new Date(at).toISOString();
|
|
368
|
+
}
|
|
369
|
+
case 'select': {
|
|
370
|
+
const candidates = Array.isArray(value) ? value : [text ?? ''];
|
|
371
|
+
for (const one of candidates) {
|
|
372
|
+
const found = typeof one === 'string' ? choiceOf(to.choices, one) : undefined;
|
|
373
|
+
if (found)
|
|
374
|
+
return found;
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
case 'multi_select': {
|
|
379
|
+
const candidates = Array.isArray(value) ? value : (text ?? '').split(',');
|
|
380
|
+
const found = candidates
|
|
381
|
+
.map(one => (typeof one === 'string' ? choiceOf(to.choices, one) : undefined))
|
|
382
|
+
.filter((one) => Boolean(one));
|
|
383
|
+
return [...new Set(found)];
|
|
384
|
+
}
|
|
385
|
+
case 'person':
|
|
386
|
+
return from.type === 'person' ? value : undefined;
|
|
387
|
+
case 'link':
|
|
388
|
+
case 'attachment':
|
|
389
|
+
return from.type === to.type ? value : undefined;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/** Open types whose values are kept in the plain column: filtered, and all but the two lists sorted. */
|
|
393
|
+
export const isPlainOpenType = (type) => !['text', 'long_text', 'url', 'email', 'phone', 'attachment'].includes(type);
|
|
394
|
+
/**
|
|
395
|
+
* One companion record as a definition, or null when it cannot be one.
|
|
396
|
+
* `table` is the field naming its table, if the collection has tables.
|
|
397
|
+
*/
|
|
398
|
+
export function definitionOf(id, body, table) {
|
|
399
|
+
const { key, name, type } = body;
|
|
400
|
+
if (typeof key !== 'string' || typeof name !== 'string' || !OPEN_TYPES.includes(type)) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
const tableValue = table ? body[table] : null;
|
|
404
|
+
if (table && typeof tableValue !== 'string')
|
|
405
|
+
return null;
|
|
406
|
+
return {
|
|
407
|
+
id,
|
|
408
|
+
key,
|
|
409
|
+
name,
|
|
410
|
+
type: type,
|
|
411
|
+
table: tableValue ?? null,
|
|
412
|
+
choices: Array.isArray(body.choices) ? body.choices.filter((one) => typeof one === 'string') : [],
|
|
413
|
+
required: body.required === true,
|
|
414
|
+
...(typeof body.currency === 'string' ? { currency: body.currency } : {}),
|
|
415
|
+
...(typeof body.precision === 'number' ? { precision: body.precision } : {}),
|
|
416
|
+
...(typeof body.linkTo === 'string' ? { linkTo: body.linkTo } : {}),
|
|
417
|
+
...(typeof body.description === 'string' ? { description: body.description } : {}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* The collection's spec with one table's live fields folded in: what a row
|
|
422
|
+
* is checked against and what a list filters and sorts on. A fixed field
|
|
423
|
+
* always wins over a definition of the same key.
|
|
424
|
+
*/
|
|
425
|
+
export function openSpec(base, fields) {
|
|
426
|
+
const added = fields.filter(field => !base.fields[field.key]);
|
|
427
|
+
const types = Object.fromEntries(added.map(field => [field.key, fieldTypeOf(field)]));
|
|
428
|
+
const keys = added.map(field => field.key);
|
|
429
|
+
return {
|
|
430
|
+
...base,
|
|
431
|
+
fields: { ...base.fields, ...types },
|
|
432
|
+
structured: [...base.structured, ...keys.filter(key => isStructured(types[key]))],
|
|
433
|
+
sortable: [...base.sortable, ...keys.filter(key => isSortable(types[key]))],
|
|
434
|
+
search: [...base.search, ...added.filter(field => field.type === 'text' || field.type === 'long_text').map(field => field.key)],
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/** Why a definition's type and choices don't go together, or null. */
|
|
438
|
+
export function definitionTypeProblem(body) {
|
|
439
|
+
const type = body.type;
|
|
440
|
+
if (typeof type !== 'string' || !OPEN_TYPES.includes(type))
|
|
441
|
+
return null;
|
|
442
|
+
const choices = Array.isArray(body.choices) ? body.choices.filter((one) => typeof one === 'string') : [];
|
|
443
|
+
return choicesProblem(type, choices);
|
|
444
|
+
}
|
package/src/schema.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { FieldTypeInvalid, type FieldType } from './field-types.js';
|
|
3
|
+
import { type OpenProblemCode } from './open-schema.js';
|
|
3
4
|
export { COLOUR_TOKENS, FIELD_LIMITS, parseFieldType, RESERVED_FIELDS, structuredFields, type FieldKind, type FieldType, } from './field-types.js';
|
|
4
5
|
/**
|
|
5
6
|
* What an app adds to `.brydio/app.json` to be more than a bundle of servers
|
|
@@ -60,6 +61,7 @@ declare const customToolSchema: z.ZodObject<{
|
|
|
60
61
|
input: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
61
62
|
write: z.ZodOptional<z.ZodBoolean>;
|
|
62
63
|
collection: z.ZodOptional<z.ZodString>;
|
|
64
|
+
public: z.ZodOptional<z.ZodBoolean>;
|
|
63
65
|
}, z.core.$strip>;
|
|
64
66
|
export type CustomToolSpec = z.infer<typeof customToolSchema>;
|
|
65
67
|
/** Most secrets one app may declare (ADR-A24). */
|
|
@@ -97,6 +99,7 @@ declare const extensionShape: {
|
|
|
97
99
|
"project-tab": "project-tab";
|
|
98
100
|
"project-sidebar": "project-sidebar";
|
|
99
101
|
"workspace-sidebar": "workspace-sidebar";
|
|
102
|
+
"public-page": "public-page";
|
|
100
103
|
}>;
|
|
101
104
|
screen: z.ZodString;
|
|
102
105
|
sizes: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
@@ -141,6 +144,12 @@ declare const extensionShape: {
|
|
|
141
144
|
schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
142
145
|
search: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
143
146
|
label: z.ZodOptional<z.ZodString>;
|
|
147
|
+
openSchema: z.ZodOptional<z.ZodObject<{
|
|
148
|
+
fields: z.ZodString;
|
|
149
|
+
table: z.ZodOptional<z.ZodString>;
|
|
150
|
+
}, z.core.$strict>>;
|
|
151
|
+
publicRead: z.ZodOptional<z.ZodBoolean>;
|
|
152
|
+
publicSubmit: z.ZodOptional<z.ZodBoolean>;
|
|
144
153
|
}, z.core.$strip>>>;
|
|
145
154
|
tools: z.ZodOptional<z.ZodObject<{
|
|
146
155
|
generated: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -151,6 +160,7 @@ declare const extensionShape: {
|
|
|
151
160
|
input: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
152
161
|
write: z.ZodOptional<z.ZodBoolean>;
|
|
153
162
|
collection: z.ZodOptional<z.ZodString>;
|
|
163
|
+
public: z.ZodOptional<z.ZodBoolean>;
|
|
154
164
|
}, z.core.$strip>>>;
|
|
155
165
|
}, z.core.$strip>>;
|
|
156
166
|
screens: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
@@ -218,7 +228,7 @@ export interface DataProblem {
|
|
|
218
228
|
field?: string;
|
|
219
229
|
message: string;
|
|
220
230
|
}
|
|
221
|
-
export type DataProblemCode = FieldTypeInvalid['code'] | 'data_too_many_collections' | 'data_too_many_fields' | 'data_collection_name_format' | 'data_field_name_format' | 'data_field_reserved' | 'data_label_format' | 'data_label_taken' | 'data_project_field_twice' | 'data_search_unknown_field' | 'data_search_not_text' | 'grant_collection_missing' | 'grant_tool_missing' | 'custom_name_taken' | 'custom_collection_unknown' | 'custom_input_invalid' | 'placement_key_taken' | 'placement_screen_unknown' | 'placement_home_sizes' | 'placement_sizes_not_home' | 'placement_widget_sizes' | 'placement_tab_retired' | 'placement_children_too_deep' | 'placement_create_tool_unknown' | 'placement_create_not_write' | 'secret_name_taken' | 'grant_secrets_missing';
|
|
231
|
+
export type DataProblemCode = FieldTypeInvalid['code'] | OpenProblemCode | 'data_too_many_collections' | 'data_too_many_fields' | 'data_collection_name_format' | 'data_field_name_format' | 'data_field_reserved' | 'data_label_format' | 'data_label_taken' | 'data_project_field_twice' | 'data_search_unknown_field' | 'data_search_not_text' | 'grant_collection_missing' | 'grant_tool_missing' | 'custom_name_taken' | 'custom_collection_unknown' | 'custom_input_invalid' | 'placement_key_taken' | 'placement_screen_unknown' | 'placement_home_sizes' | 'placement_sizes_not_home' | 'placement_widget_sizes' | 'placement_tab_retired' | 'placement_public_shape' | 'placement_public_nothing' | 'placement_children_too_deep' | 'placement_create_tool_unknown' | 'placement_create_not_write' | 'secret_name_taken' | 'grant_secrets_missing';
|
|
222
232
|
type Additions = z.infer<z.ZodObject<typeof extensionShape>>;
|
|
223
233
|
/**
|
|
224
234
|
* Everything wrong with an app's additions that a type cannot say.
|
|
@@ -244,6 +254,7 @@ export declare const manifestExtensionsSchema: z.ZodObject<{
|
|
|
244
254
|
"project-tab": "project-tab";
|
|
245
255
|
"project-sidebar": "project-sidebar";
|
|
246
256
|
"workspace-sidebar": "workspace-sidebar";
|
|
257
|
+
"public-page": "public-page";
|
|
247
258
|
}>;
|
|
248
259
|
screen: z.ZodString;
|
|
249
260
|
sizes: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
@@ -288,6 +299,12 @@ export declare const manifestExtensionsSchema: z.ZodObject<{
|
|
|
288
299
|
schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
289
300
|
search: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
290
301
|
label: z.ZodOptional<z.ZodString>;
|
|
302
|
+
openSchema: z.ZodOptional<z.ZodObject<{
|
|
303
|
+
fields: z.ZodString;
|
|
304
|
+
table: z.ZodOptional<z.ZodString>;
|
|
305
|
+
}, z.core.$strict>>;
|
|
306
|
+
publicRead: z.ZodOptional<z.ZodBoolean>;
|
|
307
|
+
publicSubmit: z.ZodOptional<z.ZodBoolean>;
|
|
291
308
|
}, z.core.$strip>>>;
|
|
292
309
|
tools: z.ZodOptional<z.ZodObject<{
|
|
293
310
|
generated: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -298,6 +315,7 @@ export declare const manifestExtensionsSchema: z.ZodObject<{
|
|
|
298
315
|
input: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
299
316
|
write: z.ZodOptional<z.ZodBoolean>;
|
|
300
317
|
collection: z.ZodOptional<z.ZodString>;
|
|
318
|
+
public: z.ZodOptional<z.ZodBoolean>;
|
|
301
319
|
}, z.core.$strip>>>;
|
|
302
320
|
}, z.core.$strip>>;
|
|
303
321
|
screens: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
@@ -361,6 +379,7 @@ export declare const storedExtensionsSchema: z.ZodObject<{
|
|
|
361
379
|
"project-tab": "project-tab";
|
|
362
380
|
"project-sidebar": "project-sidebar";
|
|
363
381
|
"workspace-sidebar": "workspace-sidebar";
|
|
382
|
+
"public-page": "public-page";
|
|
364
383
|
}>;
|
|
365
384
|
screen: z.ZodString;
|
|
366
385
|
sizes: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
@@ -405,6 +424,12 @@ export declare const storedExtensionsSchema: z.ZodObject<{
|
|
|
405
424
|
schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
406
425
|
search: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
407
426
|
label: z.ZodOptional<z.ZodString>;
|
|
427
|
+
openSchema: z.ZodOptional<z.ZodObject<{
|
|
428
|
+
fields: z.ZodString;
|
|
429
|
+
table: z.ZodOptional<z.ZodString>;
|
|
430
|
+
}, z.core.$strict>>;
|
|
431
|
+
publicRead: z.ZodOptional<z.ZodBoolean>;
|
|
432
|
+
publicSubmit: z.ZodOptional<z.ZodBoolean>;
|
|
408
433
|
}, z.core.$strip>>>;
|
|
409
434
|
tools: z.ZodOptional<z.ZodObject<{
|
|
410
435
|
generated: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -415,6 +440,7 @@ export declare const storedExtensionsSchema: z.ZodObject<{
|
|
|
415
440
|
input: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
416
441
|
write: z.ZodOptional<z.ZodBoolean>;
|
|
417
442
|
collection: z.ZodOptional<z.ZodString>;
|
|
443
|
+
public: z.ZodOptional<z.ZodBoolean>;
|
|
418
444
|
}, z.core.$strip>>>;
|
|
419
445
|
}, z.core.$strip>>;
|
|
420
446
|
screens: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
@@ -513,6 +539,7 @@ export declare const appManifestSchema: z.ZodObject<{
|
|
|
513
539
|
"project-tab": "project-tab";
|
|
514
540
|
"project-sidebar": "project-sidebar";
|
|
515
541
|
"workspace-sidebar": "workspace-sidebar";
|
|
542
|
+
"public-page": "public-page";
|
|
516
543
|
}>;
|
|
517
544
|
screen: z.ZodString;
|
|
518
545
|
sizes: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
@@ -557,6 +584,12 @@ export declare const appManifestSchema: z.ZodObject<{
|
|
|
557
584
|
schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
558
585
|
search: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
559
586
|
label: z.ZodOptional<z.ZodString>;
|
|
587
|
+
openSchema: z.ZodOptional<z.ZodObject<{
|
|
588
|
+
fields: z.ZodString;
|
|
589
|
+
table: z.ZodOptional<z.ZodString>;
|
|
590
|
+
}, z.core.$strict>>;
|
|
591
|
+
publicRead: z.ZodOptional<z.ZodBoolean>;
|
|
592
|
+
publicSubmit: z.ZodOptional<z.ZodBoolean>;
|
|
560
593
|
}, z.core.$strip>>>;
|
|
561
594
|
tools: z.ZodOptional<z.ZodObject<{
|
|
562
595
|
generated: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -567,6 +600,7 @@ export declare const appManifestSchema: z.ZodObject<{
|
|
|
567
600
|
input: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
568
601
|
write: z.ZodOptional<z.ZodBoolean>;
|
|
569
602
|
collection: z.ZodOptional<z.ZodString>;
|
|
603
|
+
public: z.ZodOptional<z.ZodBoolean>;
|
|
570
604
|
}, z.core.$strip>>>;
|
|
571
605
|
}, z.core.$strip>>;
|
|
572
606
|
screens: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
@@ -669,6 +703,7 @@ export declare const newAppManifestSchema: z.ZodObject<{
|
|
|
669
703
|
"project-tab": "project-tab";
|
|
670
704
|
"project-sidebar": "project-sidebar";
|
|
671
705
|
"workspace-sidebar": "workspace-sidebar";
|
|
706
|
+
"public-page": "public-page";
|
|
672
707
|
}>;
|
|
673
708
|
screen: z.ZodString;
|
|
674
709
|
sizes: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
@@ -713,6 +748,12 @@ export declare const newAppManifestSchema: z.ZodObject<{
|
|
|
713
748
|
schema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
714
749
|
search: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
715
750
|
label: z.ZodOptional<z.ZodString>;
|
|
751
|
+
openSchema: z.ZodOptional<z.ZodObject<{
|
|
752
|
+
fields: z.ZodString;
|
|
753
|
+
table: z.ZodOptional<z.ZodString>;
|
|
754
|
+
}, z.core.$strict>>;
|
|
755
|
+
publicRead: z.ZodOptional<z.ZodBoolean>;
|
|
756
|
+
publicSubmit: z.ZodOptional<z.ZodBoolean>;
|
|
716
757
|
}, z.core.$strip>>>;
|
|
717
758
|
tools: z.ZodOptional<z.ZodObject<{
|
|
718
759
|
generated: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -723,6 +764,7 @@ export declare const newAppManifestSchema: z.ZodObject<{
|
|
|
723
764
|
input: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
724
765
|
write: z.ZodOptional<z.ZodBoolean>;
|
|
725
766
|
collection: z.ZodOptional<z.ZodString>;
|
|
767
|
+
public: z.ZodOptional<z.ZodBoolean>;
|
|
726
768
|
}, z.core.$strip>>>;
|
|
727
769
|
}, z.core.$strip>>;
|
|
728
770
|
screens: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
@@ -791,6 +833,17 @@ export interface CollectionSpec {
|
|
|
791
833
|
search: string[];
|
|
792
834
|
/** The one `project` field, whose value is copied to `app_document.project_id`. */
|
|
793
835
|
projectField: string | null;
|
|
836
|
+
/**
|
|
837
|
+
* Fields defined at runtime (P5): the companion collection, and the field
|
|
838
|
+
* naming a row's table (null: one table per instance). The store reads the
|
|
839
|
+
* live definitions; `fields` here holds only the manifest's own.
|
|
840
|
+
*/
|
|
841
|
+
openSchema?: {
|
|
842
|
+
fields: string;
|
|
843
|
+
table: string | null;
|
|
844
|
+
};
|
|
845
|
+
/** On a companion: the open collection whose fields its records define (P5). */
|
|
846
|
+
definesFieldsOf?: string;
|
|
794
847
|
}
|
|
795
848
|
/**
|
|
796
849
|
* The singular a collection's tools are named with: the manifest's, else the
|
package/src/schema.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { MANIFEST_LIMITS, SEMVER_FORMAT, baseManifestSchema as manifestSchema } from "./base.js";
|
|
3
3
|
import { COLLECTION_NAME, FIELD_LIMITS, FIELD_NAME, FieldTypeInvalid, isSearchable, isSortable, isStructured, parseFieldType, RESERVED_FIELDS, } from "./field-types.js";
|
|
4
4
|
import { migrationsSchema } from "./migrations.js";
|
|
5
|
+
import { openSchemaProblems } from "./open-schema.js";
|
|
5
6
|
export { COLOUR_TOKENS, FIELD_LIMITS, parseFieldType, RESERVED_FIELDS, structuredFields, } from "./field-types.js";
|
|
6
7
|
/**
|
|
7
8
|
* What an app adds to `.brydio/app.json` to be more than a bundle of servers
|
|
@@ -64,8 +65,13 @@ const placementSchema = z.object({
|
|
|
64
65
|
* `project-widget`: a card on a project's page, drawn at one of `sizes`.
|
|
65
66
|
* `project-tab` is retired (30 Sep 2026): still read from a published
|
|
66
67
|
* manifest, refused in a new one (`placement_tab_retired`).
|
|
68
|
+
* `public-page`: a screen people without a Brydio account open at the
|
|
69
|
+
* page's own address, once an admin turns it on (P3). It has no `sizes`,
|
|
70
|
+
* `children` or `settings` (`placement_public_shape`), and it reaches only
|
|
71
|
+
* collections marked `publicRead` or `publicSubmit` and tools marked
|
|
72
|
+
* `public`.
|
|
67
73
|
*/
|
|
68
|
-
kind: z.enum(['project-tab', 'project-widget', 'project-sidebar', 'workspace-sidebar', 'home']),
|
|
74
|
+
kind: z.enum(['project-tab', 'project-widget', 'project-sidebar', 'workspace-sidebar', 'home', 'public-page']),
|
|
69
75
|
screen: z.string().min(1).max(FIELD_LIMITS.nameChars),
|
|
70
76
|
/** For `home` and `project-widget` only, and required there: the sizes the screen draws well at. */
|
|
71
77
|
sizes: z.array(z.enum(HOME_CARD_SIZES)).max(3).optional(),
|
|
@@ -101,6 +107,29 @@ const collectionSchema = z.object({
|
|
|
101
107
|
search: z.array(z.string()).optional(),
|
|
102
108
|
/** The singular noun the tools are named with: `issue` gives `create_issue`. */
|
|
103
109
|
label: z.string().optional(),
|
|
110
|
+
/**
|
|
111
|
+
* Fields defined at runtime as records of another collection (P5): `fields`
|
|
112
|
+
* names it, and `table`, when given, the `string` field on both naming
|
|
113
|
+
* which table a row or a definition belongs to.
|
|
114
|
+
*/
|
|
115
|
+
openSchema: z
|
|
116
|
+
.object({
|
|
117
|
+
fields: z.string().min(1).max(FIELD_LIMITS.nameChars),
|
|
118
|
+
table: z.string().min(1).max(FIELD_LIMITS.nameChars).optional(),
|
|
119
|
+
})
|
|
120
|
+
.strict()
|
|
121
|
+
.optional(),
|
|
122
|
+
/**
|
|
123
|
+
* A visitor on one of the app's public pages may `get` and `list` every
|
|
124
|
+
* record of this collection in that instance (P3).
|
|
125
|
+
*/
|
|
126
|
+
publicRead: z.boolean().optional(),
|
|
127
|
+
/**
|
|
128
|
+
* A visitor on one of the app's public pages may create records here, and
|
|
129
|
+
* nothing else: no update, remove or batch, and no get or list unless
|
|
130
|
+
* `publicRead` is set too (P3).
|
|
131
|
+
*/
|
|
132
|
+
publicSubmit: z.boolean().optional(),
|
|
104
133
|
});
|
|
105
134
|
const screenSchema = z.object({
|
|
106
135
|
/**
|
|
@@ -137,6 +166,12 @@ const customToolSchema = z.object({
|
|
|
137
166
|
write: z.boolean().optional(),
|
|
138
167
|
/** The collection it works on, when it works on one: its grant then needs that collection too. */
|
|
139
168
|
collection: z.string().optional(),
|
|
169
|
+
/**
|
|
170
|
+
* Callable from a public page (P3). Its handler then runs for a visitor,
|
|
171
|
+
* whose `data` is held to `publicRead` and `publicSubmit` and who can use
|
|
172
|
+
* nothing else of the workspace's.
|
|
173
|
+
*/
|
|
174
|
+
public: z.boolean().optional(),
|
|
140
175
|
});
|
|
141
176
|
const toolsSchema = z.object({
|
|
142
177
|
/** Off only when the app supplies every tool itself (A3-F08). */
|
|
@@ -305,6 +340,25 @@ export function dataProblems(additions, options = {}) {
|
|
|
305
340
|
}
|
|
306
341
|
}
|
|
307
342
|
}
|
|
343
|
+
// Open-schema collections (P5): the companion is declared, shaped as the
|
|
344
|
+
// host reads it, and defines one collection's fields at most.
|
|
345
|
+
const companions = new Map();
|
|
346
|
+
for (const [collection, declared] of collections) {
|
|
347
|
+
if (!declared.openSchema)
|
|
348
|
+
continue;
|
|
349
|
+
for (const problem of openSchemaProblems(collection, declared.openSchema, additions.data ?? {})) {
|
|
350
|
+
problems.push({ ...problem, collection });
|
|
351
|
+
}
|
|
352
|
+
const other = companions.get(declared.openSchema.fields);
|
|
353
|
+
if (other) {
|
|
354
|
+
problems.push({
|
|
355
|
+
code: 'data_open_fields_unknown',
|
|
356
|
+
collection,
|
|
357
|
+
message: `${declared.openSchema.fields} already defines ${other}'s fields; give ${collection} its own.`,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
companions.set(declared.openSchema.fields, collection);
|
|
361
|
+
}
|
|
308
362
|
// What the app keeps must be what it asks to keep (A3-F06-S01): a
|
|
309
363
|
// collection the grants leave out would be data a workspace never agreed to
|
|
310
364
|
// hold, found only when the first write is refused.
|
|
@@ -366,6 +420,25 @@ export function dataProblems(additions, options = {}) {
|
|
|
366
420
|
message: 'Project tabs are retired; declare a project-widget with sizes instead.',
|
|
367
421
|
});
|
|
368
422
|
}
|
|
423
|
+
// A public page is one screen at its own address: nothing to list in a
|
|
424
|
+
// sidebar, and nothing for an admin to fill in when it is placed.
|
|
425
|
+
if (placement.kind === 'public-page' && (placement.children || placement.settings)) {
|
|
426
|
+
problems.push({
|
|
427
|
+
code: 'placement_public_shape',
|
|
428
|
+
message: `The public page "${placement.screen}" can have no children or settings: it is one screen at its own address.`,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// A public page with nothing public could show a visitor nothing at all.
|
|
433
|
+
if ((additions.placements ?? []).some(placement => placement.kind === 'public-page')) {
|
|
434
|
+
const publicData = Object.values(additions.data ?? {}).some(declared => declared.publicRead || declared.publicSubmit);
|
|
435
|
+
const publicTool = (additions.tools?.custom ?? []).some(tool => tool.public);
|
|
436
|
+
if (!publicData && !publicTool) {
|
|
437
|
+
problems.push({
|
|
438
|
+
code: 'placement_public_nothing',
|
|
439
|
+
message: 'A public page needs something a visitor may use: mark a collection publicRead or publicSubmit, or a custom tool public.',
|
|
440
|
+
});
|
|
441
|
+
}
|
|
369
442
|
}
|
|
370
443
|
problems.push(...folderProblems(additions));
|
|
371
444
|
return problems;
|
|
@@ -571,8 +644,16 @@ export function labelOf(collection, label) {
|
|
|
571
644
|
*/
|
|
572
645
|
export function collectionsOf(manifest) {
|
|
573
646
|
const parsed = storedExtensionsSchema.parse({ data: manifest.data ?? {} });
|
|
647
|
+
const owners = new Map(Object.entries(parsed.data ?? {}).flatMap(([name, declared]) => declared.openSchema ? [[declared.openSchema.fields, { name, flag: declared.openSchema }]] : []));
|
|
574
648
|
return Object.entries(parsed.data ?? {}).map(([name, declared]) => {
|
|
575
|
-
const
|
|
649
|
+
const owner = owners.get(name);
|
|
650
|
+
// The field naming a row's table is kept in plain on both sides, so a
|
|
651
|
+
// table is filtered and counted without decrypting a row (P5).
|
|
652
|
+
const tableField = declared.openSchema?.table ?? owner?.flag.table;
|
|
653
|
+
const fields = Object.fromEntries(Object.entries(declared.schema).map(([field, raw]) => {
|
|
654
|
+
const type = parseFieldType(raw);
|
|
655
|
+
return [field, field === tableField ? { ...type, plain: true } : type];
|
|
656
|
+
}));
|
|
576
657
|
const label = labelOf(name, declared.label);
|
|
577
658
|
const names = Object.keys(fields);
|
|
578
659
|
return {
|
|
@@ -584,6 +665,10 @@ export function collectionsOf(manifest) {
|
|
|
584
665
|
sortable: names.filter(field => isSortable(fields[field])),
|
|
585
666
|
search: declared.search ?? [],
|
|
586
667
|
projectField: names.find(field => fields[field].kind === 'project') ?? null,
|
|
668
|
+
...(declared.openSchema
|
|
669
|
+
? { openSchema: { fields: declared.openSchema.fields, table: declared.openSchema.table ?? null } }
|
|
670
|
+
: {}),
|
|
671
|
+
...(owner ? { definesFieldsOf: owner.name } : {}),
|
|
587
672
|
};
|
|
588
673
|
});
|
|
589
674
|
}
|