@pepys/kit 0.0.0-stage → 0.0.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.
Files changed (67) hide show
  1. package/README.md +63 -2
  2. package/package.json +34 -4
  3. package/src/constants.ts +1 -0
  4. package/src/consumers/define-consumer.ts +20 -0
  5. package/src/consumers/types.ts +48 -0
  6. package/src/delivery-checks/build-delivery-proof.ts +12 -0
  7. package/src/delivery-checks/is-delivery-authentic.ts +49 -0
  8. package/src/deployments/check-deployment.ts +444 -0
  9. package/src/deployments/define-deployment.ts +7 -0
  10. package/src/deployments/define-plugin.ts +56 -0
  11. package/src/deployments/list-receive-routes.ts +27 -0
  12. package/src/deployments/types.ts +110 -0
  13. package/src/entity-ids/build-entity-id.ts +16 -0
  14. package/src/entity-ids/entity-id-failure.ts +6 -0
  15. package/src/entity-ids/entity-id-parts.ts +5 -0
  16. package/src/entity-ids/fact-kinds.ts +3 -0
  17. package/src/entity-ids/find-entity-id-failure.ts +25 -0
  18. package/src/entity-ids/parse-entity-id.ts +27 -0
  19. package/src/fact-envelope-cases.json +464 -0
  20. package/src/facts/build-enrichment-fact-id.ts +15 -0
  21. package/src/facts/build-event-fact-id.ts +11 -0
  22. package/src/facts/build-fact-hash.ts +6 -0
  23. package/src/facts/build-manual-fact-id.ts +7 -0
  24. package/src/facts/build-observation-fact-id.ts +15 -0
  25. package/src/facts/build-webhook-fact-id.ts +11 -0
  26. package/src/facts/parse-fact-envelope.ts +44 -0
  27. package/src/facts/parse-stream-name.ts +33 -0
  28. package/src/generators/build-payload-shape.ts +75 -0
  29. package/src/generators/collect-view-kinds.ts +8 -0
  30. package/src/generators/format-column-type.ts +16 -0
  31. package/src/generators/format-entity-ids.ts +46 -0
  32. package/src/generators/format-fact-reader.ts +194 -0
  33. package/src/generators/format-literal.ts +13 -0
  34. package/src/generators/format-model-contract.ts +23 -0
  35. package/src/generators/format-operations.ts +109 -0
  36. package/src/generators/format-payload-shape.ts +181 -0
  37. package/src/generators/format-property-access.ts +6 -0
  38. package/src/generators/format-property-name.ts +6 -0
  39. package/src/generators/format-row-readers.ts +116 -0
  40. package/src/generators/generate-boundary-glue.ts +60 -0
  41. package/src/generators/generate-sqlite-schema.ts +78 -0
  42. package/src/generators/is-identifier.ts +5 -0
  43. package/src/generators/quote-sql-identifier.ts +3 -0
  44. package/src/generators/to-entity-id-type-name.ts +6 -0
  45. package/src/generators/to-pascal-case.ts +7 -0
  46. package/src/generators.ts +4 -0
  47. package/src/ids/types.ts +25 -0
  48. package/src/index.ts +36 -0
  49. package/src/link-types/define-link-type.ts +9 -0
  50. package/src/link-types/types.ts +15 -0
  51. package/src/links/find-link-problem.ts +53 -0
  52. package/src/links/link-check-cases.json +274 -0
  53. package/src/links/types.ts +22 -0
  54. package/src/queries/define-query.ts +14 -0
  55. package/src/queries/types.ts +56 -0
  56. package/src/send/create-fact-batch-sender.ts +143 -0
  57. package/src/send/types.ts +25 -0
  58. package/src/send.ts +2 -0
  59. package/src/sources/define-fact-family.ts +10 -0
  60. package/src/sources/define-source.ts +7 -0
  61. package/src/sources/read-fact-batch.ts +114 -0
  62. package/src/sources/receive-fact-batch.ts +11 -0
  63. package/src/sources/types.ts +154 -0
  64. package/src/store-reader/store-reader.ts +76 -0
  65. package/src/tables/define-table.ts +13 -0
  66. package/src/tables/table-row.ts +20 -0
  67. package/src/types.ts +47 -0
