lambder 14.1.1 → 15.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 (158) hide show
  1. package/CHANGELOG.md +407 -0
  2. package/README.md +14 -17
  3. package/dist/api/LambderApiDeclarations.d.ts +11 -5
  4. package/dist/api/LambderApiDefinition.d.ts +2 -1
  5. package/dist/api/LambderApiEnvelope.d.ts +2 -2
  6. package/dist/api/LambderApiEnvelope.js +8 -2
  7. package/dist/api/LambderApiGuards.d.ts +44 -5
  8. package/dist/api/LambderApiGuards.js +27 -0
  9. package/dist/api/LambderApiIdempotency.d.ts +14 -7
  10. package/dist/api/LambderApiIdempotency.js +39 -27
  11. package/dist/api/LambderApiPipeline.d.ts +25 -11
  12. package/dist/api/LambderApiPipeline.js +34 -17
  13. package/dist/api/LambderApiRateLimits.d.ts +28 -21
  14. package/dist/api/LambderApiRateLimits.js +55 -35
  15. package/dist/api/LambderApiRefusals.d.ts +10 -4
  16. package/dist/api/LambderApiRefusals.js +31 -5
  17. package/dist/api/LambderApiRequest.d.ts +21 -1
  18. package/dist/api/LambderApiRequest.js +31 -2
  19. package/dist/build/checkApiRefusals.d.ts +7 -2
  20. package/dist/build/checkApiRefusals.js +56 -29
  21. package/dist/build/contractPrinterProcess.d.ts +11 -0
  22. package/dist/build/contractPrinterProcess.js +17 -0
  23. package/dist/build/generateApiFiles.d.ts +26 -5
  24. package/dist/build/generateApiFiles.js +68 -7
  25. package/dist/build/writeApiSchemas.d.ts +109 -0
  26. package/dist/build/writeApiSchemas.js +308 -0
  27. package/dist/build.d.ts +3 -1
  28. package/dist/build.js +2 -0
  29. package/dist/client/LambderCaller.d.ts +25 -11
  30. package/dist/client/LambderCaller.js +57 -49
  31. package/dist/client/LambderUploadRunner.d.ts +55 -27
  32. package/dist/client/LambderUploadRunner.js +89 -34
  33. package/dist/client.d.ts +6 -6
  34. package/dist/client.js +2 -2
  35. package/dist/core/Lambder.d.ts +31 -21
  36. package/dist/core/Lambder.js +79 -23
  37. package/dist/core/LambderCallSummary.d.ts +1 -1
  38. package/dist/core/LambderContext.d.ts +59 -7
  39. package/dist/core/LambderContext.js +31 -15
  40. package/dist/core/LambderCrashHandling.d.ts +5 -0
  41. package/dist/core/LambderCrashHandling.js +11 -1
  42. package/dist/core/LambderCreateOptions.d.ts +28 -17
  43. package/dist/core/LambderCreateOptions.js +14 -7
  44. package/dist/core/LambderResolver.d.ts +3 -2
  45. package/dist/core/LambderResponseBuilder.d.ts +20 -9
  46. package/dist/core/LambderResponseBuilder.js +39 -8
  47. package/dist/core/LambderTemplatingEngine.d.ts +24 -6
  48. package/dist/core/LambderTemplatingEngine.js +23 -3
  49. package/dist/index.d.ts +15 -12
  50. package/dist/index.js +7 -5
  51. package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
  52. package/dist/invoke/LambderInvokeCaller.js +47 -9
  53. package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
  54. package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
  55. package/dist/mock/LambderMockAdapterDoor.js +10 -0
  56. package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
  57. package/dist/mock/LambderMockApiSchemas.js +150 -0
  58. package/dist/mock/LambderMockApp.d.ts +56 -48
  59. package/dist/mock/LambderMockApp.js +107 -62
  60. package/dist/mock/LambderMockBrowserCookies.js +18 -5
  61. package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
  62. package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
  63. package/dist/mock/LambderMockFailureInjector.js +12 -8
  64. package/dist/mock/LambderMockTypes.d.ts +8 -4
  65. package/dist/mock/lambderMockInvokeTransport.js +2 -9
  66. package/dist/mock/lambderMockMswHandler.d.ts +19 -13
  67. package/dist/mock/lambderMockMswHandler.js +25 -22
  68. package/dist/mock.d.ts +1 -0
  69. package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
  70. package/dist/secrets/LambderOneShotSecrets.js +6 -36
  71. package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
  72. package/dist/secrets/LambderPasswordHasher.js +168 -0
  73. package/dist/session/LambderSessionController.d.ts +37 -4
  74. package/dist/session/LambderSessionController.js +36 -6
  75. package/dist/session/LambderSessionManager.d.ts +33 -3
  76. package/dist/session/LambderSessionManager.js +55 -16
  77. package/dist/shared/LambderHtml.d.ts +23 -4
  78. package/dist/shared/LambderHtml.js +17 -5
  79. package/dist/shared/LambderI18n.d.ts +106 -16
  80. package/dist/shared/LambderI18n.js +259 -74
  81. package/dist/shared/contracts/LambderCache.d.ts +14 -1
  82. package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
  83. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  84. package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
  85. package/dist/shared/util/LambderBackoffTimer.js +40 -12
  86. package/dist/shared/util/LambderCallAbort.d.ts +22 -6
  87. package/dist/shared/util/LambderCallAbort.js +37 -9
  88. package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
  89. package/dist/shared/util/LambderErrorReportMark.js +22 -0
  90. package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
  91. package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
  92. package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
  93. package/dist/shared/util/LambderSignedClaims.js +54 -0
  94. package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
  95. package/dist/shared/util/LambderSwappableInstances.js +54 -0
  96. package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
  97. package/dist/shared/util/LambderTestingDoors.js +6 -2
  98. package/dist/shared/util/LambderTextDigest.d.ts +5 -5
  99. package/dist/shared/util/LambderTextDigest.js +5 -5
  100. package/dist/shared/wire/LambderApiContract.d.ts +1 -1
  101. package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
  102. package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
  103. package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
  104. package/dist/shared/wire/LambderApiOutcome.d.ts +21 -12
  105. package/dist/shared/wire/LambderApiOutcome.js +1 -1
  106. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
  107. package/dist/shared/wire/LambderApiRefusal.js +3 -3
  108. package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
  109. package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
  110. package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
  111. package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
  112. package/dist/shared/wire/LambderCallOutcome.js +3 -2
  113. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
  114. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
  115. package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
  116. package/dist/shared/wire/LambderInvokeApiId.js +3 -3
  117. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
  118. package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
  119. package/dist/stores/LambderCacheValues.d.ts +2 -5
  120. package/dist/stores/LambderDdbCache.d.ts +15 -9
  121. package/dist/stores/LambderDdbCache.js +25 -6
  122. package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
  123. package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
  124. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
  125. package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
  126. package/dist/stores/LambderDdbSdk.d.ts +59 -29
  127. package/dist/stores/LambderDdbSdk.js +116 -44
  128. package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
  129. package/dist/stores/LambderDdbSessionStore.js +133 -68
  130. package/dist/stores/LambderMemoryCache.d.ts +2 -2
  131. package/dist/stores/LambderS3FileSource.d.ts +3 -2
  132. package/dist/stores/LambderS3FileSource.js +11 -13
  133. package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
  134. package/dist/stores/LambderS3UploadBucket.js +23 -0
  135. package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
  136. package/dist/stores/LambderStoredText.d.ts +26 -0
  137. package/dist/stores/LambderStoredText.js +25 -0
  138. package/dist/testing/LambderConformanceRunner.d.ts +9 -2
  139. package/dist/testing/LambderConformanceRunner.js +9 -2
  140. package/dist/testing/LambderTestApp.d.ts +65 -7
  141. package/dist/testing/LambderTestApp.js +101 -6
  142. package/dist/testing/LambderTestVisitor.d.ts +43 -12
  143. package/dist/testing/LambderTestVisitor.js +35 -11
  144. package/dist/testing/bootLambdaPackage.d.ts +105 -0
  145. package/dist/testing/bootLambdaPackage.js +265 -0
  146. package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
  147. package/dist/testing/lambdaPackageBootProcess.js +130 -0
  148. package/dist/testing/lambderCacheConformance.d.ts +26 -0
  149. package/dist/testing/lambderCacheConformance.js +287 -0
  150. package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
  151. package/dist/testing/lambderCacheStorageConformance.js +159 -0
  152. package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
  153. package/dist/testing/lambderSessionStoreConformance.js +3 -3
  154. package/dist/testing.d.ts +7 -1
  155. package/dist/testing.js +5 -0
  156. package/package.json +1 -6
  157. package/dist/shared/util/boundKeyField.d.ts +0 -20
  158. package/dist/shared/util/boundKeyField.js +0 -34
