@crombolo/schema 0.0.0-stage → 0.9.1
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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +32 -4
- package/src/builtin.ts +215 -0
- package/src/check.ts +502 -0
- package/src/export.ts +419 -0
- package/src/forms.ts +328 -0
- package/src/generate.ts +149 -0
- package/src/index.ts +19 -0
- package/src/load.ts +137 -0
- package/src/model.ts +156 -0
- package/src/parse-forms.ts +229 -0
- package/src/parse.ts +260 -0
- package/src/reader.ts +99 -0
- package/src/scaffold.ts +172 -0
- package/src/sync.ts +615 -0
- package/src/upgrade.ts +296 -0
- package/src/validate-forms.ts +346 -0
- package/src/validate.ts +242 -0
- package/src/value-migrations.ts +122 -0
- package/src/version.ts +53 -0
- package/src/write-forms.ts +139 -0
- package/src/write.ts +176 -0
- package/README.md +0 -3
package/src/upgrade.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `check --fix` and `upgrade`. The fix writes early, under a prepared state no
|
|
3
|
+
* live node reads as-of; the upgrade writes no content at all — it re-checks,
|
|
4
|
+
* runs the framework's steps, and cuts over. docs/10, Part 2 and Part 3.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
ContentTypeRepository,
|
|
8
|
+
compareStates,
|
|
9
|
+
cromboloPlan,
|
|
10
|
+
currentSchemaState,
|
|
11
|
+
type Db,
|
|
12
|
+
DocumentRepository,
|
|
13
|
+
type Finding,
|
|
14
|
+
Locks,
|
|
15
|
+
latestPreparedState,
|
|
16
|
+
type MigratedValue,
|
|
17
|
+
migrate,
|
|
18
|
+
readLedger,
|
|
19
|
+
recordInLedger,
|
|
20
|
+
writeReport,
|
|
21
|
+
} from '@crombolo/data'
|
|
22
|
+
import {
|
|
23
|
+
affectedProperties,
|
|
24
|
+
type CheckOptions,
|
|
25
|
+
type CheckReport,
|
|
26
|
+
conversionLedgerName,
|
|
27
|
+
runCheck,
|
|
28
|
+
typeNodeIdsCarrying,
|
|
29
|
+
} from './check.ts'
|
|
30
|
+
import type { LoadedSchema } from './load.ts'
|
|
31
|
+
import { allProperties } from './model.ts'
|
|
32
|
+
import { type SyncNode, type SyncReport, syncSchema } from './sync.ts'
|
|
33
|
+
import { findConverter, outputsOf } from './value-migrations.ts'
|
|
34
|
+
|
|
35
|
+
export interface FixOptions extends CheckOptions {
|
|
36
|
+
/** Recorded in the ledger. */
|
|
37
|
+
by: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FixReport {
|
|
41
|
+
sync: SyncReport
|
|
42
|
+
stateId: number | undefined
|
|
43
|
+
/** What was written: 'default:type.prop', 'set:type.prop', a migration id, a conversion name. */
|
|
44
|
+
applied: string[]
|
|
45
|
+
/** Values a converter or migration threw on; they stay findings. */
|
|
46
|
+
skipped: number
|
|
47
|
+
check: CheckReport
|
|
48
|
+
runId: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Applies everything additive and every conversion now, under a prepared state. */
|
|
52
|
+
export async function runFix(
|
|
53
|
+
db: Db,
|
|
54
|
+
loaded: LoadedSchema,
|
|
55
|
+
node: SyncNode,
|
|
56
|
+
options: FixOptions,
|
|
57
|
+
): Promise<FixReport> {
|
|
58
|
+
// Expand steps first: the site part of the check needs the framework's tables.
|
|
59
|
+
await migrate(db, cromboloPlan, { by: options.by })
|
|
60
|
+
const before = await runCheck(db, loaded, node, options)
|
|
61
|
+
const blocking = before.findings.filter((f) => f.kind === 'blocking')
|
|
62
|
+
if (blocking.length > 0) {
|
|
63
|
+
const runId = await writeReport(db, before.findings, { source: 'upgrade' })
|
|
64
|
+
return { sync: before.sync, stateId: undefined, applied: [], skipped: 0, check: before, runId }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sync = await syncSchema(db, loaded, node, { ...options, status: 'prepared' })
|
|
68
|
+
const stateId = sync.state?.id
|
|
69
|
+
const applied: string[] = []
|
|
70
|
+
let skipped = 0
|
|
71
|
+
if (sync.action === 'refused' || stateId === undefined) {
|
|
72
|
+
const runId = await writeReport(db, before.findings, { source: 'upgrade' })
|
|
73
|
+
return { sync, stateId, applied, skipped, check: before, runId }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const docs = new DocumentRepository(db)
|
|
77
|
+
const types = new ContentTypeRepository(db)
|
|
78
|
+
const ledgered = new Set(
|
|
79
|
+
(await readLedger(db)).filter((r) => r.kind === 'value').map((r) => r.name),
|
|
80
|
+
)
|
|
81
|
+
const set = options.set ?? {}
|
|
82
|
+
|
|
83
|
+
for (const affected of await affectedProperties(db, loaded)) {
|
|
84
|
+
const { type: t, property: p, typeAggregate } = affected
|
|
85
|
+
const carriers = await typeNodeIdsCarrying(db, typeAggregate.key)
|
|
86
|
+
const documents = await docs.documentsOfTypes(carriers)
|
|
87
|
+
|
|
88
|
+
// ---- defaults and --set fill what is missing
|
|
89
|
+
const fill = set[`${t.alias}.${p.alias}`] ?? p.default
|
|
90
|
+
if (affected.newlyMandatory && fill !== undefined) {
|
|
91
|
+
let filled = 0
|
|
92
|
+
for (const doc of documents) {
|
|
93
|
+
const values = await docs.currentValuesByAlias(doc.nodeId, [p.alias])
|
|
94
|
+
const has = values.some((v) => v.value !== null && v.value !== undefined && v.value !== '')
|
|
95
|
+
if (has) continue
|
|
96
|
+
await docs.appendUnderState(
|
|
97
|
+
doc.key,
|
|
98
|
+
[{ alias: p.alias, culture: null, segment: null, value: fill }],
|
|
99
|
+
{
|
|
100
|
+
schemaStateId: stateId,
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
filled += 1
|
|
104
|
+
}
|
|
105
|
+
if (filled > 0)
|
|
106
|
+
applied.push(
|
|
107
|
+
`${set[`${t.alias}.${p.alias}`] !== undefined ? 'set' : 'default'}:${t.alias}.${p.alias}`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- editor conversions write the new format into the new storage column
|
|
112
|
+
if (affected.editorChange) {
|
|
113
|
+
const { from, to, toStorage } = affected.editorChange
|
|
114
|
+
const name = conversionLedgerName(t.alias, p.alias, from, to)
|
|
115
|
+
const converter = findConverter(from, to)
|
|
116
|
+
if (!converter) continue
|
|
117
|
+
const started = performance.now()
|
|
118
|
+
let converted = 0
|
|
119
|
+
for (const doc of documents) {
|
|
120
|
+
for (const v of await docs.currentValuesByAlias(doc.nodeId, [p.alias])) {
|
|
121
|
+
if (v.value === null || v.value === undefined) continue
|
|
122
|
+
if (ledgered.has(name) && v.schemaStateId >= stateId) continue
|
|
123
|
+
try {
|
|
124
|
+
const value = converter.convert(v.value, {
|
|
125
|
+
documentKey: doc.key,
|
|
126
|
+
culture: v.culture,
|
|
127
|
+
segment: v.segment,
|
|
128
|
+
})
|
|
129
|
+
await docs.appendUnderState(
|
|
130
|
+
doc.key,
|
|
131
|
+
[
|
|
132
|
+
{
|
|
133
|
+
alias: p.alias,
|
|
134
|
+
culture: v.culture,
|
|
135
|
+
segment: v.segment,
|
|
136
|
+
value,
|
|
137
|
+
eventId: v.eventId,
|
|
138
|
+
storage: toStorage,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
{ schemaStateId: stateId },
|
|
142
|
+
)
|
|
143
|
+
converted += 1
|
|
144
|
+
} catch {
|
|
145
|
+
skipped += 1
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!ledgered.has(name)) {
|
|
150
|
+
await recordInLedger(db, {
|
|
151
|
+
name,
|
|
152
|
+
kind: 'value',
|
|
153
|
+
durationMs: Math.round(performance.now() - started),
|
|
154
|
+
appliedBy: options.by,
|
|
155
|
+
note: `${converted} value(s)`,
|
|
156
|
+
})
|
|
157
|
+
ledgered.add(name)
|
|
158
|
+
}
|
|
159
|
+
if (converted > 0) applied.push(name)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- the site's value migrations
|
|
164
|
+
for (const m of options.migrations ?? []) {
|
|
165
|
+
const fileType = loaded.set.contentTypes.find((t) => t.alias === m.from.type)
|
|
166
|
+
const existing = await types.byAlias(m.from.type, { includeRetired: true })
|
|
167
|
+
if (!fileType || !existing) continue
|
|
168
|
+
if (!m.to.every((x) => allProperties(fileType).some((p) => p.alias === x.property))) continue
|
|
169
|
+
const started = performance.now()
|
|
170
|
+
let converted = 0
|
|
171
|
+
for (const doc of await docs.documentsOfTypes(await typeNodeIdsCarrying(db, existing.key))) {
|
|
172
|
+
for (const v of await docs.currentValuesByAlias(doc.nodeId, [m.from.property])) {
|
|
173
|
+
if (v.value === null || v.value === undefined) continue
|
|
174
|
+
// Already moved once and untouched since: the targets are current.
|
|
175
|
+
if (ledgered.has(m.id)) {
|
|
176
|
+
const targets = await docs.currentValuesByAlias(
|
|
177
|
+
doc.nodeId,
|
|
178
|
+
m.to.map((x) => x.property),
|
|
179
|
+
)
|
|
180
|
+
if (targets.some((tv) => tv.schemaStateId >= stateId && tv.rowId > v.rowId)) continue
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const outputs = outputsOf(
|
|
184
|
+
m,
|
|
185
|
+
m.convert(v.value, { documentKey: doc.key, culture: v.culture, segment: v.segment }),
|
|
186
|
+
)
|
|
187
|
+
const values: MigratedValue[] = [...outputs].map(([alias, value]) => ({
|
|
188
|
+
alias,
|
|
189
|
+
culture: v.culture,
|
|
190
|
+
segment: v.segment,
|
|
191
|
+
value,
|
|
192
|
+
eventId: v.eventId,
|
|
193
|
+
}))
|
|
194
|
+
await docs.appendUnderState(doc.key, values, { schemaStateId: stateId })
|
|
195
|
+
converted += 1
|
|
196
|
+
} catch {
|
|
197
|
+
skipped += 1
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!ledgered.has(m.id)) {
|
|
202
|
+
await recordInLedger(db, {
|
|
203
|
+
name: m.id,
|
|
204
|
+
kind: 'value',
|
|
205
|
+
durationMs: Math.round(performance.now() - started),
|
|
206
|
+
appliedBy: options.by,
|
|
207
|
+
note: `${converted} value(s)`,
|
|
208
|
+
})
|
|
209
|
+
ledgered.add(m.id)
|
|
210
|
+
}
|
|
211
|
+
if (converted > 0) applied.push(m.id)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const check = await runCheck(db, loaded, node, options)
|
|
215
|
+
const runId = await writeReport(db, check.findings, { source: 'upgrade' })
|
|
216
|
+
return { sync, stateId, applied, skipped, check, runId }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export type UpgradePolicy = 'strict' | 'pending-allowed'
|
|
220
|
+
|
|
221
|
+
export interface UpgradeOptions extends CheckOptions {
|
|
222
|
+
by: string
|
|
223
|
+
policy: UpgradePolicy
|
|
224
|
+
/** Proceed over findings that need a person; recorded with this reason. */
|
|
225
|
+
force?: string
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface UpgradeReport {
|
|
229
|
+
action: 'upgraded' | 'refused' | 'nothing'
|
|
230
|
+
/** Why it refused. */
|
|
231
|
+
reasons: Finding[]
|
|
232
|
+
check: CheckReport
|
|
233
|
+
frameworkApplied: string[]
|
|
234
|
+
sync?: SyncReport
|
|
235
|
+
runId: string
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Refuses over anything outstanding, then: framework steps, the cut-over
|
|
240
|
+
* (or a plain current sync when nothing was prepared early), the ledger.
|
|
241
|
+
* Holds the content-tree lock so no write straddles the cut-over.
|
|
242
|
+
*/
|
|
243
|
+
export async function runUpgrade(
|
|
244
|
+
db: Db,
|
|
245
|
+
loaded: LoadedSchema,
|
|
246
|
+
node: SyncNode,
|
|
247
|
+
options: UpgradeOptions,
|
|
248
|
+
): Promise<UpgradeReport> {
|
|
249
|
+
return db.locks.withLock(Locks.ContentTree, async () => {
|
|
250
|
+
// The framework's steps are expand-only and ledgered, so they run before the
|
|
251
|
+
// check that may still refuse the site's change.
|
|
252
|
+
const framework = await migrate(db, cromboloPlan, { by: options.by })
|
|
253
|
+
const check = await runCheck(db, loaded, node, options)
|
|
254
|
+
const reasons = check.outstanding.filter((f) => {
|
|
255
|
+
if (f.kind === 'blocking') return true
|
|
256
|
+
if (f.kind === 'auto') return true
|
|
257
|
+
return options.policy === 'strict' && !options.force
|
|
258
|
+
})
|
|
259
|
+
if (reasons.length > 0) {
|
|
260
|
+
const runId = await writeReport(db, check.findings, { source: 'upgrade' })
|
|
261
|
+
return { action: 'refused', reasons, check, frameworkApplied: framework.applied, runId }
|
|
262
|
+
}
|
|
263
|
+
const current = await currentSchemaState(db)
|
|
264
|
+
const prepared = await latestPreparedState(db)
|
|
265
|
+
const cutOverPending =
|
|
266
|
+
prepared !== undefined && prepared.id > current.id && compareStates(prepared, node) === 0
|
|
267
|
+
const nothingToDo =
|
|
268
|
+
framework.applied.length === 0 &&
|
|
269
|
+
!cutOverPending &&
|
|
270
|
+
(check.sync.action === 'skipped-same' || check.sync.action === 'skipped-older')
|
|
271
|
+
if (nothingToDo) {
|
|
272
|
+
const runId = await writeReport(db, check.findings, { source: 'upgrade' })
|
|
273
|
+
return { action: 'nothing', reasons: [], check, frameworkApplied: [], runId }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const started = performance.now()
|
|
277
|
+
const sync = await syncSchema(db, loaded, node, { ...options, status: 'current' })
|
|
278
|
+
await recordInLedger(db, {
|
|
279
|
+
name: `upgrade ${loaded.set.version}+${node.revision}`,
|
|
280
|
+
kind: 'upgrade',
|
|
281
|
+
durationMs: Math.round(performance.now() - started),
|
|
282
|
+
appliedBy: options.by,
|
|
283
|
+
note: options.force ? `forced: ${options.force}` : sync.action,
|
|
284
|
+
})
|
|
285
|
+
const after = await runCheck(db, loaded, node, options)
|
|
286
|
+
const runId = await writeReport(db, after.findings, { source: 'upgrade' })
|
|
287
|
+
return {
|
|
288
|
+
action: 'upgraded',
|
|
289
|
+
reasons: [],
|
|
290
|
+
check: after,
|
|
291
|
+
frameworkApplied: framework.applied,
|
|
292
|
+
sync,
|
|
293
|
+
runId,
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whole-set validation for forms: everything that cannot be checked while
|
|
3
|
+
* parsing one file, plus the per-type settings rules.
|
|
4
|
+
*
|
|
5
|
+
* Every problem is reported rather than the first, because someone fixing a
|
|
6
|
+
* form wants the whole list. Separate from `validate.ts` so the form rules can
|
|
7
|
+
* be read on their own.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
allFormFields,
|
|
11
|
+
type FormCondition,
|
|
12
|
+
formFieldType,
|
|
13
|
+
type SchemaForm,
|
|
14
|
+
type SchemaFormField,
|
|
15
|
+
VALUELESS_OPERATORS,
|
|
16
|
+
} from '@crombolo/core'
|
|
17
|
+
import type { SchemaProblem } from './model.ts'
|
|
18
|
+
|
|
19
|
+
const VERSION = /^\d+(\.\d+)*$/
|
|
20
|
+
const ALIAS = /^[a-zA-Z][a-zA-Z0-9]*$/
|
|
21
|
+
|
|
22
|
+
/** Which model field a per-type setting lives on, for the "does not apply" check. */
|
|
23
|
+
const SETTING_OF: Record<string, keyof SchemaFormField> = {
|
|
24
|
+
placeholder: 'placeholder',
|
|
25
|
+
maxlength: 'maxlength',
|
|
26
|
+
rows: 'rows',
|
|
27
|
+
min: 'min',
|
|
28
|
+
max: 'max',
|
|
29
|
+
step: 'step',
|
|
30
|
+
accept: 'accept',
|
|
31
|
+
'max-size-kb': 'maxSizeKb',
|
|
32
|
+
content: 'content',
|
|
33
|
+
'default-value': 'defaultValue',
|
|
34
|
+
multiple: 'multiple',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const set = (field: SchemaFormField, model: keyof SchemaFormField): boolean => {
|
|
38
|
+
const value = field[model]
|
|
39
|
+
if (value === undefined) return false
|
|
40
|
+
return !(Array.isArray(value) && value.length === 0)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ValidateFormsOptions {
|
|
44
|
+
/** Theme directories that exist under `components/Forms/`; omit to skip the check. */
|
|
45
|
+
formThemes?: ReadonlySet<string>
|
|
46
|
+
/** Content type aliases, so `saveAsContent` can be held to a real one. */
|
|
47
|
+
contentTypeAliases?: ReadonlySet<string>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function validateForms(
|
|
51
|
+
forms: readonly SchemaForm[],
|
|
52
|
+
options: ValidateFormsOptions = {},
|
|
53
|
+
): SchemaProblem[] {
|
|
54
|
+
const problems: SchemaProblem[] = []
|
|
55
|
+
const add = (file: string, path: string, message: string) =>
|
|
56
|
+
problems.push({ file, path, message })
|
|
57
|
+
|
|
58
|
+
const aliases = new Set<string>()
|
|
59
|
+
const keys = new Map<string, string>()
|
|
60
|
+
|
|
61
|
+
for (const form of forms) {
|
|
62
|
+
const file = `schema/forms/${form.alias}`
|
|
63
|
+
|
|
64
|
+
if (!ALIAS.test(form.alias))
|
|
65
|
+
add(file, 'form.alias', `"${form.alias}" must start with a letter and be alphanumeric`)
|
|
66
|
+
if (aliases.has(form.alias.toLowerCase()))
|
|
67
|
+
add(file, 'form.alias', `duplicate form alias "${form.alias}"`)
|
|
68
|
+
aliases.add(form.alias.toLowerCase())
|
|
69
|
+
|
|
70
|
+
if (form.key) {
|
|
71
|
+
const seen = keys.get(form.key.toLowerCase())
|
|
72
|
+
if (seen) add(file, 'form.key', `key "${form.key}" is already used by "${seen}"`)
|
|
73
|
+
keys.set(form.key.toLowerCase(), form.alias)
|
|
74
|
+
}
|
|
75
|
+
if (form.since && !VERSION.test(form.since))
|
|
76
|
+
add(file, 'form.since', `"${form.since}" is not a dotted number`)
|
|
77
|
+
|
|
78
|
+
if (form.pages.length === 0) add(file, 'page', 'a form needs at least one page')
|
|
79
|
+
if (options.formThemes && form.theme && !options.formThemes.has(form.theme))
|
|
80
|
+
add(file, 'form.theme', `no theme directory "components/Forms/${form.theme}"`)
|
|
81
|
+
if (form.maxEntries !== undefined && form.maxEntries < 1)
|
|
82
|
+
add(file, 'form.max-entries', 'must be at least 1')
|
|
83
|
+
if (form.minimumSubmitSeconds !== undefined && form.minimumSubmitSeconds < 0)
|
|
84
|
+
add(file, 'form.minimum-submit-seconds', 'cannot be negative')
|
|
85
|
+
// A form that stores nothing and does nothing has no effect at all, and is
|
|
86
|
+
// much more likely to be a mistake than an intention.
|
|
87
|
+
if (!form.storeEntries && form.workflows.length === 0)
|
|
88
|
+
add(
|
|
89
|
+
file,
|
|
90
|
+
'form.store-entries',
|
|
91
|
+
'a form that stores no entries needs at least one workflow, or a submission does nothing',
|
|
92
|
+
)
|
|
93
|
+
// Approval gates the entry, so there is nothing to approve without one.
|
|
94
|
+
if (form.requiresApproval && !form.storeEntries)
|
|
95
|
+
add(
|
|
96
|
+
file,
|
|
97
|
+
'form.requires-approval',
|
|
98
|
+
'requires store-entries, since there is no entry to approve',
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
validateFields(form, file, add)
|
|
102
|
+
validateWorkflows(form, file, options, add)
|
|
103
|
+
}
|
|
104
|
+
return problems
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type Add = (file: string, path: string, message: string) => void
|
|
108
|
+
|
|
109
|
+
function validateFields(form: SchemaForm, file: string, add: Add): void {
|
|
110
|
+
const fields = allFormFields(form)
|
|
111
|
+
const seen = new Set<string>()
|
|
112
|
+
/** Aliases usable by a condition: a field may only look at earlier ones. */
|
|
113
|
+
const earlier = new Set<string>()
|
|
114
|
+
|
|
115
|
+
let index = 0
|
|
116
|
+
for (const page of form.pages) {
|
|
117
|
+
if (page.groups.length === 0)
|
|
118
|
+
add(file, `page[${form.pages.indexOf(page)}].group`, 'a page needs at least one group')
|
|
119
|
+
for (const group of page.groups) {
|
|
120
|
+
if (group.columns < 1 || group.columns > 4 || !Number.isInteger(group.columns))
|
|
121
|
+
add(file, 'page.group.columns', `${group.columns} is not a column count between 1 and 4`)
|
|
122
|
+
for (const field of group.fields) {
|
|
123
|
+
const path = `page.group.field[${index}]`
|
|
124
|
+
const info = formFieldType(field.type)
|
|
125
|
+
|
|
126
|
+
if (!ALIAS.test(field.alias))
|
|
127
|
+
add(
|
|
128
|
+
file,
|
|
129
|
+
`${path}.alias`,
|
|
130
|
+
`"${field.alias}" must start with a letter and be alphanumeric`,
|
|
131
|
+
)
|
|
132
|
+
if (seen.has(field.alias.toLowerCase()))
|
|
133
|
+
add(file, `${path}.alias`, `duplicate field alias "${field.alias}"`)
|
|
134
|
+
seen.add(field.alias.toLowerCase())
|
|
135
|
+
|
|
136
|
+
if (info) {
|
|
137
|
+
if (info.presentational) {
|
|
138
|
+
if (field.mandatory)
|
|
139
|
+
add(
|
|
140
|
+
file,
|
|
141
|
+
`${path}.mandatory`,
|
|
142
|
+
`a ${field.type} field collects nothing, so it cannot be mandatory`,
|
|
143
|
+
)
|
|
144
|
+
if (field.sensitive)
|
|
145
|
+
add(
|
|
146
|
+
file,
|
|
147
|
+
`${path}.sensitive`,
|
|
148
|
+
`a ${field.type} field collects nothing, so it cannot be sensitive`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
if (info.choices && field.values.length === 0)
|
|
152
|
+
add(file, `${path}.values`, `a ${field.type} field needs values to choose from`)
|
|
153
|
+
if (!info.choices && field.values.length > 0)
|
|
154
|
+
add(file, `${path}.values`, `a ${field.type} field has nothing to choose from`)
|
|
155
|
+
// A default has to be one of the choices, or the form opens invalid.
|
|
156
|
+
if (
|
|
157
|
+
info.choices &&
|
|
158
|
+
field.defaultValue !== undefined &&
|
|
159
|
+
!field.values.includes(field.defaultValue)
|
|
160
|
+
)
|
|
161
|
+
add(file, `${path}.default-value`, `"${field.defaultValue}" is not one of the values`)
|
|
162
|
+
|
|
163
|
+
for (const [setting, model] of Object.entries(SETTING_OF)) {
|
|
164
|
+
if (!set(field, model)) continue
|
|
165
|
+
if (!info.settings.includes(setting as never))
|
|
166
|
+
add(file, `${path}.${setting}`, `does not apply to a ${field.type} field`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (field.pattern !== undefined) {
|
|
171
|
+
try {
|
|
172
|
+
new RegExp(field.pattern)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
add(
|
|
175
|
+
file,
|
|
176
|
+
`${path}.pattern`,
|
|
177
|
+
`is not a valid regular expression: ${(error as Error).message}`,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (field.min !== undefined && field.max !== undefined && field.min > field.max)
|
|
182
|
+
add(file, `${path}.min`, `${field.min} is greater than max ${field.max}`)
|
|
183
|
+
if (field.maxSizeKb !== undefined && field.maxSizeKb < 1)
|
|
184
|
+
add(file, `${path}.max-size-kb`, 'must be at least 1')
|
|
185
|
+
|
|
186
|
+
if (field.condition)
|
|
187
|
+
validateCondition(field.condition, fields, earlier, file, `${path}.condition`, add)
|
|
188
|
+
earlier.add(field.alias.toLowerCase())
|
|
189
|
+
index++
|
|
190
|
+
}
|
|
191
|
+
if (group.condition)
|
|
192
|
+
validateCondition(group.condition, fields, earlier, file, 'page.group.condition', add)
|
|
193
|
+
}
|
|
194
|
+
if (page.condition)
|
|
195
|
+
validateCondition(page.condition, fields, earlier, file, 'page.condition', add)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (fields.length === 0) add(file, 'page.group.field', 'a form needs at least one field')
|
|
199
|
+
else if (fields.every((field) => formFieldType(field.type)?.presentational))
|
|
200
|
+
add(file, 'page.group.field', 'every field is presentational, so the form collects nothing')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function validateCondition(
|
|
204
|
+
condition: FormCondition,
|
|
205
|
+
fields: readonly SchemaFormField[],
|
|
206
|
+
earlier: ReadonlySet<string>,
|
|
207
|
+
file: string,
|
|
208
|
+
path: string,
|
|
209
|
+
add: Add,
|
|
210
|
+
): void {
|
|
211
|
+
for (const [index, rule] of condition.rules.entries()) {
|
|
212
|
+
const rulePath = `${path}.rule[${index}]`
|
|
213
|
+
const target = fields.find((field) => field.alias.toLowerCase() === rule.field.toLowerCase())
|
|
214
|
+
if (!target) {
|
|
215
|
+
add(file, `${rulePath}.field`, `unknown field "${rule.field}"`)
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
// A condition on a later field cannot be evaluated: the visitor has not
|
|
219
|
+
// reached it, so the answer would depend on which way the form is read.
|
|
220
|
+
if (!earlier.has(rule.field.toLowerCase()))
|
|
221
|
+
add(
|
|
222
|
+
file,
|
|
223
|
+
`${rulePath}.field`,
|
|
224
|
+
`"${rule.field}" comes later in the form, so it has no value to compare yet`,
|
|
225
|
+
)
|
|
226
|
+
if (formFieldType(target.type)?.presentational)
|
|
227
|
+
add(file, `${rulePath}.field`, `"${rule.field}" collects nothing, so it cannot be compared`)
|
|
228
|
+
const valueless = VALUELESS_OPERATORS.includes(rule.operator)
|
|
229
|
+
if (valueless && rule.value !== undefined)
|
|
230
|
+
add(file, `${rulePath}.value`, `${rule.operator} takes no value`)
|
|
231
|
+
if (!valueless && rule.value === undefined)
|
|
232
|
+
add(file, `${rulePath}.value`, `${rule.operator} needs a value`)
|
|
233
|
+
if (
|
|
234
|
+
rule.value !== undefined &&
|
|
235
|
+
target.values.length > 0 &&
|
|
236
|
+
(rule.operator === 'is' || rule.operator === 'isNot') &&
|
|
237
|
+
!target.values.includes(rule.value)
|
|
238
|
+
)
|
|
239
|
+
add(file, `${rulePath}.value`, `"${rule.value}" is not one of the values of "${rule.field}"`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The settings each workflow type needs, and the ones it merely allows. */
|
|
244
|
+
const WORKFLOW_SETTINGS: Record<string, { required: string[]; optional: string[] }> = {
|
|
245
|
+
sendEmail: {
|
|
246
|
+
required: ['to', 'subject'],
|
|
247
|
+
// No `template`: a TSX e-mail template is not built yet, and a setting that
|
|
248
|
+
// silently does nothing is worse than one that is refused by name.
|
|
249
|
+
optional: ['cc', 'bcc', 'reply-to', 'from', 'body', 'attach-uploads'],
|
|
250
|
+
},
|
|
251
|
+
saveAsContent: {
|
|
252
|
+
required: ['content-type', 'parent'],
|
|
253
|
+
optional: ['publish', 'name-field', 'map'],
|
|
254
|
+
},
|
|
255
|
+
sendToUrl: { required: ['url'], optional: ['method', 'headers', 'include-standard-fields'] },
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function validateWorkflows(
|
|
259
|
+
form: SchemaForm,
|
|
260
|
+
file: string,
|
|
261
|
+
options: ValidateFormsOptions,
|
|
262
|
+
add: Add,
|
|
263
|
+
): void {
|
|
264
|
+
const names = new Set<string>()
|
|
265
|
+
for (const [index, workflow] of form.workflows.entries()) {
|
|
266
|
+
const path = `workflow[${index}]`
|
|
267
|
+
if (names.has(workflow.name.toLowerCase()))
|
|
268
|
+
add(file, `${path}.name`, `duplicate workflow name "${workflow.name}"`)
|
|
269
|
+
names.add(workflow.name.toLowerCase())
|
|
270
|
+
|
|
271
|
+
if (workflow.on === 'approve' && !form.requiresApproval)
|
|
272
|
+
add(
|
|
273
|
+
file,
|
|
274
|
+
`${path}.on`,
|
|
275
|
+
'runs on approval, but the form does not require approval so nothing ever approves it',
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
const rules = WORKFLOW_SETTINGS[workflow.type]
|
|
279
|
+
if (!rules) continue
|
|
280
|
+
const allowed = new Set([...rules.required, ...rules.optional])
|
|
281
|
+
for (const key of rules.required)
|
|
282
|
+
if (workflow.settings[key] === undefined)
|
|
283
|
+
add(file, `${path}.settings.${key}`, `is required by a ${workflow.type} workflow`)
|
|
284
|
+
for (const key of Object.keys(workflow.settings))
|
|
285
|
+
if (!allowed.has(key))
|
|
286
|
+
add(
|
|
287
|
+
file,
|
|
288
|
+
`${path}.settings.${key}`,
|
|
289
|
+
`unknown setting for a ${workflow.type} workflow; expected one of ${[...allowed].sort().join(', ')}`,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if (workflow.type === 'sendToUrl') {
|
|
293
|
+
const url = workflow.settings.url
|
|
294
|
+
if (typeof url === 'string' && !/^https?:\/\//.test(url))
|
|
295
|
+
add(file, `${path}.settings.url`, 'must be an http or https URL')
|
|
296
|
+
const method = workflow.settings.method
|
|
297
|
+
if (method !== undefined && method !== 'POST' && method !== 'PUT')
|
|
298
|
+
add(file, `${path}.settings.method`, "expected 'POST' or 'PUT'")
|
|
299
|
+
}
|
|
300
|
+
if (workflow.type === 'saveAsContent') {
|
|
301
|
+
const alias = workflow.settings['content-type']
|
|
302
|
+
if (
|
|
303
|
+
options.contentTypeAliases &&
|
|
304
|
+
typeof alias === 'string' &&
|
|
305
|
+
!options.contentTypeAliases.has(alias)
|
|
306
|
+
)
|
|
307
|
+
add(file, `${path}.settings.content-type`, `unknown content type "${alias}"`)
|
|
308
|
+
validateFieldMap(form, workflow.settings.map, file, `${path}.settings.map`, add)
|
|
309
|
+
const nameField = workflow.settings['name-field']
|
|
310
|
+
if (typeof nameField === 'string' && !hasField(form, nameField))
|
|
311
|
+
add(file, `${path}.settings.name-field`, `unknown field "${nameField}"`)
|
|
312
|
+
}
|
|
313
|
+
if (workflow.type === 'sendEmail') {
|
|
314
|
+
const attach = workflow.settings['attach-uploads']
|
|
315
|
+
if (attach === true && !allFormFields(form).some((field) => field.type === 'fileUpload'))
|
|
316
|
+
add(
|
|
317
|
+
file,
|
|
318
|
+
`${path}.settings.attach-uploads`,
|
|
319
|
+
'the form has no file upload field, so there is nothing to attach',
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const hasField = (form: SchemaForm, alias: string): boolean =>
|
|
326
|
+
allFormFields(form).some((field) => field.alias.toLowerCase() === alias.toLowerCase())
|
|
327
|
+
|
|
328
|
+
/** `map` is field alias → property alias; the field half has to exist. */
|
|
329
|
+
function validateFieldMap(
|
|
330
|
+
form: SchemaForm,
|
|
331
|
+
map: unknown,
|
|
332
|
+
file: string,
|
|
333
|
+
path: string,
|
|
334
|
+
add: Add,
|
|
335
|
+
): void {
|
|
336
|
+
if (map === undefined) return
|
|
337
|
+
if (typeof map !== 'object' || map === null || Array.isArray(map)) {
|
|
338
|
+
add(file, path, 'expected a table of field alias to property alias')
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
for (const [field, property] of Object.entries(map as Record<string, unknown>)) {
|
|
342
|
+
if (!hasField(form, field)) add(file, `${path}.${field}`, `unknown field "${field}"`)
|
|
343
|
+
if (typeof property !== 'string')
|
|
344
|
+
add(file, `${path}.${field}`, 'expected a property alias as a string')
|
|
345
|
+
}
|
|
346
|
+
}
|