lambder 7.2.2 → 7.2.3

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/CHANGELOG.md CHANGED
@@ -9,6 +9,22 @@ sit on its first published patch, and later patches list only what they changed.
9
9
  Releases up to 3.2.6 carry git tags; the ones after it were published without
10
10
  one, so versions are not cross-linked to tag comparisons here.
11
11
 
12
+ ## [7.2.3] - 2026-09-19
13
+
14
+ ### Added
15
+
16
+ - **`extensibleEnum(schema)`**, exported from both entries: marks an enum
17
+ whose readers tolerate values they were not built with, and the signature
18
+ digest leaves its values out wherever it is output. A list that grows with
19
+ the product (roles, permissions, statuses) and rides in a widely returned
20
+ payload changed the signature of every endpoint returning it, so one new
21
+ permission reloaded every open tab. With the mark, the list growing or
22
+ shrinking reloads only the clients of endpoints that take it as input,
23
+ where its values still count because a removed value is a request the
24
+ server now refuses. The schema's type and validation are unchanged; the
25
+ mark is zod metadata, read from zod's shared registry. An unmarked enum
26
+ digests exactly as before, so upgrading changes no signature.
27
+
12
28
  ## [7.2.2] - 2026-09-18
13
29
 
14
30
  ### Added
@@ -24,9 +24,11 @@ export type LambderApiSignatureEntry = {
24
24
  *
25
25
  * The description is hashed as built, descriptions and titles included: a
26
26
  * schema is what the server says it is, and a client built against a
27
- * different one reloads once. What must hold for the digest to mean anything
28
- * is that a schema is built from static values: one that reads the clock, a
29
- * random source or the environment at construction digests differently in
30
- * the generator's process and on the server.
27
+ * different one reloads once, with one exception the schema declares itself:
28
+ * the values of an extensibleEnum() in an output (see keepShapeOnly). What
29
+ * must hold for the digest to mean anything is that a schema is built from
30
+ * static values: one that reads the clock, a random source or the environment
31
+ * at construction digests differently in the generator's process and on the
32
+ * server.
31
33
  */
32
34
  export declare const apiSignatureOf: (definition: LambderApiDefinition, guards: Record<string, LambderApiGuard<any, any, any>> | undefined) => Promise<string>;
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { toGuardEntries } from "./LambderApiGuards.js";
3
- import { API_SIGNATURE_HEX_LENGTH } from "../shared/wire/LambderApiSignature.js";
3
+ import { API_SIGNATURE_HEX_LENGTH, EXTENSIBLE_ENUM_META_KEY } from "../shared/wire/LambderApiSignature.js";
4
4
  import { sha256HexOf } from "../shared/util/LambderTextDigest.js";
5
5
  /*
6
6
  * The digest of an endpoint's client-facing shape, computed once, by the
@@ -30,7 +30,7 @@ const sortKeys = (value) => {
30
30
  return sorted;
31
31
  };
32
32
  /**
33
- * Two edits to every node zod emits, before it is hashed.
33
+ * Three edits to every node zod emits, before it is hashed.
34
34
  *
35
35
  * The `default` keyword goes. Its value is server behaviour, not shape: a
36
36
  * client never sends it, and its compiled types do not carry it. And for a
@@ -44,11 +44,24 @@ const sortKeys = (value) => {
44
44
  *
45
45
  * `required` is sorted. It is a set, and the order fields are declared in is
46
46
  * not shape either; left as emitted, reordering two fields forced a reload.
47
+ *
48
+ * An enum marked with extensibleEnum() loses its values in an output. Its
49
+ * clients tolerate a value they do not know, so a response carrying one they
50
+ * were not built with, or no longer carrying one they were, changes nothing
51
+ * they can see; the node still says it holds a string. In an input the values
52
+ * stay, since a value dropped from the list is a request an older client may
53
+ * still send and the server now refuses. The mark itself goes in both, so
54
+ * marking an enum changes no input's digest.
47
55
  */
48
- const keepShapeOnly = (node) => {
56
+ const keepShapeOnly = (node, io) => {
49
57
  delete node.default;
50
58
  if (Array.isArray(node.required))
51
59
  node.required.sort();
60
+ if (node[EXTENSIBLE_ENUM_META_KEY] === true) {
61
+ delete node[EXTENSIBLE_ENUM_META_KEY];
62
+ if (io === "output")
63
+ delete node.enum;
64
+ }
52
65
  };
53
66
  /**
54
67
  * A schema as JSON Schema, as zod emits it minus what keepShapeOnly removes.
@@ -56,7 +69,7 @@ const keepShapeOnly = (node) => {
56
69
  * becomes `{}` rather than throwing, because a digest has to exist for every
57
70
  * endpoint; what the digest cannot see is documented with it.
58
71
  */
59
- const jsonSchemaOf = (schema, io) => schema ? z.toJSONSchema(schema, { io, unrepresentable: "any", override: ({ jsonSchema }) => keepShapeOnly(jsonSchema) }) : null;
72
+ const jsonSchemaOf = (schema, io) => schema ? z.toJSONSchema(schema, { io, unrepresentable: "any", override: ({ jsonSchema }) => keepShapeOnly(jsonSchema, io) }) : null;
60
73
  const ownGuard = (guards, name) => guards !== undefined && Object.prototype.hasOwnProperty.call(guards, name) ? guards[name] : undefined;
61
74
  /**
62
75
  * The digest of an endpoint's client-facing shape: its name and mode, its
@@ -69,10 +82,12 @@ const ownGuard = (guards, name) => guards !== undefined && Object.prototype.hasO
69
82
  *
70
83
  * The description is hashed as built, descriptions and titles included: a
71
84
  * schema is what the server says it is, and a client built against a
72
- * different one reloads once. What must hold for the digest to mean anything
73
- * is that a schema is built from static values: one that reads the clock, a
74
- * random source or the environment at construction digests differently in
75
- * the generator's process and on the server.
85
+ * different one reloads once, with one exception the schema declares itself:
86
+ * the values of an extensibleEnum() in an output (see keepShapeOnly). What
87
+ * must hold for the digest to mean anything is that a schema is built from
88
+ * static values: one that reads the clock, a random source or the environment
89
+ * at construction digests differently in the generator's process and on the
90
+ * server.
76
91
  */
77
92
  export const apiSignatureOf = async (definition, guards) => {
78
93
  const guardShapes = toGuardEntries(definition.guards).map(({ name }) => {
package/dist/client.d.ts CHANGED
@@ -15,7 +15,7 @@ export type { LambderApiTransport, LambderApiTransportRequest, LambderTransportF
15
15
  export { LambderCookieJar, parseSetCookie } from "./shared/transport/LambderCookieJar.js";
16
16
  export type { LambderStoredCookie } from "./shared/transport/LambderCookieJar.js";
17
17
  export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
18
- export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
18
+ export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignature.js";
19
19
  export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignature.js";
20
20
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
21
21
  export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
package/dist/client.js CHANGED
@@ -14,8 +14,9 @@ export { buildTransportEnvelope, LambderTransportFailure, isLambderTransportFail
14
14
  export { lambderCookieJarTransport } from "./shared/transport/lambderCookieJarTransport.js";
15
15
  export { LambderCookieJar, parseSetCookie } from "./shared/transport/LambderCookieJar.js";
16
16
  export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
17
- // The per-endpoint signature map a build ships with, how a caller reads it, and the reload-loop window.
18
- export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
17
+ // The per-endpoint signature map a build ships with, how a caller reads it, the
18
+ // mark a shared schema sets on an enum its readers let grow, and the reload-loop window.
19
+ export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignature.js";
19
20
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
20
21
  export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
21
22
  // Typed API refusals (isomorphic: shared code may throw them from anywhere;
package/dist/index.d.ts CHANGED
@@ -27,7 +27,7 @@ export type { LambderApiCallContext, LambderApiCallTrace } from "./api/LambderAp
27
27
  export type { LambderApiDefinition } from "./api/LambderApiDefinition.js";
28
28
  export { apiSignatureOf } from "./api/LambderApiSignature.js";
29
29
  export type { LambderApiSignatureEntry } from "./api/LambderApiSignature.js";
30
- export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
30
+ export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignature.js";
31
31
  export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignature.js";
32
32
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
33
33
  export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ export { LambderAnswerHeaders, getAnswerHeader, setAnswerHeader, addAnswerHeader
18
18
  export { createApiCallContext } from "./api/LambderApiCallContext.js";
19
19
  // Per-endpoint signatures: what a client build ships with, digested from the server's own registrations.
20
20
  export { apiSignatureOf } from "./api/LambderApiSignature.js";
21
- export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
21
+ export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignature.js";
22
22
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
23
23
  export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
24
24
  export { buildApiEnvelope, envelopeAnswer, refusalAnswer, validationAnswer, apiNotFoundAnswer, sessionExpiredAnswer, versionExpiredAnswer, invalidPayloadAnswer, crashAnswer, API_ANSWER_CONTENT_TYPE, } from "./api/LambderApiEnvelope.js";
@@ -1,3 +1,4 @@
1
+ import type { z } from "zod";
1
2
  /**
2
3
  * The per-endpoint signatures a client carries, generated from the server's
3
4
  * own registrations (Lambder.apiSignatures()) and shipped with the client
@@ -44,3 +45,39 @@ export declare const lookupApiSignature: (signatures: LambderApiSignatureMap, ap
44
45
  * run instead of saying so.
45
46
  */
46
47
  export declare const readApiSignature: (signatures: LambderApiSignatureMap, apiName: string) => Promise<string>;
48
+ /**
49
+ * The metadata key extensibleEnum() sets and the digest reads. Namespaced,
50
+ * because zod writes metadata into the JSON Schema it emits, so the key shows
51
+ * up in any schema an app converts for itself, where OpenAPI's own
52
+ * `x-extensible-enum` means something else (the values, in place of `enum`).
53
+ */
54
+ export declare const EXTENSIBLE_ENUM_META_KEY = "x-lambder-extensible-enum";
55
+ /**
56
+ * Marks an enum whose clients tolerate a value they were not built with, so
57
+ * its list of values stays out of the signature of every endpoint that
58
+ * returns it. Adding a role, a status or a locale to a list that rides in a
59
+ * widely returned payload (a session, a profile) then reloads only the
60
+ * clients that send the list back, not every client that reads it.
61
+ *
62
+ * Where the enum is input its values still count: a value dropped from the
63
+ * list is a request an older client may still send and the server now
64
+ * refuses, so that endpoint's clients must reload. Everything else about the
65
+ * schema is untouched: its type, its validation on both sides, and what it
66
+ * is everywhere outside the digest.
67
+ *
68
+ * The mark is a promise the schema makes for its readers, and nothing checks
69
+ * it. A client that switches over every value with no fallback, or indexes a
70
+ * map by one, renders a value it does not know as nothing, or throws. Mark
71
+ * only a list every reader handles that way on purpose.
72
+ *
73
+ * It is zod metadata (`.meta()`), which zod keeps in one registry on
74
+ * globalThis, so an enum marked in a shared package is read by the digest
75
+ * even when the server resolves another copy of zod. A schema derived from a
76
+ * marked enum by rebuilding it (`z.enum(marked.options)`, `.exclude()`)
77
+ * carries no mark and counts in full, which costs a reload, never a missed
78
+ * one.
79
+ *
80
+ * @example
81
+ * export const RoleSchema = extensibleEnum(z.enum(["admin", "member"]));
82
+ */
83
+ export declare const extensibleEnum: <TSchema extends z.ZodEnum>(schema: TSchema) => TSchema;
@@ -43,3 +43,39 @@ export const readApiSignature = async (signatures, apiName) => {
43
43
  }
44
44
  return signature;
45
45
  };
46
+ /**
47
+ * The metadata key extensibleEnum() sets and the digest reads. Namespaced,
48
+ * because zod writes metadata into the JSON Schema it emits, so the key shows
49
+ * up in any schema an app converts for itself, where OpenAPI's own
50
+ * `x-extensible-enum` means something else (the values, in place of `enum`).
51
+ */
52
+ export const EXTENSIBLE_ENUM_META_KEY = "x-lambder-extensible-enum";
53
+ /**
54
+ * Marks an enum whose clients tolerate a value they were not built with, so
55
+ * its list of values stays out of the signature of every endpoint that
56
+ * returns it. Adding a role, a status or a locale to a list that rides in a
57
+ * widely returned payload (a session, a profile) then reloads only the
58
+ * clients that send the list back, not every client that reads it.
59
+ *
60
+ * Where the enum is input its values still count: a value dropped from the
61
+ * list is a request an older client may still send and the server now
62
+ * refuses, so that endpoint's clients must reload. Everything else about the
63
+ * schema is untouched: its type, its validation on both sides, and what it
64
+ * is everywhere outside the digest.
65
+ *
66
+ * The mark is a promise the schema makes for its readers, and nothing checks
67
+ * it. A client that switches over every value with no fallback, or indexes a
68
+ * map by one, renders a value it does not know as nothing, or throws. Mark
69
+ * only a list every reader handles that way on purpose.
70
+ *
71
+ * It is zod metadata (`.meta()`), which zod keeps in one registry on
72
+ * globalThis, so an enum marked in a shared package is read by the digest
73
+ * even when the server resolves another copy of zod. A schema derived from a
74
+ * marked enum by rebuilding it (`z.enum(marked.options)`, `.exclude()`)
75
+ * carries no mark and counts in full, which costs a reload, never a missed
76
+ * one.
77
+ *
78
+ * @example
79
+ * export const RoleSchema = extensibleEnum(z.enum(["admin", "member"]));
80
+ */
81
+ export const extensibleEnum = (schema) => schema.meta({ [EXTENSIBLE_ENUM_META_KEY]: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "7.2.2",
3
+ "version": "7.2.3",
4
4
  "sideEffects": false,
5
5
  "description": "Opinionated serverless web framework for TypeScript on AWS Lambda: type-safe APIs from Zod schemas, DynamoDB sessions, and declarative rate limits, authorization guards and idempotency.",
6
6
  "keywords": [