@@ -1,8 +1,9 @@
1
1
  import type { LambderModuleLocation } from "./moduleLocation.js";
2
- import { type LambderApiContractFileOptions } from "./writeApiContract.js";
2
+ import type { LambderApiContractFileOptions } from "./writeApiContract.js";
3
3
  import { type LambderApiSignatureFileOptions } from "./writeApiSignatures.js";
4
4
  import { type LambderApiOptionsFileOptions } from "./writeApiOptions.js";
5
5
  import { type LambderApiGuardParamsFileOptions } from "./writeApiGuardParams.js";
6
+ import { type LambderApiSchemasFileOptions } from "./writeApiSchemas.js";
6
7
  /** What a file's writer takes beyond where the instance is, which the app gives once for all its files. */
7
8
  type LambderAppFile<TOptions> = Omit<TOptions, "module" | "exportName" | "tsconfig" | "check">;
8
9
  /** One app: where its instance is, and which files it is written to. Each file is optional. */
@@ -21,21 +22,43 @@ export type LambderApiFilesApp = {
21
22
  options?: LambderAppFile<LambderApiOptionsFileOptions>;
22
23
  /** One file per guard whose parameters a browser needs (writeApiGuardParams). */
23
24
  guardParams?: readonly LambderAppFile<LambderApiGuardParamsFileOptions>[];
25
+ /** The input and output schemas as JSON Schema, for a mock to validate against in development (writeApiSchemas). */
26
+ schemas?: LambderAppFile<LambderApiSchemasFileOptions>;
24
27
  };
25
28
  /** The files generateApiFiles writes: every app's, by a name the output lines use. */
26
29
  export type LambderApiFilesConfig = {
27
30
  apps: Record<string, LambderApiFilesApp>;
28
31
  };
32
+ /** How generateApiFiles runs. */
33
+ export type LambderApiFilesOptions = {
34
+ /** Write nothing, and answer whether each file on disk is what its writer produces now. Default: false. */
35
+ check?: boolean;
36
+ /**
37
+ * The heap, in megabytes, of the Node process each contract is printed
38
+ * in (its --max-old-space-size). Reading a contract compiles the server,
39
+ * twice for a write that changes the file, which in a large app takes
40
+ * gigabytes; the process is started for that alone, so the heap is the
41
+ * printing's whatever the script's own is. Default: 8192.
42
+ */
43
+ contractHeapMegabytes?: number;
44
+ };
29
45
  export type LambderApiFilesResult = {
30
46
  /** False when a check found a file stale, or a writer failed. */
31
47
  ok: boolean;
32
48
  /** What happened, per app and file, as lines to print. */
33
49
  lines: string[];
34
50
  };