@@ -0,0 +1,8 @@
1
+ import type { ConsumerDeclaration } from '../consumers/types.ts';
2
+ import type { EntityKind } from '../link-types/types.ts';
3
+
4
+ // The ID column of an entity view holds IDs of the view's kind, keyed `<table>.<column>`. Any other
5
+ // entityID column holds an ID of any kind, because a column declares no kind.
6
+ export function collectViewKinds(consumer: ConsumerDeclaration): ReadonlyMap<string, EntityKind> {
7
+ return new Map(consumer.entityViews.map((view) => [`${view.table}.${view.id}`, view.kind]));
8
+ }
@@ -0,0 +1,16 @@
1
+ import { match } from 'ts-pattern';
2
+ import type { EntityKind } from '../link-types/types.ts';
3
+ import type { ColumnDeclaration } from '../types.ts';
4
+ import { toEntityIDTypeName } from './to-entity-id-type-name.ts';
5
+
6
+ export function formatColumnType(column: ColumnDeclaration, kind: EntityKind | undefined): string {
7
+ const type = match(column.kind)
8
+ .with('text', 'timestamp', () => 'string')
9
+ .with('integer', 'real', () => 'number')
10
+ .with('boolean', () => 'boolean')
11
+ .with('json', () => 'unknown')
12
+ .with('entityID', () => (kind === undefined ? 'EntityID' : toEntityIDTypeName(kind)))
13
+ .exhaustive();
14
+
15
+ return column.nullable === true && column.kind !== 'json' ? `${type} | null` : type;
16
+ }
@@ -0,0 +1,46 @@
1
+ import type { EntityKind } from '../link-types/types.ts';
2
+ import { formatLiteral } from './format-literal.ts';
3
+ import { toEntityIDTypeName } from './to-entity-id-type-name.ts';
4
+
5
+ // The glue brands IDs with its own symbols: scriptc refuses a type-fest import, and a brand whose
6
+ // tag is `string` and not a literal. So a kinded ID is an EntityID with a second, literal tag.
7
+ export function formatEntityIDs(kinds: readonly EntityKind[]): string[] {
8
+ return [
9
+ [
10
+ 'declare const idTag: unique symbol;',
11
+ 'declare const kindTag: unique symbol;',
12
+ '',
13
+ "export type EntityID = string & { readonly [idTag]: 'EntityID' };",
14
+ '',
15
+ 'type KindedID<Kind extends string> = EntityID & { readonly [kindTag]: Kind };',
16
+ ].join('\n'),
17
+ ...kinds.map((kind) => formatKindedID(kind)),
18
+ ];
19
+ }
20
+
21
+ function formatKindedID(kind: EntityKind): string {
22
+ const name = toEntityIDTypeName(kind);
23
+ const prefix = formatLiteral(`${kind}:`);
24
+
25
+ return [
26
+ `export type ${name} = KindedID<${formatLiteral(kind)}>;`,
27
+ '',
28
+ `export function to${name}(id: string): ${name} {`,
29
+ ` const text = ${prefix} + id;`,
30
+ '',
31
+ ` if (!is${name}(text)) {`,
32
+ ` throw new Error(${formatLiteral(`an ID of the kind ${kind} is never empty`)});`,
33
+ ' }',
34
+ '',
35
+ ' return text;',
36
+ '}',
37
+ '',
38
+ `export function parse${name}(text: string): ${name} | undefined {`,
39
+ ` return is${name}(text) ? text : undefined;`,
40
+ '}',
41
+ '',
42
+ `function is${name}(text: string): text is ${name} {`,
43
+ ` return text.length > ${prefix}.length && text.startsWith(${prefix});`,
44
+ '}',
45
+ ].join('\n');
46
+ }
@@ -0,0 +1,194 @@
1
+ import type { z } from 'zod';
2
+ import type { ConsumerInput } from '../consumers/types.ts';
3
+ import type { FactFamily } from '../sources/types.ts';
4
+ import { buildPayloadShape } from './build-payload-shape.ts';
5
+ import { formatLiteral } from './format-literal.ts';
6
+ import { formatPayloadShape } from './format-payload-shape.ts';
7
+ import type { FormattedShape } from './format-payload-shape.ts';
8
+ import { toEntityIDTypeName } from './to-entity-id-type-name.ts';
9
+ import { toPascalCase } from './to-pascal-case.ts';
10
+
11
+ interface FactReaderParameters {
12
+ consumerName: string;
13
+ input: ConsumerInput;
14
+
15
+ // the families on the consumer's streams
16
+ families: readonly FactFamily[];
17
+ }
18
+
19
+ // The fact union of the consumer's streams, its decoder, and the read: `readScopeFacts` in scope
20
+ // mode, `parse<Consumer>Fact` in fact mode. A fact whose type no family declares, or whose payload
21
+ // does not decode, reads as no fact.
22
+ export function formatFactReader(parameters: FactReaderParameters): string[] {
23
+ const fact = `${parameters.consumerName}Fact`;
24
+ const groups = collectPayloadGroups(parameters.families);
25
+
26
+ const members = groups.map((group) => ({
27
+ types: group.types,
28
+ payload: formatPayloadShape(
29
+ buildPayloadShape(group.schema),
30
+ `${toPascalCase(pickPayloadName(group.types))}Payload`,
31
+ ),
32
+ }));
33
+
34
+ return [
35
+ ...members.flatMap((member) => member.payload.interfaces),
36
+ formatFactUnion(fact, members),
37
+ ...formatRead(fact, parameters.input),
38
+ formatFactDecoder(fact, members),
39
+ ...members.flatMap((member) => member.payload.decoders),
40
+ ];
41
+ }
42
+
43
+ interface PayloadGroup {
44
+ schema: z.ZodType;
45
+ types: string[];
46
+ }
47
+
48
+ // Fact types that share one schema share one payload type and one decoder.
49
+ function collectPayloadGroups(families: readonly FactFamily[]): PayloadGroup[] {
50
+ const groups = new Map<z.ZodType, string[]>();
51
+
52
+ for (const family of families) {
53
+ for (const [type, schema] of Object.entries(family.types)) {
54
+ groups.set(schema, [...(groups.get(schema) ?? []), type]);
55
+ }
56
+ }
57
+
58
+ return [...groups].map(([schema, types]) => ({ schema, types }));
59
+ }
60
+
61
+ // Types that share a schema share the dotted prefix of their names, such as `github.pull_request`
62
+ // for every action of a pull request delivery.
63
+ function pickPayloadName(types: readonly string[]): string {
64
+ const [first = [], ...rest] = types.map((type) => type.split('.'));
65
+ const end = first.findIndex((part, index) => rest.some((other) => other[index] !== part));
66
+ const shared = end === -1 ? first : first.slice(0, end);
67
+
68
+ return shared.length > 0 ? shared.join('.') : (types[0] ?? '');
69
+ }
70
+
71
+ interface FactMember {
72
+ types: readonly string[];
73
+ payload: FormattedShape;
74
+ }
75
+
76
+ function formatFactUnion(fact: string, members: readonly FactMember[]): string {
77
+ if (members.length === 0) {
78
+ return `export type ${fact} = never;`;
79
+ }
80
+
81
+ const lines = members.map(
82
+ (member) =>
83
+ ` | { id: string; type: ${formatTypeUnion(member.types)}; entity: string; at: string; payload: ${member.payload.type} }`,
84
+ );
85
+
86
+ return [`export type ${fact} =`, ...lines].join('\n').concat(';');
87
+ }
88
+
89
+ function formatTypeUnion(types: readonly string[]): string {
90
+ return types.map((type) => formatLiteral(type)).join(' | ');
91
+ }
92
+
93
+ function formatRead(fact: string, input: ConsumerInput): string[] {
94
+ if (input.mode === 'fact') {
95
+ return [
96
+ [
97
+ `export function parse${fact}(factJSON: string): ${fact} | undefined {`,
98
+ ' const parsed: unknown = JSON.parse(factJSON);',
99
+ '',
100
+ ` return decode${fact}(parsed);`,
101
+ '}',
102
+ ].join('\n'),
103
+ ];
104
+ }
105
+
106
+ const scope = toEntityIDTypeName(input.scopeKind);
107
+
108
+ return [
109
+ [
110
+ 'declare function readScopeFactsLength(scope: string): number;',
111
+ 'declare function readScopeFactsInto(scope: string, facts: Uint8Array): void;',
112
+ ].join('\n'),
113
+ [
114
+ "// every fact of the scope on the consumer's streams, one per fact ID, in the host's order",
115
+ `export function readScopeFacts(scope: ${scope}): ${fact}[] {`,
116
+ ' const buffer = new Uint8Array(readScopeFactsLength(scope));',
117
+ '',
118
+ ' readScopeFactsInto(scope, buffer);',
119
+ '',
120
+ ' const parsed: unknown = JSON.parse(new TextDecoder().decode(buffer));',
121
+ ` const facts: ${fact}[] = [];`,
122
+ '',
123
+ ' if (!Array.isArray(parsed)) {',
124
+ ' return facts;',
125
+ ' }',
126
+ '',
127
+ ' for (let index = 0; index < parsed.length; index += 1) {',
128
+ ` const decoded = decode${fact}(parsed[index]);`,
129
+ '',
130
+ ' if (decoded !== undefined) {',
131
+ ' facts.push(decoded);',
132
+ ' }',
133
+ ' }',
134
+ '',
135
+ ' return facts;',
136
+ '}',
137
+ ].join('\n'),
138
+ ];
139
+ }
140
+
141
+ function formatFactDecoder(fact: string, members: readonly FactMember[]): string {
142
+ const parameter = members.length === 0 ? '_value' : 'value';
143
+
144
+ const branches = members.flatMap((member) => [
145
+ ` if (${member.types.map((type) => `type === ${formatLiteral(type)}`).join(' || ')}) {`,
146
+ ...formatPayloadDecode(member.payload),
147
+ ' return { id, type, entity, at, payload: decoded };',
148
+ ' }',
149
+ '',
150
+ ]);
151
+
152
+ const body =
153
+ members.length === 0
154
+ ? []
155
+ : [
156
+ " if (typeof value !== 'object' || value === null || Array.isArray(value)) {",
157
+ ' return undefined;',
158
+ ' }',
159
+ '',
160
+ " const id = 'id' in value ? value.id : undefined;",
161
+ " const type = 'type' in value ? value.type : undefined;",
162
+ " const entity = 'entity' in value ? value.entity : undefined;",
163
+ " const at = 'at' in value ? value.at : undefined;",
164
+ " const payload = 'payload' in value ? value.payload : undefined;",
165
+ '',
166
+ " if (typeof id !== 'string' || typeof entity !== 'string' || typeof at !== 'string') {",
167
+ ' return undefined;',
168
+ ' }',
169
+ '',
170
+ ...branches,
171
+ ];
172
+
173
+ return [
174
+ `function decode${fact}(${parameter}: unknown): ${fact} | undefined {`,
175
+ ...body,
176
+ ' return undefined;',
177
+ '}',
178
+ ].join('\n');
179
+ }
180
+
181
+ function formatPayloadDecode(payload: FormattedShape): string[] {
182
+ if (!payload.canFail) {
183
+ return [' const decoded = payload;', ''];
184
+ }
185
+
186
+ return [
187
+ ` const decoded = ${payload.decode('payload')};`,
188
+ '',
189
+ ' if (decoded === undefined) {',
190
+ ' return undefined;',
191
+ ' }',
192
+ '',
193
+ ];
194
+ }
@@ -0,0 +1,13 @@
1
+ // A TypeScript literal in single quotes, the quote style of the rest of the glue.
2
+ export function formatLiteral(value: string | number | boolean): string {
3
+ if (typeof value !== 'string') {
4
+ return String(value);
5
+ }
6
+
7
+ const escaped = JSON.stringify(value)
8
+ .slice(1, -1)
9
+ .replaceAll(String.raw`\"`, '"')
10
+ .replaceAll("'", String.raw`\'`);
11
+
12
+ return `'${escaped}'`;
13
+ }
@@ -0,0 +1,23 @@
1
+ // scriptc attests a library whose entry exports a model type, a message union tagged by `kind`,
2
+ // `init` and `update`. It takes the types through a re-export but not the functions, so the entry
3
+ // declares `init` and `update` and calls these two.
4
+ export function formatModelContract(consumerName: string): string {
5
+ const model = `${consumerName}Model`;
6
+ const message = `${consumerName}Message`;
7
+
8
+ return [
9
+ `export interface ${model} {`,
10
+ ' input: string;',
11
+ '}',
12
+ '',
13
+ `export type ${message} = { kind: 'apply'; input: string } | { kind: 'reset' };`,
14
+ '',
15
+ `export function init${model}(): ${model} {`,
16
+ " return { input: '' };",
17
+ '}',
18
+ '',
19
+ `export function update${model}(model: ${model}, message: ${message}): ${model} {`,
20
+ ` return message.kind === 'reset' ? init${model}() : { ...model, input: message.input };`,
21
+ '}',
22
+ ].join('\n');
23
+ }
@@ -0,0 +1,109 @@
1
+ import type { ConsumerDeclaration } from '../consumers/types.ts';
2
+ import type { EntityKind, LinkTypeDeclaration } from '../link-types/types.ts';
3
+ import type { ColumnDeclaration, TableDeclaration } from '../types.ts';
4
+ import { collectViewKinds } from './collect-view-kinds.ts';
5
+ import { formatColumnType } from './format-column-type.ts';
6
+ import { formatLiteral } from './format-literal.ts';
7
+ import { formatPropertyName } from './format-property-name.ts';
8
+ import { toEntityIDTypeName } from './to-entity-id-type-name.ts';
9
+ import { toPascalCase } from './to-pascal-case.ts';
10
+
11
+ // The row and key type of each table, the link of each link type, the operation union, and one
12
+ // constructor per link type. A delete and an unlink are fact mode only.
13
+ export function formatOperations(consumer: ConsumerDeclaration): string[] {
14
+ const consumerName = toPascalCase(consumer.name);
15
+ const isFactMode = consumer.input.mode === 'fact';
16
+ const tables = Object.entries(consumer.tables);
17
+ const columnKinds = collectViewKinds(consumer);
18
+
19
+ return [
20
+ ...tables.map(([name, table]) =>
21
+ formatRecord(`${toPascalCase(name)}Row`, Object.entries(table.columns), columnKinds, name),
22
+ ),
23
+ ...(isFactMode
24
+ ? tables.map(([name, table]) =>
25
+ formatRecord(`${toPascalCase(name)}Key`, collectKey(table), columnKinds, name),
26
+ )
27
+ : []),
28
+ formatOperationUnion(consumerName, consumer, isFactMode),
29
+ ...consumer.links.map((link) => formatLinkConstructor(consumerName, link)),
30
+ ...(isFactMode
31
+ ? consumer.links.map((link) => formatUnlinkConstructor(consumerName, link))
32
+ : []),
33
+ ];
34
+ }
35
+
36
+ function formatRecord(
37
+ typeName: string,
38
+ columns: readonly (readonly [string, ColumnDeclaration])[],
39
+ columnKinds: ReadonlyMap<string, EntityKind>,
40
+ tableName: string,
41
+ ): string {
42
+ const fields = columns.map(
43
+ ([name, column]) =>
44
+ ` ${formatPropertyName(name)}: ${formatColumnType(column, columnKinds.get(`${tableName}.${name}`))};`,
45
+ );
46
+
47
+ return [`export interface ${typeName} {`, ...fields, '}'].join('\n');
48
+ }
49
+
50
+ function collectKey(table: TableDeclaration): [string, ColumnDeclaration][] {
51
+ return table.primaryKey.flatMap((name) => {
52
+ const column = table.columns[name];
53
+
54
+ return column === undefined ? [] : [[name, column]];
55
+ });
56
+ }
57
+
58
+ function formatOperationUnion(
59
+ consumerName: string,
60
+ consumer: ConsumerDeclaration,
61
+ isFactMode: boolean,
62
+ ): string {
63
+ const tableMembers = Object.keys(consumer.tables).flatMap((name) => {
64
+ const key = formatPropertyName(name);
65
+ const upsert = ` | { upsert: { ${key}: ${toPascalCase(name)}Row } }`;
66
+
67
+ return isFactMode ? [upsert, ` | { delete: { ${key}: ${toPascalCase(name)}Key } }`] : [upsert];
68
+ });
69
+
70
+ const linkMembers = consumer.links.flatMap((link) => {
71
+ const type = formatLiteral(link.name);
72
+ const ends = `from: ${formatEndType(link.from)}; to: ${formatEndType(link.to)}`;
73
+ const linkMember = ` | { link: { type: ${type}; ${ends}; confidence: number; evidence: string } }`;
74
+
75
+ return isFactMode ? [linkMember, ` | { unlink: { type: ${type}; ${ends} } }`] : [linkMember];
76
+ });
77
+
78
+ const members = [...tableMembers, ...linkMembers];
79
+
80
+ if (members.length === 0) {
81
+ return `export type ${consumerName}Operation = never;`;
82
+ }
83
+
84
+ return `${[`export type ${consumerName}Operation =`, ...members].join('\n')};`;
85
+ }
86
+
87
+ function formatEndType(kinds: readonly EntityKind[]): string {
88
+ return kinds.map((kind) => toEntityIDTypeName(kind)).join(' | ');
89
+ }
90
+
91
+ function formatLinkConstructor(consumerName: string, link: LinkTypeDeclaration): string {
92
+ const ends = `from: ${formatEndType(link.from)}; to: ${formatEndType(link.to)}`;
93
+
94
+ return [
95
+ `export function link${toPascalCase(link.name)}(link: { ${ends}; confidence: number; evidence: string }): ${consumerName}Operation {`,
96
+ ` return { link: { type: ${formatLiteral(link.name)}, from: link.from, to: link.to, confidence: link.confidence, evidence: link.evidence } };`,
97
+ '}',
98
+ ].join('\n');
99
+ }
100
+
101
+ function formatUnlinkConstructor(consumerName: string, link: LinkTypeDeclaration): string {
102
+ const ends = `from: ${formatEndType(link.from)}; to: ${formatEndType(link.to)}`;
103
+
104
+ return [
105
+ `export function unlink${toPascalCase(link.name)}(link: { ${ends} }): ${consumerName}Operation {`,
106
+ ` return { unlink: { type: ${formatLiteral(link.name)}, from: link.from, to: link.to } };`,
107
+ '}',
108
+ ].join('\n');
109
+ }
@@ -0,0 +1,181 @@
1
+ import { match } from 'ts-pattern';
2
+ import type { PayloadField, PayloadShape } from './build-payload-shape.ts';
3
+ import { formatLiteral } from './format-literal.ts';
4
+ import { formatPropertyAccess } from './format-property-access.ts';
5
+ import { formatPropertyName } from './format-property-name.ts';
6
+ import { toPascalCase } from './to-pascal-case.ts';
7
+
8
+ // The glue's type for one payload shape, the expression that decodes an `unknown` identifier into
9
+ // it (or `undefined` when the value does not fit), and the interfaces and decoders it needs.
10
+ export interface FormattedShape {
11
+ type: string;
12
+ decode: (identifier: string) => string;
13
+ canFail: boolean;
14
+ interfaces: readonly string[];
15
+ decoders: readonly string[];
16
+ }
17
+
18
+ export function formatPayloadShape(shape: PayloadShape, name: string): FormattedShape {
19
+ return match(shape)
20
+ .with({ kind: 'string' }, { kind: 'number' }, { kind: 'boolean' }, (primitive) => ({
21
+ type: primitive.kind,
22
+ decode: (identifier: string) =>
23
+ `typeof ${identifier} === '${primitive.kind}' ? ${identifier} : undefined`,
24
+ canFail: true,
25
+ interfaces: [],
26
+ decoders: [],
27
+ }))
28
+ .with({ kind: 'literal' }, (literal) => {
29
+ const values = literal.values.map((value) => formatLiteral(value));
30
+
31
+ return {
32
+ type: values.join(' | '),
33
+ decode: (identifier: string) =>
34
+ `${values.map((value) => `${identifier} === ${value}`).join(' || ')} ? ${identifier} : undefined`,
35
+ canFail: true,
36
+ interfaces: [],
37
+ decoders: [],
38
+ };
39
+ })
40
+ .with({ kind: 'nullable' }, (nullable) => {
41
+ const inner = formatPayloadShape(nullable.inner, name);
42
+
43
+ return {
44
+ type: `${inner.type} | null`,
45
+ decode: (identifier: string) =>
46
+ `${identifier} === null ? null : ${inner.decode(identifier)}`,
47
+ canFail: inner.canFail,
48
+ interfaces: inner.interfaces,
49
+ decoders: inner.decoders,
50
+ };
51
+ })
52
+ .with({ kind: 'object' }, (object) => formatObjectShape(object.fields, name))
53
+ .with({ kind: 'array' }, (array) => formatArrayShape(array.element, name))
54
+ .with({ kind: 'unknown' }, () => ({
55
+ type: 'unknown',
56
+ decode: (identifier: string) => identifier,
57
+ canFail: false,
58
+ interfaces: [],
59
+ decoders: [],
60
+ }))
61
+ .exhaustive();
62
+ }
63
+
64
+ function formatObjectShape(fields: readonly PayloadField[], name: string): FormattedShape {
65
+ const formatted = fields.map((field) => ({
66
+ field,
67
+ shape: formatPayloadShape(field.shape, `${name}${toPascalCase(field.name)}`),
68
+ }));
69
+
70
+ const members = formatted.map(
71
+ (entry) =>
72
+ ` ${formatPropertyName(entry.field.name)}${entry.field.isOptional ? '?' : ''}: ${entry.shape.type};`,
73
+ );
74
+
75
+ const reads = formatted.flatMap((entry, index) =>
76
+ formatFieldRead(entry.field, entry.shape, index),
77
+ );
78
+
79
+ const properties = formatted.map((entry, index) =>
80
+ entry.field.isOptional
81
+ ? ` ...(field${index} === undefined ? {} : { ${formatPropertyName(entry.field.name)}: field${index} }),`
82
+ : ` ${formatPropertyName(entry.field.name)}: field${index},`,
83
+ );
84
+
85
+ const decoder = [
86
+ `function decode${name}(value: unknown): ${name} | undefined {`,
87
+ " if (typeof value !== 'object' || value === null || Array.isArray(value)) {",
88
+ ' return undefined;',
89
+ ' }',
90
+ '',
91
+ ...reads,
92
+ ' return {',
93
+ ...properties,
94
+ ' };',
95
+ '}',
96
+ ].join('\n');
97
+
98
+ return {
99
+ type: name,
100
+ decode: (identifier: string) => `decode${name}(${identifier})`,
101
+ canFail: true,
102
+ interfaces: [
103
+ [`export interface ${name} {`, ...members, '}'].join('\n'),
104
+ ...formatted.flatMap((entry) => entry.shape.interfaces),
105
+ ],
106
+ decoders: [decoder, ...formatted.flatMap((entry) => entry.shape.decoders)],
107
+ };
108
+ }
109
+
110
+ function formatFieldRead(field: PayloadField, shape: FormattedShape, index: number): string[] {
111
+ const read = `${formatLiteral(field.name)} in value ? ${formatPropertyAccess('value', field.name)} : undefined`;
112
+
113
+ if (!shape.canFail) {
114
+ return [` const field${index} = ${read};`, ''];
115
+ }
116
+
117
+ if (field.isOptional) {
118
+ return [
119
+ ` const raw${index} = ${read};`,
120
+ ` const field${index} = raw${index} === undefined ? undefined : ${shape.decode(`raw${index}`)};`,
121
+ '',
122
+ ` if (raw${index} !== undefined && field${index} === undefined) {`,
123
+ ' return undefined;',
124
+ ' }',
125
+ '',
126
+ ];
127
+ }
128
+
129
+ return [
130
+ ` const raw${index} = ${read};`,
131
+ ` const field${index} = ${shape.decode(`raw${index}`)};`,
132
+ '',
133
+ ` if (field${index} === undefined) {`,
134
+ ' return undefined;',
135
+ ' }',
136
+ '',
137
+ ];
138
+ }
139
+
140
+ // An index loop: `for...of` over a parsed list makes scriptc link its dynamic invoke object.
141
+ function formatArrayShape(element: PayloadShape, name: string): FormattedShape {
142
+ const item = formatPayloadShape(element, `${name}Item`);
143
+ const type = item.type.includes(' | ') ? `(${item.type})[]` : `${item.type}[]`;
144
+
145
+ const push = item.canFail
146
+ ? [
147
+ ` const item = ${item.decode('element')};`,
148
+ '',
149
+ ' if (item === undefined) {',
150
+ ' return undefined;',
151
+ ' }',
152
+ '',
153
+ ' items.push(item);',
154
+ ]
155
+ : [' items.push(element);'];
156
+
157
+ const decoder = [
158
+ `function decode${name}(value: unknown): ${type} | undefined {`,
159
+ ' if (!Array.isArray(value)) {',
160
+ ' return undefined;',
161
+ ' }',
162
+ '',
163
+ ` const items: ${type} = [];`,
164
+ '',
165
+ ' for (let index = 0; index < value.length; index += 1) {',
166
+ ' const element: unknown = value[index];',
167
+ ...push,
168
+ ' }',
169
+ '',
170
+ ' return items;',
171
+ '}',
172
+ ].join('\n');
173
+
174
+ return {
175
+ type,
176
+ decode: (identifier: string) => `decode${name}(${identifier})`,
177
+ canFail: true,
178
+ interfaces: item.interfaces,
179
+ decoders: [decoder, ...item.decoders],
180
+ };
181
+ }
@@ -0,0 +1,6 @@
1
+ import { formatLiteral } from './format-literal.ts';
2
+ import { isIdentifier } from './is-identifier.ts';
3
+
4
+ export function formatPropertyAccess(object: string, name: string): string {
5
+ return isIdentifier(name) ? `${object}.${name}` : `${object}[${formatLiteral(name)}]`;
6
+ }
@@ -0,0 +1,6 @@
1
+ import { formatLiteral } from './format-literal.ts';
2
+ import { isIdentifier } from './is-identifier.ts';
3
+
4
+ export function formatPropertyName(name: string): string {
5
+ return isIdentifier(name) ? name : formatLiteral(name);
6
+ }