proteum 2.5.16 → 2.5.18

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.
@@ -23,6 +23,7 @@ Managed compact root routers must use trigger -> canonical instruction file refe
23
23
  - For every non-trivial coding task, load and follow root-level `DOCUMENTATION.md` before coding.
24
24
  - For bug fixes, regressions, incidents, broken public routes, auth/OAuth failures, integration failures, or production behavior fixes, load and follow root-level `DOCUMENTATION.md` before coding so the relevant fix note, regression-test docs, ADR, or explicit skip reason is handled in the same change.
25
25
  - If the user reports an issue, or you encounter one during exploration, implementation, verification, or runtime reproduction, load and follow root-level `diagnostics.md`.
26
+ - If the task adds or changes client data fetching, polling, or retry behavior, read the `Client Request Discipline` section in `client/AGENTS.md` before editing.
26
27
  - If the task touches client-side files, especially `client/**` and page files, load and apply root-level `optimizations.md` only after implementation for post-implementation checking and optimization. Skip it at task start and skip it for server-only, test-only, doc-only, and non-client refactor tasks unless the user explicitly asks for optimization work.
27
28
  - If the task changes UX, copy, onboarding, pricing, product semantics, or commercial positioning, use root-level `DOCUMENTATION.md` to choose the smallest relevant `./docs/` pack before editing. If a dev server is already running, print the live dev server URL as a clickable Markdown link.
28
29
  - If the task needs new app or artifact boilerplate, prefer `npx proteum init ...` and `npx proteum create ...` before creating files by hand. Use `--dry-run --json` when an agent needs a machine-readable plan before writing files.
@@ -31,6 +31,17 @@ Coding style source of truth: root-level `CODING_STYLE.md`.
31
31
  - Toasts and form errors are local feedback only; use `setError(context.app.handleError(error, fallbackMessage))` or rethrow the caught error.
32
32
  - `console.*(error)` is not error handling and must not be the last stop for a caught error.
33
33
 
