@brydio/manifest 0.1.0-alpha.6 → 0.1.0-alpha.61
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/README.md +1 -1
- package/package.json +6 -2
- package/src/anonymous.d.ts +42 -0
- package/src/anonymous.js +86 -0
- package/src/base.d.ts +17 -1
- package/src/base.js +13 -1
- package/src/bundle.d.ts +22 -2
- package/src/bundle.js +93 -11
- package/src/confirm-email.d.ts +40 -0
- package/src/confirm-email.js +78 -0
- package/src/define.d.ts +1 -1
- package/src/define.js +1 -1
- package/src/field-types.d.ts +41 -5
- package/src/field-types.js +60 -10
- package/src/frame-checks.d.ts +13 -0
- package/src/frame-checks.js +35 -0
- package/src/grants.d.ts +14 -2
- package/src/grants.js +34 -2
- package/src/index.d.ts +13 -5
- package/src/index.js +15 -5
- package/src/look.d.ts +196 -0
- package/src/look.js +744 -0
- package/src/migrations.d.ts +1 -1
- package/src/migrations.js +29 -0
- package/src/open-schema.d.ts +128 -0
- package/src/open-schema.js +444 -0
- package/src/readers.d.ts +54 -0
- package/src/readers.js +96 -0
- package/src/requirements.d.ts +122 -0
- package/src/requirements.js +692 -0
- package/src/schema.d.ts +576 -9
- package/src/schema.js +484 -31
- package/src/screen-kinds.d.ts +18 -0
- package/src/screen-kinds.js +30 -0
- package/src/secrets.d.ts +14 -0
- package/src/secrets.js +38 -5
- package/src/validate.d.ts +12 -3
- package/src/validate.js +7 -5
package/src/migrations.d.ts
CHANGED
|
@@ -115,7 +115,7 @@ export type SchemaChange = {
|
|
|
115
115
|
* Hodler's `diffManifests` to show, and none of them moves a record.
|
|
116
116
|
*/
|
|
117
117
|
export declare function diffSchemas(from: unknown, to: unknown): SchemaChange[];
|
|
118
|
-
export type MigrationCode = 'migration_step_invalid' | 'migration_step_pointless' | 'migration_default_missing' | 'migration_default_invalid' | 'migration_unexplained' | 'migration_type_changed' | 'migration_value_removed';
|
|
118
|
+
export type MigrationCode = 'migration_step_invalid' | 'migration_step_pointless' | 'migration_default_missing' | 'migration_default_invalid' | 'migration_unexplained' | 'migration_type_changed' | 'migration_value_removed' | 'migration_anonymous_changed';
|
|
119
119
|
export interface MigrationProblem {
|
|
120
120
|
code: MigrationCode;
|
|
121
121
|
collection: string;
|
package/src/migrations.js
CHANGED
|
@@ -181,6 +181,35 @@ export function migrationProblems(from, to, steps) {
|
|
|
181
181
|
break;
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
|
+
problems.push(...anonymityChanges(from, to));
|
|
185
|
+
return problems;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* A kept collection that becomes anonymous, stops being, or moves its group
|
|
189
|
+
* (P13): no step can say what its records become. Records kept before kept
|
|
190
|
+
* their writers and times, and records kept since were read only by group.
|
|
191
|
+
*/
|
|
192
|
+
function anonymityChanges(from, to) {
|
|
193
|
+
const before = record(record(from).data);
|
|
194
|
+
const after = record(record(to).data);
|
|
195
|
+
const problems = [];
|
|
196
|
+
for (const [name, declared] of Object.entries(after)) {
|
|
197
|
+
if (!(name in before))
|
|
198
|
+
continue;
|
|
199
|
+
const was = record(record(before[name]).anonymous).group;
|
|
200
|
+
const now = record(record(declared).anonymous).group;
|
|
201
|
+
if (was === now)
|
|
202
|
+
continue;
|
|
203
|
+
problems.push({
|
|
204
|
+
code: 'migration_anonymous_changed',
|
|
205
|
+
collection: name,
|
|
206
|
+
message: was === undefined
|
|
207
|
+
? `${name} already keeps records with their writers, so it can't become anonymous; keep anonymous answers in a new collection.`
|
|
208
|
+
: now === undefined
|
|
209
|
+
? `${name} keeps anonymous answers, so it stays anonymous.`
|
|
210
|
+
: `${name}'s anonymous answers stay grouped by ${String(was)}.`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
184
213
|
return problems;
|
|
185
214
|
}
|
|
186
215
|
/** The old schema with the steps applied, and every step that could not apply or changed nothing. */
|
|
@@ -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/readers.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Record readers (P16): a record only the people it
|
|
4
|
+
* names may read.
|
|
5
|
+
*
|
|
6
|
+
* An app names one of the collection's own `string[]` fields, which holds
|
|
7
|
+
* user ids. When a record's list is empty, everyone who can read the
|
|
8
|
+
* instance reads it, as before; when it names anyone, nobody else finds it —
|
|
9
|
+
* not on a screen, not through a tool, not through the assistant, not on the
|
|
10
|
+
* co-edit socket. There is no admin past it: an app that wants its admins
|
|
11
|
+
* in puts them in the list. Brydio keeps the list in plain beside the record
|
|
12
|
+
* (`app_document.readers`), so every read can be narrowed before anything
|
|
13
|
+
* is decrypted. Mirrors Brydio's `apps/api/src/apps/manifest/readers.ts`;
|
|
14
|
+
* the two change together.
|
|
15
|
+
*/
|
|
16
|
+
export declare const readersSchema: z.ZodString;
|
|
17
|
+
export type ReadersProblemCode = 'data_readers_field' | 'data_readers_anonymous';
|
|
18
|
+
/** The most people one record's list may name. */
|
|
19
|
+
export declare const READERS_LIMIT = 1000;
|
|
20
|
+
export declare function readersProblems(collection: string, declared: {
|
|
21
|
+
schema: Record<string, unknown>;
|
|
22
|
+
readers?: string;
|
|
23
|
+
anonymous?: unknown;
|
|
24
|
+
}): {
|
|
25
|
+
code: ReadersProblemCode;
|
|
26
|
+
collection: string;
|
|
27
|
+
field?: string;
|
|
28
|
+
message: string;
|
|
29
|
+
}[];
|
|
30
|
+
/** The plain list a record is kept with: its readers, or null for everyone who can read the instance. */
|
|
31
|
+
export declare function readersOf(field: string | undefined, body: Record<string, unknown>): string[] | null;
|
|
32
|
+
/**
|
|
33
|
+
* Record editors (DW06): a record only the people it names may change. The
|
|
34
|
+
* same shape as readers: one of the collection's own `string[]` fields of
|
|
35
|
+
* user ids, kept in plain beside the record (`app_document.editors`). Empty,
|
|
36
|
+
* and anyone who may write the instance may change it; naming anyone, and
|
|
37
|
+
* only they may — through a tool, a handler, the assistant or the co-edit
|
|
38
|
+
* socket, where anyone else's connection is read-only. Everyone who may read
|
|
39
|
+
* the record still reads it.
|
|
40
|
+
*/
|
|
41
|
+
export declare const editorsSchema: z.ZodString;
|
|
42
|
+
export type EditorsProblemCode = 'data_editors_field' | 'data_editors_anonymous';
|
|
43
|
+
export declare function editorsProblems(collection: string, declared: {
|
|
44
|
+
schema: Record<string, unknown>;
|
|
45
|
+
editors?: string;
|
|
46
|
+
anonymous?: unknown;
|
|
47
|
+
}): {
|
|
48
|
+
code: EditorsProblemCode;
|
|
49
|
+
collection: string;
|
|
50
|
+
field?: string;
|
|
51
|
+
message: string;
|
|
52
|
+
}[];
|
|
53
|
+
/** The plain list a record is kept with: its editors, or null for anyone who may write the instance. */
|
|
54
|
+
export declare const editorsOf: typeof readersOf;
|