51
+ /**
52
+ * How the contract printing process reports: one line on stdout starting
53
+ * with this, then its report as JSON. The line, not the exit status, is the
54
+ * answer: a process that dies before it (out of heap, say) answered nothing.
55
+ */
56
+ export declare const CONTRACT_REPORT_PREFIX = "lambder-api-contract-printed:";
35
57
  /**
36
58
  * Writes every file the config names, or with `check` writes nothing and
37
59
  * answers whether each one on disk is current. Paths are relative to the
38
- * working directory, as each writer takes them.
60
+ * working directory, as each writer takes them. Each contract is printed in
61
+ * a Node process of its own, with a heap of `contractHeapMegabytes`.
39
62
  *
40
63
  * ```ts
41
64
  * import { generateApiFiles } from "lambder/build";
@@ -54,7 +77,5 @@ export type LambderApiFilesResult = {
54
77
  * process.exit(result.ok ? 0 : 1);
55
78
  * ```
56
79
  */
57
- export declare const generateApiFiles: (config: LambderApiFilesConfig, options?: {
58
- check?: boolean;
59
- }) => Promise<LambderApiFilesResult>;
80
+ export declare const generateApiFiles: (config: LambderApiFilesConfig, options?: LambderApiFilesOptions) => Promise<LambderApiFilesResult>;
60
81
  export {};
@@ -1,9 +1,22 @@
1
- import { writeApiContract } from "./writeApiContract.js";
1
+ import { spawn } from "child_process";
2
+ import { fileURLToPath } from "url";
2
3
  import { writeApiSignatures } from "./writeApiSignatures.js";
3
4
  import { writeApiOptions } from "./writeApiOptions.js";
4
5
  import { writeApiGuardParams } from "./writeApiGuardParams.js";
5
- const APP_KEYS = ["module", "exportName", "tsconfig", "contract", "signatures", "options", "guardParams"];
6
- const FILE_KINDS = ["contract", "signatures", "options", "guardParams"];
6
+ import { writeApiSchemas } from "./writeApiSchemas.js";
7
+ const APP_KEYS = ["module", "exportName", "tsconfig", "contract", "signatures", "options", "guardParams", "schemas"];
8
+ const FILE_KINDS = ["contract", "signatures", "options", "guardParams", "schemas"];
9
+ const DEFAULT_CONTRACT_HEAP_MEGABYTES = 8192;
10
+ /**
11
+ * How the contract printing process reports: one line on stdout starting
12
+ * with this, then its report as JSON. The line, not the exit status, is the
13
+ * answer: a process that dies before it (out of heap, say) answered nothing.
14
+ */
15
+ export const CONTRACT_REPORT_PREFIX = "lambder-api-contract-printed:";
16
+ /** The contract printing process's entry, beside this module in the build. */
17
+ const CONTRACT_PRINTER_ENTRY = new URL("./contractPrinterProcess.js", import.meta.url);
18
+ /** How much of a process's error output a failure repeats. */
19
+ const STDERR_TAIL_LINES = 20;
7
20
  /** A config that names apps, each with its module and at least one file, and no key the generator does not read. */
8
21
  function assertConfig(config) {
9
22
  const apps = config?.apps;
@@ -19,7 +32,7 @@ function assertConfig(config) {
19
32
  if (!app.module)
20
33
  throw new Error(`generateApiFiles: the app "${name}" names no module to read its instance from.`);
21
34
  if (!FILE_KINDS.some((kind) => app[kind] !== undefined)) {
22
- throw new Error(`generateApiFiles: the app "${name}" is written to no file; give it a contract, signatures, options or guardParams.`);
35
+ throw new Error(`generateApiFiles: the app "${name}" is written to no file; give it a contract, signatures, options, guardParams or schemas.`);
23
36
  }
24
37
  }
25
38
  }
@@ -35,10 +48,51 @@ const settle = async (label, write) => {
35
48
  return { ok: false, lines: [`✗ ${label} failed: ${reasons.join(": ") || String(err)}`] };
36
49
  }
37
50
  };