34
+ ## Client Request Discipline
35
+
36
+ - A page or layout must not accumulate one mount-time API call per component or store. Before adding a request that fires on page load, extend an existing load-time payload (SSR `data`, the surface's bootstrap endpoint, or an already-fetched store) and hydrate from it; a new load-time call requires an explicit reason why no existing read can carry the data.
37
+ - When an endpoint accepts a set (for example an array of ids), request the whole needed set in one call and coalesce triggers that land in the same tick. Never loop single-item requests over a known set.
38
+ - Any effect or reactive block that can issue a request must key on explicit values (ids, tokens, statuses), must guard with sequence tokens so state identity churn cannot re-fire it, and must treat every terminal status, including `'error'`, as ineligible for automatic re-request.
39
+ - Automatic retries require exponential backoff with jitter, a hard attempt cap, and respect for server pacing (`retryAfterMs`, `Retry-After`). A transport failure or a server failed status ends in a terminal error state with a manual retry affordance, never in an immediate re-request from a render or effect cycle.
40
+ - Treat HTTP 429 as a stop signal for the whole surface, not as a retriable error.
41
+ - Long polls honor the server-provided delay, run as a single chain per logical resource, and are cancelled when superseded or unmounted.
42
+ - Identical concurrent requests must share one in-flight promise, usually a module-level cache with in-flight dedupe.
43
+ - After changing load-time fetching, polling, or retry behavior, measure the surface's executed request count (browser network log or a request-count contract test) and report the before and after numbers with the change.
44
+
34
45
  ## Design
35
46
 
36
47
  - Follow the existing design language of the touched area.
package/eslint.js CHANGED
@@ -183,6 +183,8 @@ const hasOptionalCallBoundary = (callExpression, ancestors = []) => {
183
183
  return hasOptional || ancestors.some(({ node }) => node.type === 'ChainExpression');
184
184
  };
185
185
 
186
+ // A loop body is iteration, not a condition. `for (const item of batch) item.reject(error)`
187
+ // preserves the error for every item there is, so it is not a swallow.
186
188
  const isUnderConditionalControlFlow = (ancestors = []) =>
187
189
  ancestors.some(({ node, childKey }) => {
188
190
  if (node.type === 'IfStatement') return childKey === 'consequent' || childKey === 'alternate';
@@ -190,13 +192,33 @@ const isUnderConditionalControlFlow = (ancestors = []) =>
190
192
  if (node.type === 'LogicalExpression') return childKey === 'right';
191
193
  if (node.type === 'SwitchCase') return childKey === 'consequent';
192
194
 
193
- return (
194
- ['ForInStatement', 'ForOfStatement', 'ForStatement', 'WhileStatement', 'DoWhileStatement'].includes(
195
- node.type,
196
- ) && childKey === 'body'
197
- );
195
+ return false;
198
196
  });
199
197
 
198
+ /**
199
+ * Does the handler throw, whatever it throws?
200
+ *
201
+ * `catch { throw new Error('must be an absolute URL') }` translates a failure
202
+ * into the domain's own vocabulary. The original object is dropped, but the
203
+ * failure still propagates and nothing continues silently, which is what this
204
+ * rule exists to prevent. Attaching the original as `cause` is better practice,
205
+ * not a separate correctness question for this rule.
206
+ */
207
+ const handlerThrows = (node) => {
208
+ let throws = false;
209
+
210
+ traverseNode(node, (child, _parent, _parentKey, ancestors) => {
211
+ if (child.type === 'ThrowStatement' && !isUnderConditionalControlFlow(ancestors)) throws = true;
212
+ });
213
+
214
+ return throws;
215
+ };
216
+
217
+ // A returned fallback is deliberately NOT accepted. `catch { return null }` is
218
+ // the textbook swallow, and no structural signal separates it from
219
+ // `.catch(() => [])`. Where a fallback really is correct, the call site says so
220
+ // with a disable comment and a reason, which stays greppable.
221
+
200
222
  const defaultErrorReporters = ['app.reportError', 'app.handleError'];
201
223
 
202
224
  /**
@@ -363,6 +385,11 @@ const createSwallowedErrorRule = () => ({
363
385
  const reportHandler = (node, params, body) => {
364
386
  const names = params.flatMap((param) => collectPatternNames(param));
365
387
  if (names.length === 0) {
388
+ // Nothing was bound, so nothing can be routed. Still accepted when
389
+ // the handler translates the failure into a throw, because the
390
+ // failure keeps propagating rather than being continued past.
391
+ if (handlerThrows(body)) return;
392
+
366
393
  context.report({ node, messageId: 'missingParam' });
367
394
  return;
368
395
  }
@@ -373,6 +400,8 @@ const createSwallowedErrorRule = () => ({
373
400
  return;
374
401
  }
375
402
 
403
+ if (handlerThrows(body)) return;
404
+
376
405
  if (!handlerPreservesCaughtError(body, collectDerivedErrorNames(body, names), side, reporters)) {
377
406
  context.report({ node, messageId: 'unpreserved', data: { name: referencedName } });
378
407
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proteum",
3
3
  "description": "LLM-first Opinionated Typescript Framework for web applications.",
4
- "version": "2.5.16",
4
+ "version": "2.5.18",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -240,6 +240,46 @@ test('an error pushed into a result collection counts as preservation', () => {
240
240
  assert.equal(count(messages, swallowedRuleId), 0);
241
241
  });
242
242
 
243
+ test('translating a failure into a new throw counts, even without the original', () => {
244
+ const messages = lint(`
245
+ export const assertUrl = (raw) => {
246
+ try {
247
+ return new URL(raw);
248
+ } catch {
249
+ throw new Error('must be an absolute URL');
250
+ }
251
+ };
252
+ `);
253
+
254
+ assert.equal(count(messages, swallowedRuleId), 0);
255
+ });
256
+
257
+ test('preservation inside a loop body counts, because iteration is not a condition', () => {
258
+ const messages = lint(`
259
+ export const run = (batch) => {
260
+ load().catch((error) => {
261
+ for (const item of batch) {
262
+ item.reject(error);
263
+ }
264
+ });
265
+ };
266
+ `);
267
+
268
+ assert.equal(count(messages, swallowedRuleId), 0);
269
+ });
270
+
271
+ test('a returned fallback is still a swallow, however it is spelled', () => {
272
+ const nullFallback = lint(`
273
+ export const run = () => {
274
+ try { risky(); } catch { return null; }
275
+ };
276
+ `);
277
+ assert.equal(count(nullFallback, swallowedRuleId), 1);
278
+
279
+ const emptyList = lint(`export const run = (d) => dns.resolveMx(d).catch(() => []);`);
280
+ assert.equal(count(emptyList, swallowedRuleId), 1);
281
+ });
282
+
243
283
  test('a console-only catch is still reported', () => {
244
284
  const messages = lint(`
245
285
  export const run = async () => {