lambder 14.1.1 → 15.0.2

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 +429 -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 +8 -3
  20. package/dist/build/checkApiRefusals.js +80 -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 +37 -19
  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
@@ -10,36 +10,36 @@
10
10
  // Implementation
11
11
  // ---------------------------------------------------------------------------
12
12
  /**
13
- * Browser detection: ordered prefs, full code then primary subtag. Only in a
14
- * page, where there is a document: Node 21 and later, Deno and Bun define
15
- * `navigator.languages` too, from the process locale, and a server's locale
16
- * is not its reader's.
13
+ * Browser detection: ordered prefs, full code then primary subtag, each
14
+ * matched in any case and answered as registered. Only in a page, where there
15
+ * is a document: Node 21 and later, Deno and Bun define `navigator.languages`
16
+ * too, from the process locale, and a server's locale is not its reader's.
17
17
  */
18
- const detectBrowserLanguage = (isCode) => {
18
+ const detectBrowserLanguage = (codeOf) => {
19
19
  if (typeof document === "undefined" || typeof navigator === "undefined")
20
20
  return null;
21
21
  const prefs = navigator.languages?.length ? navigator.languages : [navigator.language];
22
22
  for (const pref of prefs ?? []) {
23
- const lower = (pref ?? "").toLowerCase();
24
- if (isCode(lower))
25
- return lower;
26
- const primary = lower.split("-")[0] ?? "";
27
- if (isCode(primary))
23
+ const full = codeOf(pref ?? "");
24
+ if (full)
25
+ return full;
26
+ const primary = codeOf((pref ?? "").split("-")[0] ?? "");
27
+ if (primary)
28
28
  return primary;
29
29
  }
30
30
  return null;
31
31
  };
32
32
  /** Mutable active-language state, shared between an instance and all its extensions. */
33
33
  class LanguageState {
34
- isCode;
34
+ codeOf;
35
35
  defaultLanguage;
36
36
  customDetect;
37
37
  override = null;
38
38
  /** A throwing detector is reported once rather than on every t() call that runs it. */
39
39
  detectorFailureReported = false;
40
40
  listeners = new Set();
41
- constructor(isCode, defaultLanguage, customDetect) {
42
- this.isCode = isCode;
41
+ constructor(codeOf, defaultLanguage, customDetect) {
42
+ this.codeOf = codeOf;
43
43
  this.defaultLanguage = defaultLanguage;
44
44
  this.customDetect = customDetect;
45
45
  }
@@ -63,13 +63,13 @@ class LanguageState {
63
63
  console.error("LambderI18n: detectLanguage threw; continuing detection chain.", err);
64
64
  }
65
65
  }
66
- if (custom && this.isCode(custom))
67
- return custom;
68
- return detectBrowserLanguage(this.isCode) ?? this.defaultLanguage;
66
+ const detected = typeof custom === "string" ? this.codeOf(custom) : null;
67
+ if (detected)
68
+ return detected;
69
+ return detectBrowserLanguage(this.codeOf) ?? this.defaultLanguage;
69
70
  }
71
+ /** Overrides the active language with a code as registered (registeredCodeOf). */
70
72
  set(code) {
71
- if (!this.isCode(code))
72
- throw new Error(`LambderI18n: unsupported language code "${code}".`);
73
73
  if (this.override === code)
74
74
  return;
75
75
  this.override = code;
@@ -101,8 +101,8 @@ class LanguageState {
101
101
  }
102
102
  /**
103
103
  * Fills `{name}` tokens in one pass over the template, so a value is inserted
104
- * as it is: a display name "Eve {org}" stays that, rather than having its own
105
- * `{org}` filled by the next parameter. A token with no parameter stays.
104
+ * as it is: a display name "Eve {store}" stays that, rather than having its own
105
+ * `{store}` filled by the next parameter. A token with no parameter stays.
106
106
  * Own properties only, through Object.prototype.hasOwnProperty rather than
107
107
  * Object.hasOwn: this runs in the browser bundle, and a browser without
108
108
  * ES2022 (Safari before 15.4) would throw on the first parameterised text.
@@ -116,33 +116,111 @@ const interpolate = (text, params) => {
116
116
  const placeholdersOf = (text) => new Set([...text.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]));
117
117
  /** A set of placeholders as a message shows it: `{count}, {name}`, or "none". */
118
118
  const describePlaceholders = (placeholders) => placeholders.size ? [...placeholders].sort().map((name) => `{${name}}`).join(", ") : "none";
119
- /** How many mismatched translations an error lists before it only counts the rest. */
120
- const PLACEHOLDER_MISMATCH_LIMIT = 20;
119
+ /** How many refused entries an error lists, per kind of refusal, before it only counts the rest. */
120
+ const REFUSED_ENTRY_LIST_LIMIT = 20;
121
+ /** The plural categories in CLDR's order, the order messages list them in. */
122
+ const PLURAL_CATEGORIES = ["zero", "one", "two", "few", "many", "other"];
121
123
  /**
122
- * Refuses translations that do not carry the placeholders of the text they
123
- * translate. The default language's text is the contract the translator's
124
- * type takes its parameters from, so a translation that drops one loses the
125
- * value a caller passed, and one that adds one shows its `{token}` as it
126
- * is. `lookupDefault` answers the default language's text for a key, and a
127
- * key it has none for is left to the other checks.
124
+ * What is wrong with a dictionary value that is not a text, as the end of a
125
+ * sentence about its key, or null for a well-formed plural entry: an object
126
+ * of texts under plural categories. Which categories it must hold is its
127
+ * language's, so that is checked apart.
128
128
  */
129
- const assertPlaceholdersKept = (lookupDefault, lang, dict, label) => {
129
+ const pluralEntryProblem = (value) => {
130
+ if (!isObjectValue(value) || Array.isArray(value))
131
+ return "is neither a text nor a plural entry";
132
+ for (const [category, form] of Object.entries(value)) {
133
+ if (!PLURAL_CATEGORIES.includes(category)) {
134
+ return `has "${category}", which is no plural category (${PLURAL_CATEGORIES.join(", ")})`;
135
+ }
136
+ if (typeof form !== "string")
137
+ return `has a ${category} form that is not a text`;
138
+ }
139
+ if (value.other === undefined)
140
+ return "has no other form, which every plural entry needs";
141
+ return null;
142
+ };
143
+ /** A list of refused entries under its heading, as one section of an error. */
144
+ const refusalSection = (heading, lines) => {
145
+ const listed = lines.slice(0, REFUSED_ENTRY_LIST_LIMIT).map((line) => ` ${line}`);
146
+ const more = lines.length > REFUSED_ENTRY_LIST_LIMIT ? [` ... and ${lines.length - REFUSED_ENTRY_LIST_LIMIT} more`] : [];
147
+ return [heading, ...listed, ...more].join("\n");
148
+ };
149
+ /**
150
+ * Refuses the entries of one language's block that do not keep the default
151
+ * language's contract, every such key listed in one error. Everything
152
+ * refused here is refused alike by every runtime; which plural categories a
153
+ * language uses is not (runtimes ship different plural data), so that is
154
+ * left to checkPluralCoverage.
155
+ *
156
+ * Placeholders: the default language's text is the contract the
157
+ * translator's type takes its parameters from, so a translation that drops
158
+ * one loses the value a caller passed, and one that adds one shows its
159
+ * `{token}` as it is. A plural entry's contract is the tokens across all
160
+ * the default entry's forms, and every form in every language, the default
161
+ * one's included, carries each of them, except `{count}`, which a form may
162
+ * leave out to spell the number in words ("one item"). The default
163
+ * language's plain texts are the contract itself and are not compared.
164
+ *
165
+ * Kinds: a plural entry where the default language has a text is refused,
166
+ * since `t` takes no count for that key. A plain text where the default
167
+ * language has a plural entry is taken as the other form alone.
168
+ *
169
+ * Intl locale: a language with a plural entry has its plural rules built
170
+ * here, so an Intl locale that is no well-formed language tag, which every
171
+ * runtime refuses, fails where the dictionary arrives rather than in `t`.
172
+ *
173
+ * `lookupDefault` answers the default language's entry for a key, the one
174
+ * already held when the block is the default language's own registration.
175
+ * A key it has none for is held to itself.
176
+ */
177
+ const assertEntriesKept = (core, lookupDefault, lang, dict, label) => {
178
+ const refused = [];
130
179
  const mismatches = [];
131
- for (const [key, text] of Object.entries(dict)) {
132
- const original = lookupDefault(key);
133
- if (original === undefined || typeof text !== "string")
134
- continue;
135
- const expected = placeholdersOf(original);
136
- const found = placeholdersOf(text);
137
- if (expected.size === found.size && [...expected].every((name) => found.has(name)))
180
+ for (const [key, entry] of Object.entries(dict)) {
181
+ if (typeof entry !== "string") {
182
+ const problem = pluralEntryProblem(entry);
183
+ if (problem) {
184
+ refused.push(`"${key}" ${problem}`);
185
+ continue;
186
+ }
187
+ }
188
+ const checked = entry;
189
+ const reference = lookupDefault(key) ?? checked;
190
+ if (typeof reference === "string") {
191
+ if (typeof checked !== "string") {
192
+ refused.push(`"${key}" is a plural entry where the default language has a text, for which t takes no count: make the default language's entry plural`);
193
+ }
194
+ else if (lang !== core.defaultLanguage) {
195
+ const expected = placeholdersOf(reference);
196
+ const found = placeholdersOf(checked);
197
+ if (expected.size !== found.size || ![...expected].every((name) => found.has(name))) {
198
+ mismatches.push(`"${key}" has ${describePlaceholders(found)} where the default language has ${describePlaceholders(expected)}`);
199
+ }
200
+ }
138
201
  continue;
139
- mismatches.push(`"${key}" has ${describePlaceholders(found)} where the default language has ${describePlaceholders(expected)}`);
202
+ }
203
+ const forms = typeof checked === "string" ? { other: checked } : checked;
204
+ core.pluralRulesOf(lang);
205
+ const tokens = new Set(Object.values(reference).flatMap((form) => [...placeholdersOf(form)]));
206
+ tokens.delete("count");
207
+ for (const [category, form] of Object.entries(forms)) {
208
+ const found = placeholdersOf(form);
209
+ if ([...tokens].every((name) => found.has(name)) && [...found].every((name) => name === "count" || tokens.has(name)))
210
+ continue;
211
+ mismatches.push(`"${key}" (${category}) has ${describePlaceholders(found)} where every form needs ${describePlaceholders(tokens)} and may add {count}`);
212
+ }
140
213
  }
141
- if (mismatches.length === 0)
142
- return;
143
- const listed = mismatches.slice(0, PLACEHOLDER_MISMATCH_LIMIT).map((line) => ` ${line}`);
144
- const more = mismatches.length > PLACEHOLDER_MISMATCH_LIMIT ? [` ... and ${mismatches.length - PLACEHOLDER_MISMATCH_LIMIT} more`] : [];
145
- throw new Error([`LambderI18n: in ${label}, these "${lang}" translations carry other placeholders than the default language's text:`, ...listed, ...more].join("\n"));
214
+ const sections = [];
215
+ if (refused.length > 0) {
216
+ sections.push(refusalSection(`LambderI18n: in ${label}, these "${lang}" entries are refused:`, refused));
217
+ }
218
+ if (mismatches.length > 0) {
219
+ const texts = lang === core.defaultLanguage ? "plural forms" : "translations";
220
+ sections.push(refusalSection(`LambderI18n: in ${label}, these "${lang}" ${texts} carry other placeholders than the default language's text:`, mismatches));
221
+ }
222
+ if (sections.length > 0)
223
+ throw new Error(sections.join("\n"));
146
224
  };
147
225
  const layerLookup = (layer, lang, key) => {
148
226
  for (let node = layer; node; node = node.parent) {
@@ -167,8 +245,10 @@ const assertInlineDefaultBlock = (dict, defaultLanguage, label) => {
167
245
  };
168
246
  /**
169
247
  * A layer over its parent, from a dictionary set as configured. The inline
170
- * blocks' placeholders are checked here, against the default language's
171
- * texts the layer reaches; a loader's are checked when it has run.
248
+ * blocks are checked here (assertEntriesKept), against the default
249
+ * language's entries the layer reaches, the default block first so that a
250
+ * fault in the contract is reported as its own; a loader's block is checked
251
+ * when it has run.
172
252
  */
173
253
  const createLayer = (core, sources, parent, label) => {
174
254
  const layer = { dicts: {}, loaders: new Map(), inFlight: new Map(), parent };
@@ -178,9 +258,11 @@ const createLayer = (core, sources, parent, label) => {
178
258
  else if (source)
179
259
  layer.dicts[lang] = source;
180
260
  }
181
- for (const [lang, dict] of Object.entries(layer.dicts)) {
182
- if (lang !== core.defaultLanguage && dict)
183
- assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), lang, dict, label);
261
+ const languages = [core.defaultLanguage, ...Object.keys(layer.dicts).filter((lang) => lang !== core.defaultLanguage)];
262
+ for (const lang of languages) {
263
+ const dict = layer.dicts[lang];
264
+ if (dict)
265
+ assertEntriesKept(core, (key) => layerLookup(layer, core.defaultLanguage, key), lang, dict, label);
184
266
  }
185
267
  if (layer.loaders.size > 0)
186
268
  core.lazyLayers.add(layer);
@@ -189,11 +271,16 @@ const createLayer = (core, sources, parent, label) => {
189
271
  const isObjectValue = (value) => typeof value === "object" && value !== null;
190
272
  /**
191
273
  * A loader resolves to the dictionary itself or to a module whose default
192
- * export is the dictionary. The two cannot be confused: a dictionary's values
193
- * are strings, so a `default` holding an object can only be a module's.
274
+ * export is the dictionary. A `default` holding an object is a module's
275
+ * export, unless the contract makes `default` a plural key and the object is
276
+ * a plural entry: then it is the dictionary's own entry for that key, since
277
+ * a module's dictionary would carry keys that are no plural category.
194
278
  */
195
- const dictionaryFromLoaded = (loaded, lang) => {
196
- const dict = isObjectValue(loaded) && isObjectValue(loaded.default) ? loaded.default : loaded;
279
+ const dictionaryFromLoaded = (loaded, lang, lookupDefault) => {
280
+ const ownPluralDefault = isObjectValue(loaded)
281
+ && isObjectValue(lookupDefault("default"))
282
+ && pluralEntryProblem(loaded.default) === null;
283
+ const dict = isObjectValue(loaded) && isObjectValue(loaded.default) && !ownPluralDefault ? loaded.default : loaded;
197
284
  if (!isObjectValue(dict)) {
198
285
  throw new Error(`LambderI18n: the "${lang}" loader resolved to neither a dictionary nor a module whose default export is one.`);
199
286
  }
@@ -210,10 +297,11 @@ const startLayerLoad = (core, layer, lang, loader) => {
210
297
  layer.loaders.delete(lang);
211
298
  if (layer.loaders.size === 0)
212
299
  core.lazyLayers.delete(layer);
213
- const dict = dictionaryFromLoaded(loaded, lang);
300
+ const lookupDefault = (key) => layerLookup(layer, core.defaultLanguage, key);
301
+ const dict = dictionaryFromLoaded(loaded, lang, lookupDefault);
214
302
  if (layer.parent)
215
303
  assertNoRedeclaredKeys(layer.parent, core.defaultLanguage, dict, `the "${lang}" loader`);
216
- assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), lang, dict, `the "${lang}" loader`);
304
+ assertEntriesKept(core, lookupDefault, lang, dict, `the "${lang}" loader`);
217
305
  // Translations registered while the loader ran override what it brought.
218
306
  layer.dicts[lang] = { ...dict, ...layer.dicts[lang] };
219
307
  })
@@ -249,15 +337,41 @@ const loadLanguageAcrossRoot = async (core, lang) => {
249
337
  if (failure)
250
338
  throw failure.reason;
251
339
  };
340
+ /**
341
+ * A code a caller passed, as registered: language tags are case-insensitive
342
+ * (BCP 47), so `pt-br` names a registered `pt-BR`, and everything past this
343
+ * point (the override, the dictionaries, the loaders) is keyed by the one
344
+ * spelling the config used.
345
+ */
346
+ const registeredCodeOf = (core, code) => {
347
+ const registered = core.codeOf(code);
348
+ if (registered === null)
349
+ throw new Error(`LambderI18n: unsupported language code "${code}".`);
350
+ return registered;
351
+ };
252
352
  const buildInstance = (core, layer) => {
253
353
  const translateIn = (lang, key, params) => {
254
- const text = layerLookup(layer, lang, key)
255
- ?? layerLookup(layer, core.defaultLanguage, key)
256
- ?? key;
354
+ let textLanguage = lang;
355
+ let entry = layerLookup(layer, lang, key);
356
+ if (entry === undefined) {
357
+ textLanguage = core.defaultLanguage;
358
+ entry = layerLookup(layer, textLanguage, key);
359
+ }
360
+ if (entry === undefined)
361
+ return key;
362
+ // A form is picked under the rules of the language the text is in,
363
+ // which is the default language's when the key fell back to it. A
364
+ // category the entry lacks falls back to other, silently: this
365
+ // runtime's plural data may list a category the dictionary was not
366
+ // written for, which is no fault a visitor's page can act on, and a
367
+ // warning would reach every visitor's console from the render path.
368
+ // checkPluralCoverage is where such gaps are reported.
369
+ const text = typeof entry === "string" ? entry : entry[core.pluralCategoryOf(textLanguage, params?.count)] ?? entry.other;
257
370
  return interpolate(text, params);
258
371
  };
259
372
  const t = (key, params) => translateIn(core.state.resolve(), key, params);
260
- // forLanguage sits on the hot path of reactive T() bridges: cache per code.
373
+ // forLanguage sits on the hot path of reactive T() bridges: cache per
374
+ // code as registered, so every spelling of one hands back one translator.
261
375
  const translatorCache = new Map();
262
376
  const validateExtension = (dict, requiredLanguages, label) => {
263
377
  for (const lang of Object.keys(dict)) {
@@ -285,13 +399,12 @@ const buildInstance = (core, layer) => {
285
399
  const instance = {
286
400
  t,
287
401
  forLanguage(code) {
288
- const cached = translatorCache.get(code);
402
+ const registered = registeredCodeOf(core, code);
403
+ const cached = translatorCache.get(registered);
289
404
  if (cached)
290
405
  return cached;
291
- if (!core.isCode(code))
292
- throw new Error(`LambderI18n: unsupported language code "${code}".`);
293
- const translator = (key, params) => translateIn(code, key, params);
294
- translatorCache.set(code, translator);
406
+ const translator = (key, params) => translateIn(registered, key, params);
407
+ translatorCache.set(registered, translator);
295
408
  return translator;
296
409
  },
297
410
  extend(dict) {
@@ -303,23 +416,57 @@ const buildInstance = (core, layer) => {
303
416
  return buildInstance(core, createLayer(core, dict, layer, "extendPartial() dictionary"));
304
417
  },
305
418
  loadLanguage(code) {
306
- const lang = code ?? core.state.resolve();
307
- if (!core.isCode(lang))
308
- return Promise.reject(new Error(`LambderI18n: unsupported language code "${lang}".`));
419
+ let lang;
420
+ try {
421
+ lang = registeredCodeOf(core, code ?? core.state.resolve());
422
+ }
423
+ catch (err) {
424
+ return Promise.reject(err);
425
+ }
309
426
  return loadLanguageAcrossRoot(core, lang);
310
427
  },
311
428
  registerDictionary(code, dict) {
312
- if (!core.isCode(code))
313
- throw new Error(`LambderI18n: unsupported language code "${code}".`);
314
- if (code !== core.defaultLanguage) {
315
- assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), code, dict, `registerDictionary("${code}")`);
316
- }
317
- layer.dicts[code] = { ...layer.dicts[code], ...dict };
429
+ const lang = registeredCodeOf(core, code);
430
+ assertEntriesKept(core, (key) => layerLookup(layer, core.defaultLanguage, key), lang, dict, `registerDictionary("${lang}")`);
431
+ layer.dicts[lang] = { ...layer.dicts[lang], ...dict };
318
432
  core.state.emitChange();
319
433
  },
434
+ async checkPluralCoverage() {
435
+ await Promise.all(core.languageList.map((lang) => loadLanguageAcrossRoot(core, lang)));
436
+ const gaps = [];
437
+ for (const lang of core.languageList) {
438
+ // Every key this instance answers in the language, each by
439
+ // the entry t would use: the nearest layer's.
440
+ const keys = new Set();
441
+ for (let node = layer; node; node = node.parent) {
442
+ for (const key of Object.keys(node.dicts[lang] ?? {}))
443
+ keys.add(key);
444
+ }
445
+ for (const key of keys) {
446
+ const entry = layerLookup(layer, lang, key);
447
+ const reference = layerLookup(layer, core.defaultLanguage, key) ?? entry;
448
+ if (typeof reference === "string")
449
+ continue;
450
+ const forms = typeof entry === "string" ? { other: entry } : entry;
451
+ const used = core.pluralCategoriesOf(lang);
452
+ const missing = used.filter((category) => forms[category] === undefined);
453
+ if (missing.length === 0)
454
+ continue;
455
+ const text = typeof entry === "string" ? "is a text, which stands for the other form alone, and " : "";
456
+ gaps.push(` "${key}" in "${lang}" ${text}lacks ${missing.join(", ")}: "${lang}" uses ${used.join(", ")}`);
457
+ }
458
+ }
459
+ if (gaps.length > 0) {
460
+ throw new Error([
461
+ "LambderI18n: these plural entries lack forms for categories their language's plural rules use, by this runtime's Intl plural data; t shows their other form for those counts:",
462
+ ...gaps,
463
+ ].join("\n"));
464
+ }
465
+ },
320
466
  setLanguage(code) {
321
- core.state.set(code);
322
- loadSwitchedLanguage(code);
467
+ const lang = registeredCodeOf(core, code);
468
+ core.state.set(lang);
469
+ loadSwitchedLanguage(lang);
323
470
  },
324
471
  resetLanguage() {
325
472
  core.state.reset();
@@ -355,6 +502,18 @@ const buildInstance = (core, layer) => {
355
502
  export const createLambderI18n = (config) => {
356
503
  const languageList = Object.keys(config.languages);
357
504
  const isCode = (value) => Object.prototype.hasOwnProperty.call(config.languages, value);
505
+ // Language tags are case-insensitive (BCP 47): a browser's `pt-br` and a
506
+ // path's `PT-BR` both name a registered `pt-BR`. So two registered codes
507
+ // that differ only in case would be one language twice.
508
+ const codeByLowerCase = new Map();
509
+ for (const code of languageList) {
510
+ const clash = codeByLowerCase.get(code.toLowerCase());
511
+ if (clash !== undefined) {
512
+ throw new Error(`LambderI18n: languages registers "${clash}" and "${code}", one language tag in two spellings: tags are compared case-insensitively.`);
513
+ }
514
+ codeByLowerCase.set(code.toLowerCase(), code);
515
+ }
516
+ const codeOf = (value) => typeof value === "string" ? codeByLowerCase.get(value.toLowerCase()) ?? null : null;
358
517
  if (!isCode(config.defaultLanguage)) {
359
518
  throw new Error(`LambderI18n: defaultLanguage "${config.defaultLanguage}" is not in languages.`);
360
519
  }
@@ -384,6 +543,25 @@ export const createLambderI18n = (config) => {
384
543
  })
385
544
  : null;
386
545
  const metaByCode = new Map(languageList.map((code) => [code, { code, ...config.languages[code] }]));
546
+ // Built for a language the first time one of its plural entries arrives,
547
+ // and kept: t picks a form on every call, and building the rules costs
548
+ // far more than the pick. Never for a language without plural entries,
549
+ // so its code need not be a tag Intl accepts.
550
+ const pluralRules = new Map();
551
+ const pluralRulesOf = (lang) => {
552
+ let rules = pluralRules.get(lang);
553
+ if (!rules) {
554
+ const locale = metaByCode.get(lang)?.intlLocale ?? lang;
555
+ try {
556
+ rules = new Intl.PluralRules(locale);
557
+ }
558
+ catch (err) {
559
+ throw new Error(`LambderI18n: "${lang}" has plural entries, and Intl.PluralRules refuses its Intl locale "${locale}" (${err.message}): give the language an intlLocale it accepts.`);
560
+ }
561
+ pluralRules.set(lang, rules);
562
+ }
563
+ return rules;
564
+ };
387
565
  const core = {
388
566
  languages: config.languages,
389
567
  languageList,
@@ -391,9 +569,16 @@ export const createLambderI18n = (config) => {
391
569
  metaList: [...metaByCode.values()],
392
570
  defaultLanguage: config.defaultLanguage,
393
571
  enforced: config.enforced,
394
- state: new LanguageState(isCode, config.defaultLanguage, customDetect),
572
+ state: new LanguageState(codeOf, config.defaultLanguage, customDetect),
395
573
  isCode,
574
+ codeOf,
396
575
  lazyLayers: new Set(),
576
+ pluralRulesOf,
577
+ pluralCategoriesOf: (lang) => {
578
+ const used = pluralRulesOf(lang).resolvedOptions().pluralCategories;
579
+ return PLURAL_CATEGORIES.filter((category) => used.includes(category));
580
+ },
581
+ pluralCategoryOf: (lang, count) => pluralRulesOf(lang).select(Number(count)),
397
582
  };
398
583
  return buildInstance(core, createLayer(core, config.base, null, "base dictionary"));
399
584
  };
@@ -22,6 +22,19 @@ export interface LambderCacheSetOptions {
22
22
  /** How long the entry lives. Default: the cache's own defaultTtlSeconds. */
23
23
  ttlSeconds?: number;
24
24
  }
25
+ /**
26
+ * What getOrSet takes: a write's options, and the fill lease LambderDdbCache
27
+ * holds across containers while one of them loads a missing entry. A cache
28
+ * that holds no lease checks the lease options all the same and otherwise
29
+ * ignores them, so code typed against LambderCache tunes the lease for the
30
+ * table and runs unchanged over any cache.
31
+ */
32
+ export interface LambderCacheGetOrSetOptions extends LambderCacheSetOptions {
33
+ /** How long one container's fill lease on a missing entry holds the others off. Default: 15. */
34
+ leaseSeconds?: number;
35
+ /** How long a container waits for another's fill before loading itself. Default: (leaseSeconds + 1) * 1000. */
36
+ waitForFillMs?: number;
37
+ }
25
38
  export interface LambderCacheListOptions {
26
39
  /** Only sort keys starting with this prefix. */
27
40
  prefix?: string;
@@ -63,5 +76,5 @@ export interface LambderCache {
63
76
  * lands over it. An invalid option throws before anything is read or
64
77
  * loaded.
65
78
  */
66
- getOrSet<T>(key: LambderCacheKey, loader: () => Promise<T>, options?: LambderCacheSetOptions): Promise<T>;
79
+ getOrSet<T>(key: LambderCacheKey, loader: () => Promise<T>, options?: LambderCacheGetOrSetOptions): Promise<T>;
67
80
  }
@@ -58,12 +58,11 @@ export type LambderRateLimitResult = false | LambderRateLimitExceeded;
58
58
  * implementations disagree on a negative cap (one refuses the first attempt,
59
59
  * the other allows it), since neither is meant to be asked.
60
60
  *
61
- * The tracker key is bounded there too: the variable half of it (a custom
62
- * key handler's return, a session key) is replaced by its own sha256 once it
63
- * passes 1024 UTF-8 bytes as written into the key, so an implementation with
64
- * a key limit of its own never meets a key it has to refuse. That matters
65
- * because a limiter's refusal is a throw, and a throw is what failOpen
66
- * swallows into no limit at all.
61
+ * The tracker key is bounded there too: its caller's half (an address, a
62
+ * session key, a custom key handler's return) arrives as a digest of fixed
63
+ * length, so an implementation with a key limit of its own never meets a key
64
+ * it has to refuse. That matters because a limiter's refusal is a throw, and
65
+ * a throw is what failOpen swallows into no limit at all.
67
66
  *
68
67
  * A limiter that answers a run of requests with one continuing failure (the
69
68
  * DynamoDB limiter, for a flooded partition it cannot size) may throw the
@@ -67,8 +67,9 @@ export declare const isLambderTransportFailure: (err: unknown) => err is Lambder
67
67
  * status means. Rejecting on a status would throw away the envelope a
68
68
  * refusal, a validation failure or a crash arrived in.
69
69
  * - **A rejection is a transport failure.** The caller reports it as
70
- * `network`, as `timeout` when its own abort fired, or as the reason a
71
- * thrown LambderTransportFailure names. That failure's `cause` carries the
70
+ * `network`, as `timeout` or `aborted` when the call's own timeout or
71
+ * signal had aborted it, or as the reason a thrown LambderTransportFailure
72
+ * names. That failure's `cause` carries the
72
73
  * real error through to the caller's `outcome.error`.
73
74
  * - **`request.signal` must be honoured**, by rejecting as soon as it aborts.
74
75
  * Without that, the caller's `timeoutMs` and per-call `signal` mean
@@ -7,7 +7,9 @@
7
7
  * retry. So the timer holds exactly one wait of either kind: `retry` and
8
8
  * `wait` climb the ladder, `after` waits a fixed time without climbing it, and
9
9
  * scheduling any of them replaces whatever was waiting. A caller says what to
10
- * run and when it worked, and never keeps a handle and a counter of its own.
10
+ * run and when it worked, and never keeps a handle and a counter of its own:
11
+ * `retries` is how many times it has been asked to try again since the last
12
+ * reset, so a loop that gives up after so many reads it there.
11
13
  *
12
14
  * The ladder: a retry waits the base plus a share of a ceiling that grows by
13
15
  * `factor` with every failed attempt, the whole never past `maxMs`. With full
@@ -22,15 +24,17 @@
22
24
  */
23
25
  export type LambderBackoffTimerOptions = {
24
26
  /**
25
- * The shortest retry wait, in milliseconds. The first after a reset falls
26
- * between it and twice it (exactly twice with `jitter: "none"`), so even
27
- * the first retries of many clients spread out. Default: 1000.
27
+ * The shortest retry wait, in milliseconds, above 0: every wait is a
28
+ * multiple of it, so 0 would retry with no pause. The first after a reset
29
+ * falls between it and twice it (exactly twice with `jitter: "none"`), so
30
+ * even the first retries of many clients spread out. Default: 1000.
28
31
  */
29
32
  baseMs?: number;
30
33
  /**
31
34
  * The longest any retry wait is, in milliseconds, however many attempts
32
- * have failed; at least `baseMs`. Default: 60000, or `baseMs` when that is
33
- * longer.
35
+ * have failed; at least `baseMs`, and at most 2147483647 (about 24.8
36
+ * days), the longest delay setTimeout keeps. Default: 60000, or `baseMs`
37
+ * when that is longer.
34
38
  */
35
39
  maxMs?: number;
36
40
  /** How much the ceiling grows with each failed attempt. Default: 2. */
@@ -47,7 +51,7 @@ export declare class LambderBackoffTimer {
47
51
  private readonly maxMs;
48
52
  private readonly factor;
49
53
  private readonly jitter;
50
- private attempts;
54
+ private retriesSinceReset;
51
55
  private timer;
52
56
  /** Settles the promise of a `wait` that cancel() or a replacement drops, so no `await` is left hanging. */
53
57
  private dropPending;
@@ -55,13 +59,20 @@ export declare class LambderBackoffTimer {
55
59
  /** True while a wait of any kind is pending. False again by the time it runs. */
56
60
  get pending(): boolean;
57
61
  /**
58
- * Runs `run` after the next rung of the ladder, counting one more failed
59
- * attempt. Replaces whatever was waiting.
62
+ * How many retries `retry` and `wait` have scheduled since the last reset
63
+ * (or since the timer was built), a dropped one included: the rung the
64
+ * next one climbs from. A loop that gives up after so many retries reads
65
+ * it here rather than keeping a counter of its own.
66
+ */
67
+ get retries(): number;
68
+ /**
69
+ * Runs `run` after the next rung of the ladder, counting one more retry.
70
+ * Replaces whatever was waiting.
60
71
  */
61
72
  retry(run: () => void): void;
62
73
  /**
63
- * Resolves after the next rung of the ladder, counting one more failed
64
- * attempt: the `retry` for code that awaits rather than calls back.
74
+ * Resolves after the next rung of the ladder, counting one more retry:
75
+ * the `retry` for code that awaits rather than calls back.
65
76
  * Replaces whatever was waiting. Rejects at once with the signal's reason
66
77
  * when `signal` aborts, and with an Error when cancel() or a later wait
67
78
  * drops it before it ran, so an await on it always settles.
@@ -72,11 +83,11 @@ export declare class LambderBackoffTimer {
72
83
  * climbs. Replaces whatever was waiting.
73
84
  */
74
85
  after(delayMs: number, run: () => void): void;
75
- /** The attempt worked: the next failure waits the shortest time again. A pending wait is left alone. */
86
+ /** The attempt worked: the next failure waits the shortest time again, and `retries` is 0. A pending wait is left alone. */
76
87
  reset(): void;
77
88
  /** Drops the pending wait (the caller is trying right now, or going away), keeping the count. */
78
89
  cancel(): void;
79
- /** The next wait on the ladder, in milliseconds, counting one more failed attempt. */
90
+ /** The next wait on the ladder, in milliseconds, counting one more retry. */
80
91
  private nextRung;
81
92
  private schedule;
82
93
  }