51
+ /**
52
+ * writeApiContract in a Node process of its own, started with the heap given
53
+ * and none of this process's flags: the compiler loads no module of the app,
54
+ * so the script's loader is not needed there, and the heap the script runs
55
+ * with is not the printing's. Its result comes back as writeApiContract
56
+ * answers it, and what it throws is thrown here with the same messages, down
57
+ * the chain of causes, so a run reads the same as one in this process.
58
+ */
59
+ const writeApiContractInOwnProcess = async (label, options, heapMegabytes) => {
60
+ const request = { ...options, module: options.module instanceof URL ? options.module.href : options.module };
61
+ const child = spawn(process.execPath, [`--max-old-space-size=${heapMegabytes}`, fileURLToPath(CONTRACT_PRINTER_ENTRY), JSON.stringify(request)], { stdio: ["ignore", "pipe", "pipe"] });
62
+ let stdout = "";
63
+ let stderr = "";
64
+ child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
65
+ child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
66
+ const ended = await new Promise((resolveEnd) => {
67
+ child.once("error", (error) => resolveEnd({ error }));
68
+ child.once("close", (status, signal) => resolveEnd({ status, signal }));
69
+ });
70
+ if ("error" in ended)
71
+ return { ok: false, lines: [`✗ ${label} failed: the process to print it in could not start: ${ended.error.message}`] };
72
+ const reportLine = stdout.split("\n").findLast((line) => line.startsWith(CONTRACT_REPORT_PREFIX));
73
+ if (reportLine === undefined) {
74
+ if (/JavaScript heap out of memory/i.test(stderr)) {
75
+ return { ok: false, lines: [`✗ ${label} failed: the process printing it ran out of its ${heapMegabytes} MB heap; raise contractHeapMegabytes`] };
76
+ }
77
+ const ending = ended.signal ? `signal ${ended.signal}` : `exit status ${ended.status ?? "none"}`;
78
+ return {
79
+ ok: false,
80
+ lines: [
81
+ `✗ ${label} failed: the process printing it ended without an answer (${ending})`,
82
+ ...stderr.trim().split("\n").filter(Boolean).slice(-STDERR_TAIL_LINES).map((line) => ` ${line}`),
83
+ ],
84
+ };
85
+ }
86
+ const report = JSON.parse(reportLine.slice(CONTRACT_REPORT_PREFIX.length));
87
+ if ("thrown" in report)
88
+ throw report.thrown.reduceRight((cause, message) => new Error(message, cause ? { cause } : undefined), undefined);
89
+ return report.result;
90
+ };
38
91
  /**
39
92
  * Writes every file the config names, or with `check` writes nothing and
40
93
  * answers whether each one on disk is current. Paths are relative to the
41
- * working directory, as each writer takes them.
94
+ * working directory, as each writer takes them. Each contract is printed in
95
+ * a Node process of its own, with a heap of `contractHeapMegabytes`.
42
96
  *
43
97
  * ```ts
44
98
  * import { generateApiFiles } from "lambder/build";
@@ -60,6 +114,10 @@ const settle = async (label, write) => {
60
114
  export const generateApiFiles = async (config, options = {}) => {
61
115
  assertConfig(config);
62
116
  const check = options.check ?? false;
117
+ const contractHeapMegabytes = options.contractHeapMegabytes ?? DEFAULT_CONTRACT_HEAP_MEGABYTES;
118
+ if (!Number.isInteger(contractHeapMegabytes) || contractHeapMegabytes <= 0) {
119
+ throw new Error(`generateApiFiles: contractHeapMegabytes is ${String(contractHeapMegabytes)}; give the heap of the process each contract is printed in as a whole number of megabytes, such as ${DEFAULT_CONTRACT_HEAP_MEGABYTES}.`);
120
+ }
63
121
  const apps = Object.entries(config.apps);
64
122
  const runs = [];
65
123
  // The contracts first: they compile the app's sources and run none of
@@ -67,10 +125,11 @@ export const generateApiFiles = async (config, options = {}) => {
67
125
  for (const [name, app] of apps) {
68
126
  if (!app.contract)
69
127
  continue;
70
- runs.push(await settle(`${name}: the contract`, () => writeApiContract({ ...app.contract, module: app.module, exportName: app.exportName, tsconfig: app.tsconfig, check })));
128
+ const label = `${name}: the contract`;
129
+ runs.push(await settle(label, () => writeApiContractInOwnProcess(label, { ...app.contract, module: app.module, exportName: app.exportName, tsconfig: app.tsconfig, check }, contractHeapMegabytes)));
71
130
  }
72
131
  // Then what is read off the instance itself, which importing the module
73
- // builds once for all three.
132
+ // builds once for all of them.
74
133
  for (const [name, app] of apps) {
75
134
  const instance = { module: app.module, exportName: app.exportName, check };
76
135
  if (app.signatures)
@@ -80,6 +139,8 @@ export const generateApiFiles = async (config, options = {}) => {
80
139
  for (const guardFile of app.guardParams ?? []) {
81
140
  runs.push(await settle(`${name}: the "${guardFile.guard}" guard parameters`, () => writeApiGuardParams({ ...guardFile, ...instance })));
82
141
  }
142
+ if (app.schemas)
143
+ runs.push(await settle(`${name}: the schemas`, () => writeApiSchemas({ ...app.schemas, ...instance })));
83
144
  }
84
145
  return { ok: runs.every((run) => run.ok), lines: runs.flatMap((run) => run.lines) };
85
146
  };
@@ -0,0 +1,109 @@
1
+ import { z } from "zod";
2
+ import { type LambderModuleLocation } from "./moduleLocation.js";
3
+ import { type LambderNameChanges } from "./generatedTables.js";
4
+ /** The zod schemas of one API, as a source reports them. */
5
+ type LambderApiSchemaPair = {
6
+ input: z.ZodType;
7
+ output: z.ZodType;
8
+ };
9
+ /** What writeApiSchemas reads the schemas from: a Lambder instance, or anything else that reports them the same way. */
10
+ export type LambderApiSchemasSource = {
11
+ /** Every API's input and output schemas by name. Asynchronous, since a Lambder instance loads its lazy groups before it can report every endpoint. */
12
+ apiSchemaEntries(): Promise<Record<string, LambderApiSchemaPair>> | Record<string, LambderApiSchemaPair>;
13
+ };
14
+ /**
15
+ * One place where an API's schema does something its written JSON Schema
16
+ * cannot hold, so the mock does not do it either:
17
+ *
18
+ * - `refinement`: a `.refine()`, `.superRefine()` or `.check()` check, or a
19
+ * `z.custom()` schema (written as one that takes anything). The mock does
20
+ * not run it, so it lets through what the refinement refuses.
21
+ * - `transform`: a `.transform()` (in the output form written as a schema
22
+ * that takes anything), a `z.preprocess()`, a codec, a rewrite such as
23
+ * `.trim()` or `.toLowerCase()`, a `z.coerce` schema, a `.catch()`. The mock
24
+ * does not apply it: its handler reads the value as posted, and a coerced or
25
+ * caught input the server takes, the mock refuses.
26
+ * - `pipe`: the second schema of a `.pipe()`. The input form carries the
27
+ * first, so the mock does not check the second.
28
+ * - `default`: a default a function computes on each parse, which the file
29
+ * cannot hold, so it is left out and the mock fills nothing there.
30
+ * - `check`: a string check JSON Schema cannot state as zod runs it: a regex
31
+ * with flags (`/i`, `/u`), whose pattern means something else without
32
+ * them, or a URL's `protocol` or `hostname` rule (`z.httpUrl()` among
33
+ * them). The mock does not run it, so it lets through what the check
34
+ * refuses.
35
+ */
36
+ export type LambderApiSchemaLoss = {
37
+ /** The API whose schema it is. */
38
+ api: string;
39
+ /** Which of its schemas: the input a client posts, or the output it receives. */
40
+ direction: "input" | "output";
41
+ /** Where, as a JSON pointer into the written schema: `#/properties/name`, or `#` for the whole schema. */
42
+ path: string;
43
+ kind: "refinement" | "transform" | "pipe" | "default" | "check";
44
+ };
45
+ export type LambderApiSchemasFileOptions = {
46
+ /**
47
+ * The module that exports the instance, usually the server's entry: a
48
+ * path relative to the working directory, or a file URL. It is imported
49
+ * in this process, so a TypeScript module needs the process's loader
50
+ * (`tsx`, `node --import tsx`), as the generator script itself does.
51
+ */
52
+ module: LambderModuleLocation;
53
+ /** The export that holds the instance. Default: "default", the module's default export. */
54
+ exportName?: string;
55
+ /** The TypeScript module to write, exporting `apiSchemas`. Relative to the working directory. */
56
+ file: string;
57
+ /** Write nothing, and answer whether the file on disk holds what the instance reports now. Default: false. */
58
+ check?: boolean;
59
+ /** The comment the file opens with, one `//` line per line. It should name what generates the file and say it is for development only. Default: a note naming writeApiSchemas(). */
60
+ header?: string;
61
+ /** End the generated statement with a semicolon. Default: true. The table itself is JSON, double quotes included, whatever the project's style: that is what lets a check read it back. */
62
+ semicolons?: boolean;
63
+ };
64
+ export type LambderApiSchemasFileResult = {
65
+ /** False when a check found the file stale or unreadable. Losses do not make it false: they are what the file cannot hold, however current it is. */
66
+ ok: boolean;
67
+ /** The absolute path. */
68
+ file: string;
69
+ /** True when the file was (re)written; a file that already holds this table is left untouched, however it is formatted. */
70
+ written: boolean;
71
+ /** How many APIs the table holds. */
72
+ count: number;
73
+ /** Which APIs moved against the file that was on disk. */
74
+ changes: LambderNameChanges;
75
+ /** Every place a schema does what its written form cannot hold, sorted by API, direction and place. */
76
+ losses: LambderApiSchemaLoss[];
77
+ /** What happened, as lines to print: a summary, one line per API that moved, then one line per loss. */
78
+ lines: string[];
79
+ };
80
+ /**
81
+ * Writes every API's input and output schemas as JSON Schema to a module of
82
+ * plain data, or checks the one on disk, and says which APIs moved and what
83
+ * their schemas do that the file cannot hold.
84
+ *
85
+ * Call it from a generator script beside writeApiOptions, writing to where
86
+ * the mock's setup reads it:
87
+ *
88
+ * ```ts
89
+ * import { writeApiSchemas } from "lambder/build";
90
+ *
91
+ * const result = await writeApiSchemas({
92
+ * module: "server/src/index.ts", // export const lambder = initLambder()...
93
+ * exportName: "lambder",
94
+ * file: "web/src/mock/apiSchemas.generated.ts",
95
+ * check: process.argv.includes("--check"),
96
+ * });
97
+ * console.log(result.lines.join("\n"));
98
+ * process.exit(result.ok ? 0 : 1);
99
+ * ```
100
+ *
101
+ * The table is compared as the data the file holds, so a checkout that
102
+ * rewrote its line endings or a formatter that re-indented it is neither
103
+ * stale nor rewritten. The same instance writes the same file on every run.
104
+ * A module that does not load, an export that is not an instance, or a
105
+ * schema holding what JSON Schema cannot represent (other than a refinement
106
+ * or a transform, which are listed) throws.
107
+ */
108
+ export declare const writeApiSchemas: (options: LambderApiSchemasFileOptions) => Promise<LambderApiSchemasFileResult>;
109
+ export {};
@@ -0,0 +1,308 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { z } from "zod";
4
+ import { canonicalJson } from "../shared/util/canonicalJson.js";
5
+ import { LAMBDER_STRIP_UNKNOWN_KEYS } from "../shared/wire/LambderApiSchemaEntries.js";
6
+ import { moduleUrlOf } from "./moduleLocation.js";
7
+ import { headerLinesOf, nameChangesOf, settleGeneratedFile, tableStatementLines } from "./generatedTables.js";
8
+ /*
9
+ * Every API's input and output schemas as JSON Schema, in a generated module
10
+ * of plain data a mock validates its calls against: so in development an
11
+ * endpoint refuses what the server refuses (a length, a format, a range), a
12
+ * handler reads the payload the server's parse leaves, and an answer goes out
13
+ * stripped and defaulted as the server sends it.
14
+ *
15
+ * The input is written in its input form, what the server's parse takes from
16
+ * a client. The output is written in its output form, because a mock handler
17
+ * returns what the client receives (the contract types it so), where a server
18
+ * handler returns what the schema parses from: the output form is what the
19
+ * mock has to check and strip.
20
+ *
21
+ * z.toJSONSchema writes the schemas, and three things are this writer's own.
22
+ * A zod object drops the keys it does not declare, which JSON Schema cannot
23
+ * say, so each such object is marked (LAMBDER_STRIP_UNKNOWN_KEYS) and the mock
24
+ * drops them again. What zod cannot represent at all (a Date, a bigint, a Map)
25
+ * throws, naming the API and the place, since a schema the mock reads
26
+ * differently from the server is the drift this file exists to end. And what
27
+ * zod represents only in part is listed as a loss, in the result and its
28
+ * lines: a refinement it leaves out, a transform it does not run, a pipe whose
29
+ * second schema the input form does not carry, a default a function computes
30
+ * on each parse, a check it writes without what zod runs it with (a regex's
31
+ * flags, a URL's protocol rule). Where zod writes a check the mock would read
32
+ * back as another (a guid as a strict uuid, a custom email pattern as zod's
33
+ * own), the writer leaves out the misleading part instead.
34
+ *
35
+ * The file names every endpoint and every field each takes and gives, so it
36
+ * is for development alone: imported by the mock's setup, which a production
37
+ * build never loads.
38
+ */
39
+ const DEFAULT_HEADER = [
40
+ "Generated by writeApiSchemas() from lambder/build. Do not edit.",
41
+ "",
42
+ "Every API's input schema, in the form a client posts, and output schema,",
43
+ "in the form a client receives, as JSON Schema (draft 2020-12): what the",
44
+ "mock validates its calls against, as the server does. Development only:",
45
+ "it names every endpoint, so only the mock's setup imports it.",
46
+ ].join("\n");
47
+ const TABLE_NAME = "apiSchemas";
48
+ /** What each kind of loss says in the lines. */
49
+ const LOSS_WORDING = {
50
+ refinement: "a refinement, which the mock does not run",
51
+ transform: "a transform, which the mock does not apply",
52
+ pipe: "a pipe, whose second schema the mock does not check",
53
+ default: "a default computed on each parse, which the mock does not fill",
54
+ check: "a check written without its flags or URL rule, which the mock does not run",
55
+ };
56
+ const defOf = (schema) => schema._zod.def;
57
+ /**
58
+ * Regex flags that change what a pattern matches. A JSON Schema pattern is
59
+ * the source alone, so the mock compiles it without them; `g` and `d` change
60
+ * nothing about a test zod runs from the start of the value.
61
+ */
62
+ const MEANING_FLAGS = /[imsuvy]/;
63
+ /** zod's own email pattern, which the mock rebuilds a written `format: "email"` with. */
64
+ const DEFAULT_EMAIL_PATTERN = z.email()._zod.def.pattern;
65
+ /** A JSON pointer into the written schema, for a path as z.toJSONSchema reports it. */
66
+ const pointerOf = (path) => ["#", ...path.map((segment) => String(segment).replace(/~/g, "~0").replace(/\//g, "~1"))].join("/");
67
+ /** A value as the JSON it is written as, compared by data: what the file holds of a default. */
68
+ const asWritten = (value) => {
69
+ const text = JSON.stringify(value);
70
+ return text === undefined ? undefined : canonicalJson(JSON.parse(text));
71
+ };
72
+ /**
73
+ * Whether the default zod wrote is computed on each parse: read once more,
74
+ * it answers otherwise. A default given as a value is copied on each read and
75
+ * answers the same; one a function computes (an id, a timestamp) would put a
76
+ * different value in the file on every run, and a value the server never
77
+ * repeats in every mock call.
78
+ */
79
+ const computedOnEachParse = (def, written) => {
80
+ let again;
81
+ try {
82
+ again = def.type === "catch" ? def.catchValue?.(undefined) : def.defaultValue;
83
+ }
84
+ catch {
85
+ return true;
86
+ }
87
+ return asWritten(again) !== asWritten(written);
88
+ };
89
+ /**
90
+ * Whether zod cannot represent a schema because of what it does to a value
91
+ * rather than what it takes: a transform, a z.custom(), a catch whose value is
92
+ * computed from the failed parse, a coercion (z.coerce.date() parses to a
93
+ * Date, which JSON Schema has no type for). Each is written as a schema that
94
+ * takes anything and listed as a loss; anything else zod cannot represent
95
+ * throws.
96
+ */
97
+ const isNonCarryable = (def) => def.type === "transform" || def.type === "custom" || def.type === "catch" || def.coerce === true;
98
+ /** One API's schema in the direction given, as plain JSON Schema, with what it does not carry added to `losses`. */
99
+ const jsonSchemaOf = (api, direction, schema, losses) => {
100
+ if (typeof schema?._zod !== "object") {
101
+ throw new Error(`writeApiSchemas: API "${api}" reports no zod ${direction} schema.`);
102
+ }
103
+ const lose = (path, kind) => {
104
+ const loss = { api, direction, path: pointerOf(path), kind };
105
+ if (!losses.some((known) => known.api === loss.api && known.direction === loss.direction && known.path === loss.path && known.kind === loss.kind))
106
+ losses.push(loss);
107
+ };
108
+ const written = z.toJSONSchema(schema, {
109
+ io: direction,
110
+ unrepresentable: ({ zodSchema, path, message }) => {
111
+ const def = defOf(zodSchema);
112
+ if (!isNonCarryable(def)) {
113
+ throw new Error(`writeApiSchemas: the ${direction} schema of API "${api}" holds what JSON Schema cannot represent, at ${pointerOf(path)}: ${message}. Declare it as the value it is on the wire (a z.date() as z.iso.datetime(), a bigint as a string).`);
114
+ }
115
+ lose(path, def.type === "custom" ? "refinement" : "transform");
116
+ return "any";
117
+ },
118
+ override: ({ zodSchema, jsonSchema, path }) => {
119
+ const def = defOf(zodSchema);
120
+ const json = jsonSchema;
121
+ if (def.type === "object" && def.catchall === undefined) {
122
+ // zod writes nothing in the input form and false in the
123
+ // output form, where the parse accepts the key and drops it.
124
+ delete json.additionalProperties;
125
+ json[LAMBDER_STRIP_UNKNOWN_KEYS] = true;
126
+ }
127
+ for (const check of def.checks ?? []) {
128
+ if (check._zod.def.check === "custom")
129
+ lose(path, "refinement");
130
+ else if (check._zod.def.check === "overwrite")
131
+ lose(path, "transform");
132
+ }
133
+ if (def.type === "string") {
134
+ // The mock rebuilds a written format by its name, with zod's
135
+ // defaults (a guid as a strict uuid, an email under zod's
136
+ // own pattern), and the pattern written beside it as given.
137
+ const formatChecks = [def, ...(def.checks ?? []).map((check) => check._zod.def)].filter((check) => check.check === "string_format");
138
+ for (const check of formatChecks) {
139
+ // The pattern written beside these is the check exactly;
140
+ // the format would read back stricter than the server.
141
+ if (check.format === "guid" || (check.format === "email" && (check.pattern?.source !== DEFAULT_EMAIL_PATTERN.source || check.pattern.flags !== DEFAULT_EMAIL_PATTERN.flags)))
142
+ delete json.format;
143
+ if (check.format === "url") {
144
+ if (check.protocol || check.hostname)
145
+ lose(path, "check");
146
+ if (check.normalize)
147
+ lose(path, "transform");
148
+ }
149
+ if (check.pattern && MEANING_FLAGS.test(check.pattern.flags)) {
150
+ // Without its flags the pattern refuses what the
151
+ // server takes, so it is not written. A format the
152
+ // mock rebuilds by name (z.emoji()) still runs it.
153
+ const source = check.pattern.source;
154
+ if (json.pattern === source)
155
+ delete json.pattern;
156
+ if (Array.isArray(json.allOf)) {
157
+ json.allOf = json.allOf.filter((entry) => entry.pattern !== source);
158
+ if (!json.allOf.length)
159
+ delete json.allOf;
160
+ }
161
+ if (check.format === "regex" || !("format" in json))
162
+ lose(path, "check");
163
+ }
164
+ }
165
+ // A string's second pattern goes in allOf, and the mock reads
166
+ // an allOf entry's pattern only when the entry says string.
167
+ for (const entry of Array.isArray(json.allOf) ? json.allOf : []) {
168
+ if ("pattern" in entry)
169
+ entry.type = "string";
170
+ }
171
+ }
172
+ if (def.type === "custom")
173
+ lose(path, "refinement");
174
+ if (def.type === "transform")
175
+ lose(path, "transform");
176
+ // A coercion, a catch and a pipe change only what an input may
177
+ // be: a mock handler returns its output already in the form the
178
+ // client receives, which the output form checks in full.
179
+ if (direction === "input") {
180
+ if (def.coerce === true || def.type === "catch")
181
+ lose(path, "transform");
182
+ if (def.type === "pipe") {
183
+ const transforms = def.in?._zod.def.type === "transform" || def.out?._zod.def.type === "transform" || def.transform !== undefined;
184
+ lose(path, transforms ? "transform" : "pipe");
185
+ }
186
+ }
187
+ if ((def.type === "default" || def.type === "prefault" || def.type === "catch") && "default" in json && computedOnEachParse(def, json.default)) {
188
+ delete json.default;
189
+ lose(path, "default");
190
+ }
191
+ },
192
+ });
193
+ // Through JSON, so what the file holds is exactly the plain data a check
194
+ // reads back; the draft is the module's, said once in its header.
195
+ const plain = JSON.parse(JSON.stringify(written));
196
+ delete plain.$schema;
197
+ return plain;
198
+ };
199
+ /** Losses in a stable order, so the lines and the result read the same on every run. */
200
+ const compareLosses = (a, b) => a.api.localeCompare(b.api) || a.direction.localeCompare(b.direction) || a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind);
201
+ /** The losses as lines, under one naming how many APIs carry them. */
202
+ const lossLinesOf = (losses) => {
203
+ if (!losses.length)
204
+ return [];
205
+ const apis = new Set(losses.map((loss) => loss.api)).size;
206
+ return [
207
+ ` ${apis === 1 ? "1 API carries" : `${apis} APIs carry`} what JSON Schema cannot hold, which the mock does not check:`,
208
+ ...losses.map((loss) => ` ! ${loss.api} ${loss.direction} ${loss.path}: ${LOSS_WORDING[loss.kind]}`),
209
+ ];
210
+ };
211
+ /**
212
+ * The table a generated file holds, read back as data, or null for a file
213
+ * that does not hold it as written. Read up to the last `as const` in the
214
+ * file, which is the statement's own: the file holds one statement, and a
215
+ * schema's description may say "as const" itself.
216
+ */
217
+ const readSchemaTable = (contents) => {
218
+ const match = new RegExp(`export const ${TABLE_NAME}\\s*=\\s*([\\s\\S]*)\\s+as const\\b`).exec(contents);
219
+ if (!match)
220
+ return null;
221
+ try {
222
+ const table = JSON.parse(match[1]);
223
+ return table !== null && typeof table === "object" && !Array.isArray(table) ? table : null;
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ };
229
+ const renderSchemasFile = (entries, options) => [
230
+ ...headerLinesOf(options.header ?? DEFAULT_HEADER),
231
+ "",
232
+ ...tableStatementLines({
233
+ name: TABLE_NAME,
234
+ doc: "Every API the server registers: its input schema in its input form and its output schema in its output form, as JSON Schema.",
235
+ value: entries,
236
+ semicolon: options.semicolons === false ? "" : ";",
237
+ }),
238
+ "",
239
+ ].join("\n");
240
+ /** Imports the module, finds the instance under its export, and answers the schemas it reports. */
241
+ const loadApiSchemaPairs = async (options) => {
242
+ const moduleUrl = moduleUrlOf(options.module);
243
+ const exportName = options.exportName ?? "default";
244
+ let namespace;
245
+ try {
246
+ namespace = await import(moduleUrl);
247
+ }
248
+ catch (err) {
249
+ throw new Error(`writeApiSchemas could not load ${moduleUrl}`, { cause: err });
250
+ }
251
+ const source = namespace[exportName];
252
+ if (typeof source?.apiSchemaEntries !== "function") {
253
+ throw new Error(`${moduleUrl} has no export "${exportName}" that reports API schemas: name the export holding the instance in exportName`);
254
+ }
255
+ return await source.apiSchemaEntries();
256
+ };
257
+ /**
258
+ * Writes every API's input and output schemas as JSON Schema to a module of
259
+ * plain data, or checks the one on disk, and says which APIs moved and what
260
+ * their schemas do that the file cannot hold.
261
+ *
262
+ * Call it from a generator script beside writeApiOptions, writing to where
263
+ * the mock's setup reads it:
264
+ *
265
+ * ```ts
266
+ * import { writeApiSchemas } from "lambder/build";
267
+ *
268
+ * const result = await writeApiSchemas({
269
+ * module: "server/src/index.ts", // export const lambder = initLambder()...
270
+ * exportName: "lambder",
271
+ * file: "web/src/mock/apiSchemas.generated.ts",
272
+ * check: process.argv.includes("--check"),
273
+ * });
274
+ * console.log(result.lines.join("\n"));
275
+ * process.exit(result.ok ? 0 : 1);
276
+ * ```
277
+ *
278
+ * The table is compared as the data the file holds, so a checkout that
279
+ * rewrote its line endings or a formatter that re-indented it is neither
280
+ * stale nor rewritten. The same instance writes the same file on every run.
281
+ * A module that does not load, an export that is not an instance, or a
282
+ * schema holding what JSON Schema cannot represent (other than a refinement
283
+ * or a transform, which are listed) throws.
284
+ */
285
+ export const writeApiSchemas = async (options) => {
286
+ const file = resolve(options.file);
287
+ const pairs = await loadApiSchemaPairs(options);
288
+ const losses = [];
289
+ const entries = {};
290
+ for (const api of Object.keys(pairs).sort()) {
291
+ const pair = pairs[api];
292
+ entries[api] = { input: jsonSchemaOf(api, "input", pair.input, losses), output: jsonSchemaOf(api, "output", pair.output, losses) };
293
+ }
294
+ losses.sort(compareLosses);
295
+ const previousText = existsSync(file) ? readFileSync(file, "utf8") : null;
296
+ const previous = previousText === null ? null : readSchemaTable(previousText);
297
+ const changes = nameChangesOf(entries, previous ?? {});
298
+ const count = Object.keys(entries).length;
299
+ const settled = settleGeneratedFile({
300
+ name: options.file, file, check: options.check, previousText, readBack: previous !== null,
301
+ tables: [{ name: TABLE_NAME, count, changes }],
302
+ held: count === 1 ? "1 API" : `${count} APIs`,
303
+ tablesNoun: "the schema table",
304
+ unmovedLine: "no schemas changed",
305
+ render: () => renderSchemasFile(entries, options),
306
+ });
307
+ return { ...settled, lines: [...settled.lines, ...lossLinesOf(losses)], file, count, changes, losses };
308
+ };
package/dist/build.d.ts CHANGED
@@ -19,8 +19,10 @@ export type { LambderApiOptionsFileOptions, LambderApiOptionsFileResult, } from
19
19
  export { writeApiGuardParams } from "./build/writeApiGuardParams.js";
20
20
  export type { LambderApiGuardParamsFileOptions, LambderApiGuardParamsFileResult, } from "./build/writeApiGuardParams.js";
21
21
  export type { LambderApiOptionsSource, LambderNameChanges } from "./build/generatedTables.js";
22
+ export { writeApiSchemas } from "./build/writeApiSchemas.js";
23
+ export type { LambderApiSchemasSource, LambderApiSchemasFileOptions, LambderApiSchemasFileResult, LambderApiSchemaLoss, } from "./build/writeApiSchemas.js";
22
24
  export { checkApiRefusals } from "./build/checkApiRefusals.js";
23
25
  export type { LambderApiRefusalCheckOptions, LambderApiRefusalCheckResult, LambderRefusalCheckFinding, } from "./build/checkApiRefusals.js";
24
26
  export { generateApiFiles } from "./build/generateApiFiles.js";
25
- export type { LambderApiFilesConfig, LambderApiFilesApp, LambderApiFilesResult } from "./build/generateApiFiles.js";
27
+ export type { LambderApiFilesConfig, LambderApiFilesApp, LambderApiFilesOptions, LambderApiFilesResult } from "./build/generateApiFiles.js";
26
28
  export type { LambderModuleLocation } from "./build/moduleLocation.js";
package/dist/build.js CHANGED
@@ -14,5 +14,7 @@ export { writeApiSignatures } from "./build/writeApiSignatures.js";
14
14
  export { writeApiContract } from "./build/writeApiContract.js";
15
15
  export { writeApiOptions } from "./build/writeApiOptions.js";
16
16
  export { writeApiGuardParams } from "./build/writeApiGuardParams.js";
17
+ // Development only: the schemas a mock validates its calls against.
18
+ export { writeApiSchemas } from "./build/writeApiSchemas.js";
17
19
  export { checkApiRefusals } from "./build/checkApiRefusals.js";
18
20
  export { generateApiFiles } from "./build/generateApiFiles.js";