@fabrika/app 0.3.3 → 0.4.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/README.md +78 -90
- package/package.json +77 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +370 -0
- package/skill/references/dev.md +90 -0
- package/skill/references/endpoint.md +640 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +746 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +308 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +331 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1932 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +444 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +161 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +824 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +120 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1095 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +760 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +281 -0
- package/src/react/index.ts +34 -0
- package/src/react/provider.tsx +179 -0
- package/src/react/store.ts +215 -0
- package/src/react/testing.ts +144 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/challenge.ts +10 -0
- package/src/testing/dev.ts +99 -0
- package/src/testing/endpoint-access.ts +809 -0
- package/src/testing/index.ts +36 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +585 -0
- package/src/testing/membership.ts +797 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// `compileContract`: the evaluated contract → its declaration with every zod schema read as a `SchemaShape` →
|
|
2
|
+
// `buildArtifact`. A reader of source produces the same declaration and calls `buildArtifact` itself.
|
|
3
|
+
|
|
4
|
+
import type { ContractArtifact, SchemaShape, TablePolicyArtifact } from '@fabrika/auth-core'
|
|
5
|
+
import { inboxColumns, isTables, tablePolicies, type Tables } from '@fabrika/db/tables'
|
|
6
|
+
import { attachmentOf } from './attachment'
|
|
7
|
+
import { buildArtifact } from './build'
|
|
8
|
+
import {
|
|
9
|
+
type AttachmentDeclaration,
|
|
10
|
+
type ContractDeclaration,
|
|
11
|
+
type ExportDeclaration,
|
|
12
|
+
isProcedure,
|
|
13
|
+
type MountDeclaration,
|
|
14
|
+
type ProcedureDeclaration,
|
|
15
|
+
type ProcedureTreeDeclaration,
|
|
16
|
+
SURFACE_SECTIONS,
|
|
17
|
+
type SurfaceSection,
|
|
18
|
+
} from './declaration'
|
|
19
|
+
import type { AnyContract, AnyMount, Schema, Surface } from './define'
|
|
20
|
+
import { Problems } from './problems'
|
|
21
|
+
import { describeSchema } from './schema-shape'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The normalized artifact of a contract and its table policies: what `defineTables` returned (`src/db/tables.ts`),
|
|
25
|
+
* or the artifact form of each policy. Only the first knows each inbox's columns, so only it checks a form's fields
|
|
26
|
+
* against them; `boot` checks them against the data layer either way.
|
|
27
|
+
*/
|
|
28
|
+
export function compileContract<DB, P>(
|
|
29
|
+
contract: AnyContract,
|
|
30
|
+
tables: Tables<DB, P> | Readonly<Record<string, TablePolicyArtifact>>,
|
|
31
|
+
): ContractArtifact {
|
|
32
|
+
const described = describeContract(contract)
|
|
33
|
+
return isTables(tables) ? buildArtifact(described, tablePolicies(tables), inboxColumns(tables)) : buildArtifact(described, tables)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The contract with its schemas read as shapes; throws `ContractError` for a schema outside the subset. */
|
|
37
|
+
export function describeContract(contract: AnyContract): ContractDeclaration<SchemaShape> {
|
|
38
|
+
const problems = new Problems()
|
|
39
|
+
const reader = new SchemaReader(problems)
|
|
40
|
+
const { surface, vocabulary } = contract
|
|
41
|
+
const mounts: Record<string, MountDeclaration<SchemaShape>> = {}
|
|
42
|
+
for (const [name, entry] of Object.entries(surface)) {
|
|
43
|
+
if (isSection(name)) {
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (isMount(entry)) {
|
|
47
|
+
mounts[name] = reader.mount(name, entry)
|
|
48
|
+
} else {
|
|
49
|
+
problems.add(`mount '${name}'`, 'is not a mount (allowed: c.rpc, c.link, c.spa, c.health, c.form or c.webhook)')
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const exports: Record<string, ExportDeclaration<SchemaShape>> = {}
|
|
53
|
+
for (const [name, declaration] of Object.entries(surface.exports ?? {})) {
|
|
54
|
+
exports[name] = { ...declaration, procedures: reader.tree(`export '${name}'`, declaration.procedures) }
|
|
55
|
+
}
|
|
56
|
+
problems.throwIfAny()
|
|
57
|
+
return {
|
|
58
|
+
app: vocabulary.app,
|
|
59
|
+
dimensions: vocabulary.dimensions,
|
|
60
|
+
actions: vocabulary.actions,
|
|
61
|
+
roles: vocabulary.roles,
|
|
62
|
+
mounts,
|
|
63
|
+
files: surface.files ?? {},
|
|
64
|
+
jobs: surface.jobs ?? {},
|
|
65
|
+
exports,
|
|
66
|
+
imports: surface.imports ?? {},
|
|
67
|
+
csp: surface.csp ?? {},
|
|
68
|
+
...(vocabulary.locale === undefined ? {} : { locale: vocabulary.locale }),
|
|
69
|
+
...(vocabulary.displayName === undefined ? {} : { displayName: vocabulary.displayName }),
|
|
70
|
+
...(vocabulary.mail === undefined ? {} : { mail: vocabulary.mail }),
|
|
71
|
+
...(vocabulary.members === undefined ? {} : { members: vocabulary.members }),
|
|
72
|
+
...(vocabulary.directory === undefined ? {} : { directory: vocabulary.directory }),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const SECTIONS: ReadonlySet<string> = new Set(SURFACE_SECTIONS)
|
|
77
|
+
|
|
78
|
+
function isSection(name: string): name is SurfaceSection {
|
|
79
|
+
return SECTIONS.has(name)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const MOUNT_KINDS: ReadonlySet<string> = new Set(['rpc', 'link', 'spa', 'health', 'form', 'webhook'])
|
|
83
|
+
|
|
84
|
+
function isMount(entry: Surface[string]): entry is AnyMount {
|
|
85
|
+
return typeof entry === 'object' && 'kind' in entry && typeof entry.kind === 'string' && MOUNT_KINDS.has(entry.kind)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class SchemaReader {
|
|
89
|
+
readonly #problems: Problems
|
|
90
|
+
|
|
91
|
+
constructor(problems: Problems) {
|
|
92
|
+
this.#problems = problems
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
mount(name: string, mount: AnyMount): MountDeclaration<SchemaShape> {
|
|
96
|
+
const where = `mount '${name}'`
|
|
97
|
+
switch (mount.kind) {
|
|
98
|
+
case 'rpc':
|
|
99
|
+
return { ...mount, procedures: this.tree(where, mount.procedures) }
|
|
100
|
+
case 'link':
|
|
101
|
+
return { ...mount, procedures: this.tree(where, mount.procedures) }
|
|
102
|
+
case 'form':
|
|
103
|
+
return { ...mount, input: this.schema(`${where} input`, mount.input) }
|
|
104
|
+
case 'webhook':
|
|
105
|
+
return { ...mount, input: this.schema(`${where} input`, mount.input) }
|
|
106
|
+
default:
|
|
107
|
+
return mount
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
tree<Access>(where: string, tree: ProcedureTreeDeclaration<Schema, Access>, prefix = ''): ProcedureTreeDeclaration<SchemaShape, Access> {
|
|
112
|
+
const described: Record<string, ProcedureDeclaration<SchemaShape, Access> | ProcedureTreeDeclaration<SchemaShape, Access>> = {}
|
|
113
|
+
for (const [name, node] of Object.entries(tree)) {
|
|
114
|
+
const path = prefix === '' ? name : `${prefix}.${name}`
|
|
115
|
+
if (isProcedure(node)) {
|
|
116
|
+
const at = `${where} procedure '${path}'`
|
|
117
|
+
described[name] = {
|
|
118
|
+
...node,
|
|
119
|
+
input: node.input === null ? null : this.schema(`${at} input`, node.input),
|
|
120
|
+
...this.output(`${at} output`, node.output),
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
described[name] = this.tree(where, node, path)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return described
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** An `attachment()` output is described as the rows' array, its declaration carried beside it. */
|
|
130
|
+
output(where: string, schema: Schema): { readonly output: SchemaShape; readonly attachment?: AttachmentDeclaration } {
|
|
131
|
+
const mark = attachmentOf(schema)
|
|
132
|
+
if (mark === null) {
|
|
133
|
+
return { output: this.schema(where, schema) }
|
|
134
|
+
}
|
|
135
|
+
const row = this.schema(`${where} row`, mark.row)
|
|
136
|
+
return { output: { type: 'array', items: row, minItems: null, maxItems: null }, attachment: mark.declaration }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A failed schema leaves a placeholder; `describeContract` throws before anything reads it. */
|
|
140
|
+
schema(where: string, schema: Schema): SchemaShape {
|
|
141
|
+
return describeSchema(schema, where, this.#problems) ?? { type: 'null' }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Connectors between applications, as data: an export's description (what the owner serves and a consumer vendors),
|
|
2
|
+
// the vendored snapshot a consumer imports, and the one way each is read back into shapes and a digest.
|
|
3
|
+
// Browser-safe: the snapshot is imported by the contract, which the SPA imports.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
canonicalJson,
|
|
7
|
+
canonicalShape,
|
|
8
|
+
type ContractArtifact,
|
|
9
|
+
type ExportDescription,
|
|
10
|
+
exportDigest,
|
|
11
|
+
type ExportProcedureDescription,
|
|
12
|
+
isAttachmentShape,
|
|
13
|
+
type SchemaShape,
|
|
14
|
+
} from '@fabrika/auth-core'
|
|
15
|
+
import { z } from 'zod/v4'
|
|
16
|
+
import { schemaShapeSchema } from './artifact-schema'
|
|
17
|
+
import { exportBase } from './defaults'
|
|
18
|
+
import type { InputSchema, Schema } from './define'
|
|
19
|
+
import { ContractError, Problems } from './problems'
|
|
20
|
+
import { describeSchema } from './schema-shape'
|
|
21
|
+
|
|
22
|
+
/** One vendored procedure: the zod schemas `fabrika connect add` generated from the owner's description. */
|
|
23
|
+
export interface SnapshotProcedure {
|
|
24
|
+
readonly input: InputSchema | null
|
|
25
|
+
readonly output: Schema
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The vendored description of another application's export, as `fabrika connect add` writes it to
|
|
30
|
+
* `fabrika/connect/<app>.<export>.v<version>.ts`. The consumer passes it to `implement({ connect })` and imports its
|
|
31
|
+
* digest into the contract.
|
|
32
|
+
*/
|
|
33
|
+
export interface ConnectSnapshot {
|
|
34
|
+
readonly app: string
|
|
35
|
+
readonly export: string
|
|
36
|
+
readonly version: number
|
|
37
|
+
readonly audience: string
|
|
38
|
+
readonly digest: string
|
|
39
|
+
/** By the procedure's dotted path in the export. */
|
|
40
|
+
readonly procedures: { readonly [path: string]: SnapshotProcedure }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The description the owner serves for `name`, or `null` when the artifact declares no such export. */
|
|
44
|
+
export function exportDescription(artifact: ContractArtifact, name: string): ExportDescription | null {
|
|
45
|
+
const declaration = Object.hasOwn(artifact.exports, name) ? artifact.exports[name] : undefined
|
|
46
|
+
if (declaration === undefined) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
const prefix = `${exportBase(name, declaration.version)}.`
|
|
50
|
+
const procedures: Record<string, ExportProcedureDescription> = {}
|
|
51
|
+
for (const row of artifact.endpoints) {
|
|
52
|
+
// The build refuses an attachment on an export, so an export row's output is a schema.
|
|
53
|
+
if (
|
|
54
|
+
row.source.kind === 'export' && row.source.name === name && row.id.startsWith(prefix) && row.output !== null && !isAttachmentShape(row.output)
|
|
55
|
+
) {
|
|
56
|
+
procedures[row.id.slice(prefix.length)] = { input: row.input, output: row.output }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const described = { app: artifact.app, export: name, version: declaration.version, audience: declaration.audience, procedures }
|
|
60
|
+
return { ...described, digest: exportDigest(described) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The description a snapshot's schemas describe now, with the digest of that. A snapshot whose recomputed digest is
|
|
65
|
+
* not its own `digest` was edited after `fabrika connect add` wrote it. Throws `ContractError` for a schema outside
|
|
66
|
+
* the contract subset.
|
|
67
|
+
*/
|
|
68
|
+
export function describeSnapshot(snapshot: ConnectSnapshot): ExportDescription {
|
|
69
|
+
const problems = new Problems()
|
|
70
|
+
const procedures: Record<string, ExportProcedureDescription> = {}
|
|
71
|
+
for (const [path, procedure] of Object.entries(snapshot.procedures)) {
|
|
72
|
+
const where = `snapshot ${snapshot.app}/${snapshot.export}@${snapshot.version} procedure '${path}'`
|
|
73
|
+
const input = procedure.input === null ? null : describeSchema(procedure.input, `${where} input`, problems)
|
|
74
|
+
const output = describeSchema(procedure.output, `${where} output`, problems)
|
|
75
|
+
procedures[path] = { input, output: output ?? { type: 'null' } }
|
|
76
|
+
}
|
|
77
|
+
problems.throwIfAny()
|
|
78
|
+
const described = { app: snapshot.app, export: snapshot.export, version: snapshot.version, audience: snapshot.audience, procedures }
|
|
79
|
+
return { ...described, digest: exportDigest(described) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const procedureDescription: z.ZodType<ExportProcedureDescription> = z.strictObject({
|
|
83
|
+
input: schemaShapeSchema.nullable(),
|
|
84
|
+
output: schemaShapeSchema,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/** The parser of a description another application served; the digest is checked by `parseExportDescription`. */
|
|
88
|
+
export const exportDescriptionSchema: z.ZodType<ExportDescription> = z.strictObject({
|
|
89
|
+
app: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
90
|
+
export: z.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/),
|
|
91
|
+
version: z.int().positive(),
|
|
92
|
+
audience: z.string().min(1),
|
|
93
|
+
procedures: z.record(z.string().regex(/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/), procedureDescription),
|
|
94
|
+
digest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const DESCRIPTION_FIELDS: readonly string[] = ['app', 'export', 'version', 'audience', 'procedures', 'digest']
|
|
98
|
+
|
|
99
|
+
/** A served description, parsed, whose digest is the digest of what it describes; throws `ContractError` otherwise. */
|
|
100
|
+
export function parseExportDescription(value: unknown): ExportDescription {
|
|
101
|
+
const parsed = exportDescriptionSchema.safeParse(value)
|
|
102
|
+
if (!parsed.success) {
|
|
103
|
+
// Another application wrote the value: name only the fields this parser declares and zod's fixed codes.
|
|
104
|
+
const problems = parsed.error.issues.map((issue) => {
|
|
105
|
+
const [top] = issue.path
|
|
106
|
+
const field = typeof top === 'string' && DESCRIPTION_FIELDS.includes(top) ? top : '(root)'
|
|
107
|
+
return `description: invalid at ${field} (${issue.code})`
|
|
108
|
+
})
|
|
109
|
+
throw new ContractError([...new Set(problems)])
|
|
110
|
+
}
|
|
111
|
+
const { digest, ...described } = parsed.data
|
|
112
|
+
if (exportDigest(described) !== digest) {
|
|
113
|
+
throw new ContractError([`description ${described.app}/${described.export}@${described.version}: its digest does not match what it describes`])
|
|
114
|
+
}
|
|
115
|
+
return parsed.data
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What an owner's change of one export version breaks for a consumer that imports `procedures` of it: a removed
|
|
120
|
+
* procedure, an input that accepts less, a changed output, or a changed audience. An output may not even gain a field,
|
|
121
|
+
* because the consumer parses every answer against its vendored schema, whose objects are strict; an input object may
|
|
122
|
+
* gain optional fields. Every other change is a new version. Empty when the change is compatible.
|
|
123
|
+
*/
|
|
124
|
+
export function incompatibleChanges(before: ExportDescription, after: ExportDescription, procedures: readonly string[]): string[] {
|
|
125
|
+
const problems: string[] = []
|
|
126
|
+
if (before.audience !== after.audience) {
|
|
127
|
+
problems.push('changes its audience')
|
|
128
|
+
}
|
|
129
|
+
for (const path of procedures) {
|
|
130
|
+
const old = Object.hasOwn(before.procedures, path) ? before.procedures[path] : undefined
|
|
131
|
+
const now = Object.hasOwn(after.procedures, path) ? after.procedures[path] : undefined
|
|
132
|
+
// A procedure the export never had is the consumer's mistake, which its own deploy is refused for.
|
|
133
|
+
if (old === undefined) {
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
if (now === undefined) {
|
|
137
|
+
problems.push(`removes '${path}'`)
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
if (!acceptsEveryInput(old.input, now.input)) {
|
|
141
|
+
problems.push(`'${path}' accepts less input than before`)
|
|
142
|
+
}
|
|
143
|
+
if (canonicalJson(canonicalShape(old.output)) !== canonicalJson(canonicalShape(now.output))) {
|
|
144
|
+
problems.push(`'${path}' answers another output than before`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return problems
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function acceptsEveryInput(before: SchemaShape | null, after: SchemaShape | null): boolean {
|
|
151
|
+
if (before === null || after === null) {
|
|
152
|
+
return before === after
|
|
153
|
+
}
|
|
154
|
+
return widens(canonicalShape(before), canonicalShape(after))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Whether `after` accepts every value `before` does, by the one widening allowed: an object field that is new and optional. */
|
|
158
|
+
function widens(before: SchemaShape, after: SchemaShape): boolean {
|
|
159
|
+
if (before.type === 'object' && after.type === 'object') {
|
|
160
|
+
const kept = Object.entries(before.fields).every(([name, field]) => {
|
|
161
|
+
const next = Object.hasOwn(after.fields, name) ? after.fields[name] : undefined
|
|
162
|
+
return next !== undefined && widens(field, next)
|
|
163
|
+
})
|
|
164
|
+
return kept && Object.entries(after.fields).every(([name, field]) => Object.hasOwn(before.fields, name) || field.type === 'optional')
|
|
165
|
+
}
|
|
166
|
+
if ((before.type === 'optional' && after.type === 'optional') || (before.type === 'nullable' && after.type === 'nullable')) {
|
|
167
|
+
return widens(before.inner, after.inner)
|
|
168
|
+
}
|
|
169
|
+
return canonicalJson(before) === canonicalJson(after)
|
|
170
|
+
}
|