@ultimat3/cli 8.0.0 → 10.0.0

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 (64) hide show
  1. package/CLAUDE.md +9 -3
  2. package/package.json +26 -25
  3. package/src/affected.ts +0 -3
  4. package/src/app-boundaries.ts +4 -5
  5. package/src/app-env.ts +7 -2
  6. package/src/app-load.ts +7 -0
  7. package/src/browser-launcher.ts +0 -2
  8. package/src/budgets.ts +4 -3
  9. package/src/cmd-build.ts +2 -2
  10. package/src/cmd-db-branch.ts +2 -2
  11. package/src/cmd-deploy.ts +20 -6
  12. package/src/cmd-docs.ts +2 -1
  13. package/src/cmd-doctor.ts +3 -5
  14. package/src/cmd-env.ts +2 -2
  15. package/src/cmd-fix.ts +2 -4
  16. package/src/cmd-new.ts +2 -2
  17. package/src/cmd-shot.ts +22 -2
  18. package/src/db-finding.ts +2 -2
  19. package/src/db-seed.ts +0 -3
  20. package/src/dev-assets.ts +4 -7
  21. package/src/dev-cache.ts +140 -36
  22. package/src/dev-lock.ts +8 -7
  23. package/src/dev-purge.ts +120 -0
  24. package/src/dev-queue.ts +31 -6
  25. package/src/dev-render.ts +11 -14
  26. package/src/dev-runtime.ts +62 -15
  27. package/src/dev-storage.ts +8 -2
  28. package/src/dev-sync.ts +37 -2
  29. package/src/document-styles.ts +4 -2
  30. package/src/drift.ts +3 -2
  31. package/src/error-codes.ts +9 -2
  32. package/src/error-contract.ts +5 -5
  33. package/src/errors.ts +1 -29
  34. package/src/flag-reads.ts +2 -2
  35. package/src/generate-write.ts +3 -2
  36. package/src/guards.ts +4 -4
  37. package/src/index.ts +2 -6
  38. package/src/island-bundle.ts +34 -10
  39. package/src/island-routes.ts +7 -1
  40. package/src/island-styles.ts +1 -1
  41. package/src/mcp-errors.ts +2 -2
  42. package/src/metrics-endpoint.ts +0 -2
  43. package/src/output.ts +2 -2
  44. package/src/prerender.ts +34 -20
  45. package/src/runtime-overrides.ts +1 -1
  46. package/src/serve.ts +1 -1
  47. package/src/solid-loader.ts +1 -1
  48. package/src/static-report.ts +41 -3
  49. package/src/style-csp.ts +2 -1
  50. package/src/templates/scaffold-container.ts +12 -0
  51. package/src/templates/scaffold-docs.ts +11 -4
  52. package/src/templates/scaffold-domain-package.ts +3 -1
  53. package/src/templates/scaffold-repo.ts +19 -9
  54. package/src/templates/slice-foundation.ts +3 -5
  55. package/src/test-shards.ts +2 -2
  56. package/src/tsconfig-references.ts +2 -2
  57. package/src/verify-checks.ts +3 -2
  58. package/src/verify-floor.ts +8 -6
  59. package/src/verify-run.ts +2 -2
  60. package/src/verify-step.ts +2 -1
  61. package/src/verify-test-run.ts +2 -2
  62. package/src/workspace-checks.ts +10 -12
  63. package/src/workspace-graph.ts +3 -2
  64. package/src/write-line.ts +7 -1
package/src/dev-cache.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  // Which cache tiers this process reads through, and the hop that tells the other replicas what it
2
- // just dropped. `createMemoTier`, `createLruTier` and `createRedisTier` were built, exported and
3
- // tested with ZERO callers — `dev-runtime.ts` registered the CDN tier and nothing else — so every
4
- // cached read was recomputed on every replica on every request, and `registerInvalidationBroadcast`
5
- // had no one to register it.
2
+ // just dropped. The ladder is exactly what `cache.tiers` names: a rung the config lists is built or
3
+ // the boot refuses, and a rung it does not list is never built — this file used to register memo
4
+ // and lru unconditionally, redis on `REDIS_URL` and cdn on any real purge driver, so the key was
5
+ // declared, validated at boot, documented, and read by nothing.
6
6
 
7
+ import { existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
7
9
  import type { CacheTier, PurgeDriver } from '@ultimat3/cache';
8
10
  import {
11
+ CacheDriverUnavailableError,
9
12
  createCdnTier,
10
13
  createLruTier,
11
14
  createMemoTier,
@@ -16,8 +19,11 @@ import {
16
19
  registerTier,
17
20
  resetTiers,
18
21
  } from '@ultimat3/cache';
19
- import { logger } from '@ultimat3/core';
22
+ import type { CacheTierName } from '@ultimat3/core';
23
+ import { CACHE_TIERS, defineConfig, logger, renderThrowable } from '@ultimat3/core';
20
24
  import type { Transport, TransportSubscription } from '@ultimat3/realtime/server';
25
+ import { APP_CONFIG_EXPORT } from './app-auth';
26
+ import { APP_CONFIG_FILE } from './app-root';
21
27
  import type { Env } from './dev-services';
22
28
 
23
29
  /**
@@ -29,24 +35,125 @@ export const CACHE_INVALIDATE_SUBJECT = 'x.cache.invalidate';
29
35
 
30
36
  export interface CacheTiersOptions {
31
37
  readonly env: Env;
32
- /** Already resolved by the boot — the CDN tier is registered only for a real edge. */
38
+ /** Already resolved by the boot — a `cdn` rung is built against a real edge or not at all. */
33
39
  readonly purge: PurgeDriver;
34
40
  readonly transport: Transport;
41
+ /**
42
+ * `config.cache.tiers`, verbatim. REQUIRED, and that is the enforcement (axiom 3): a boot that
43
+ * has not read the app's declaration cannot call this function at all — it is a type error, not
44
+ * a convention to remember. `loadCacheTiers` is what a boot holding only a root calls for it.
45
+ */
46
+ readonly tiers: readonly CacheTierName[];
35
47
  }
36
48
 
37
49
  /**
38
- * The shared tier, or nothing. `REDIS_URL` is the same "an unset variable means the embedded
39
- * default" law the db, events, storage, mail and CDN bindings already follow — and it is the
40
- * variable Bun's own `Bun.redis` reads, so a tier selected here and a client built there cannot
41
- * point at two servers.
50
+ * The rungs an app that declares none gets — ASKED of `defineConfig` rather than written out, for
51
+ * two reasons that point the same way: `defaults()` is private to `@ultimat3/core`, and a second
52
+ * literal list of rung names is a second vocabulary that `bun run render-modes` refuses (it caught
53
+ * exactly that here). One source, so the default cannot drift into a second ladder.
42
54
  */
43
- function sharedTier(env: Env): CacheTier | undefined {
55
+ export const DEFAULT_CACHE_TIERS: readonly CacheTierName[] = defineConfig({
56
+ name: 'cache-defaults',
57
+ }).cache.tiers;
58
+
59
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
60
+ typeof value === 'object' && value !== null;
61
+
62
+ const isTierList = (value: unknown): value is readonly CacheTierName[] =>
63
+ Array.isArray(value) && value.every((entry) => CACHE_TIERS.some((name) => name === entry));
64
+
65
+ /**
66
+ * `cache.tiers` out of the app's own `app.config.ts` — the sibling of `app-auth.ts`'s
67
+ * `loadSignInPath`, and structural for the same reason: `defineConfig` returns a plain object, and
68
+ * a config that resolved through an older core simply has no `cache` section.
69
+ *
70
+ * A list this refuses cannot come from `defineConfig` — `validate()` rejects an unknown rung one
71
+ * `await import` above this line — so the fallback is for a hand-written config object, and the
72
+ * two rungs every app starts from are the honest answer for one.
73
+ */
74
+ export async function loadCacheTiers(root: string): Promise<readonly CacheTierName[]> {
75
+ const configPath = join(root, APP_CONFIG_FILE);
76
+ if (!existsSync(configPath)) return DEFAULT_CACHE_TIERS;
77
+ const module = (await import(configPath)) as Record<string, unknown>;
78
+ const config = module[APP_CONFIG_EXPORT];
79
+ if (!isRecord(config)) return DEFAULT_CACHE_TIERS;
80
+ const cache = config['cache'];
81
+ if (!isRecord(cache)) return DEFAULT_CACHE_TIERS;
82
+ const { tiers } = cache;
83
+ return isTierList(tiers) ? tiers : DEFAULT_CACHE_TIERS;
84
+ }
85
+
86
+ /**
87
+ * `REDIS_URL` is the same "an unset variable means the embedded default" law the db, events,
88
+ * storage, mail and CDN bindings follow — and it is the variable Bun's own `Bun.redis` reads, so a
89
+ * tier selected here and a client built there cannot point at two servers.
90
+ */
91
+ function redisUrl(env: Env): string | undefined {
44
92
  const url = env['REDIS_URL']?.trim();
45
- return url === undefined || url === '' ? undefined : createRedisTier();
93
+ return url === undefined || url === '' ? undefined : url;
46
94
  }
47
95
 
48
96
  /**
49
- * Register the tiers, wire both halves of cross-instance invalidation, and return the release.
97
+ * One rung, or a refusal. A process that cannot build what it was configured to build must not
98
+ * start — `assertRateLimitScope`'s rule (`@ultimat3/http`), applied to the ladder: a fleet reading
99
+ * a per-process cache while `cache.tiers` declares a shared one is a stale-and-slow deployment
100
+ * that looks like a performance problem for a week.
101
+ *
102
+ * `X_CACHE_DRIVER_UNAVAILABLE` is BORROWED from `@ultimat3/cache` rather than twinned: "this tier
103
+ * cannot be built here" is what that code already means, and its shipped `fix:` is this one. The
104
+ * precedent is `dev-assets.ts` throwing `@ultimat3/pwa`'s `PwaIconMissingError`.
105
+ */
106
+ function buildTier(name: CacheTierName, options: CacheTiersOptions): CacheTier {
107
+ switch (name) {
108
+ case 'request-memo':
109
+ return createMemoTier();
110
+ case 'lru':
111
+ return createLruTier();
112
+ case 'redis':
113
+ if (redisUrl(options.env) === undefined) {
114
+ throw new CacheDriverUnavailableError({
115
+ driver: 'redis',
116
+ cause:
117
+ 'cache.tiers names it and REDIS_URL is unset, so this process would read a per-process ladder while the config declares a shared one',
118
+ fix: 'set REDIS_URL in .env, or drop the redis tier from cache.tiers in app.config.ts',
119
+ });
120
+ }
121
+ return createRedisTier();
122
+ case 'cdn':
123
+ // A noop tier would put a `cdn` line in every invalidation report claiming keys an edge that
124
+ // does not exist had accepted — and the `/_x` cache panel renders those reports, so the lie
125
+ // would be the thing an agent reads.
126
+ if (isNoopPurgeDriver(options.purge)) {
127
+ throw new CacheDriverUnavailableError({
128
+ driver: 'cdn',
129
+ cause:
130
+ 'cache.tiers names it and no CDN credential is set, so every invalidation report would claim keys an edge that does not exist had accepted',
131
+ fix: 'set FASTLY_API_TOKEN and FASTLY_SERVICE_ID in .env, or CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID, or drop the cdn tier from cache.tiers in app.config.ts',
132
+ });
133
+ }
134
+ return createCdnTier({ purge: options.purge });
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The one case where the declaration and the environment disagree and the boot still proceeds: a
140
+ * credential is set for a rung `cache.tiers` does not name. The config wins — it is the
141
+ * declaration, an env var is deployment detail — but silently paying for a Redis or an edge
142
+ * nothing reads is the same class of surprise the refusals above exist for, so it is said out loud.
143
+ */
144
+ function warnUnnamed(options: CacheTiersOptions): void {
145
+ const named = new Set(options.tiers);
146
+ if (!named.has('redis') && redisUrl(options.env) !== undefined) {
147
+ logger.warn('cache.tier.unnamed', { tier: 'redis', source: 'REDIS_URL' });
148
+ }
149
+ if (!named.has('cdn') && !isNoopPurgeDriver(options.purge)) {
150
+ logger.warn('cache.tier.unnamed', { tier: 'cdn', source: options.purge.name });
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Register the tiers the app declared, wire both halves of cross-instance invalidation, and return
156
+ * the release.
50
157
  *
51
158
  * The outbound half publishes the wire tags this process just dropped; the inbound half applies
52
159
  * another instance's. A message this process published is delivered back to it on every real bus
@@ -56,25 +163,15 @@ function sharedTier(env: Env): CacheTier | undefined {
56
163
  * suppresses it, and `emit` is not a public parameter.
57
164
  */
58
165
  export function startCacheTiers(options: CacheTiersOptions): () => Promise<void> {
59
- // Request-scoped memo first, then the process-local LRU: both are free of external state, so
60
- // they are the "embedded default" that needs no variable to switch on. Registration order does
61
- // not decide read order — `sortTiers` does — but it is written in read order anyway.
62
- registerTier(createMemoTier());
63
- registerTier(createLruTier());
64
- const shared = sharedTier(options.env);
65
- if (shared !== undefined) registerTier(shared);
66
- // Nothing installs a read tier here, and that is the point. `@ultimat3/query` used to own a
67
- // private `ReadCache` this boot had to hand-wire over one of the objects above, because
68
- // `invalidateTags` fans out to registered tiers and to nothing else — so a read cache holding
69
- // entries of its own was a `cache:` query an action's `invalidates` could never bust. The seam
70
- // is gone: a `cache:` read fills the ladder registered here, so there is one registry and one
71
- // fan-out and no wiring to get wrong.
72
- // Registered only when a credential named a real edge. A noop tier would put a `cdn` line in
73
- // every invalidation report claiming keys an edge that does not exist had accepted — and the
74
- // `/_x` cache panel renders those reports, so the lie would be the thing an agent reads.
75
- if (!isNoopPurgeDriver(options.purge)) {
76
- registerTier(createCdnTier({ purge: options.purge }));
77
- }
166
+ // Built before ANY of them is registered: a refusal halfway through a list would leave the
167
+ // process-global registry holding the rungs that came first, with no release returned to drop
168
+ // them. Registration order does not decide read order — `sortTiers` does, by `CACHE_TIERS`.
169
+ // Nothing installs a read tier beyond these, and that is the point: `invalidateTags` fans out to
170
+ // registered tiers and to nothing else, so a read cache holding entries of its own was a
171
+ // `cache:` query an action's `invalidates` could never bust.
172
+ const built = options.tiers.map((name) => buildTier(name, options));
173
+ for (const tier of built) registerTier(tier);
174
+ warnUnnamed(options);
78
175
 
79
176
  registerInvalidationBroadcast(async (wireTags) => {
80
177
  await options.transport.publish(CACHE_INVALIDATE_SUBJECT, JSON.stringify(wireTags));
@@ -90,7 +187,7 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
90
187
  void applyBroadcast(payload);
91
188
  })
92
189
  .catch((error: unknown) => {
93
- logger.warn('cache.broadcast.subscribe-failed', { error: messageOf(error) });
190
+ logger.warn('cache.broadcast.subscribe-failed', { error: broadcastErrorText(error) });
94
191
  return undefined;
95
192
  });
96
193
 
@@ -102,8 +199,15 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
102
199
  };
103
200
  }
104
201
 
105
- const messageOf = (error: unknown): string =>
106
- error instanceof Error ? error.message : 'unknown error';
202
+ /**
203
+ * `renderThrowable`, never `instanceof Error` + `.message`. Both run on a value this process did
204
+ * not build — a `Proxy` traps `getPrototypeOf` and a `message` getter can raise — and a throw here
205
+ * is inside the handler whose whole job is to keep the subscriber loop alive: losing it ends
206
+ * cross-instance cache invalidation for the process, quietly, which is the failure the loop's own
207
+ * `try` exists to prevent. The old form also answered `'unknown error'` for every non-`Error`
208
+ * throw, so a driver rejecting with a string reported nothing at all.
209
+ */
210
+ export const broadcastErrorText = (error: unknown): string => renderThrowable(error);
107
211
 
108
212
  /**
109
213
  * A peer's wire tags, applied here. Never throws: a malformed frame or an undeclared tag must not
@@ -117,6 +221,6 @@ async function applyBroadcast(payload: string): Promise<void> {
117
221
  const wire = parsed.filter((value): value is string => typeof value === 'string');
118
222
  if (wire.length > 0) await receiveInvalidationBroadcast(wire);
119
223
  } catch (error) {
120
- logger.warn('cache.broadcast.apply-failed', { error: messageOf(error) });
224
+ logger.warn('cache.broadcast.apply-failed', { error: broadcastErrorText(error) });
121
225
  }
122
226
  }
package/src/dev-lock.ts CHANGED
@@ -16,8 +16,7 @@
16
16
 
17
17
  import { closeSync, mkdirSync, openSync, unlinkSync, writeFileSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
- import { UltimateError } from '@ultimat3/core';
20
- import { docsFor } from './error-codes';
19
+ import { stringField, UltimateError } from '@ultimat3/core';
21
20
  import { exec, type Runner } from './exec';
22
21
  import { quoteArg } from './shell-quote';
23
22
 
@@ -68,7 +67,11 @@ export const isProcessAlive = (pid: number): boolean => {
68
67
  return true;
69
68
  } catch (error) {
70
69
  // EPERM means it exists and belongs to another user. Alive, and not ours to signal.
71
- return (error as { code?: string }).code === 'EPERM';
70
+ // `stringField`, never a cast plus a property read: the rule `metrics-endpoint.ts` states and
71
+ // `caught-value-reads.test.ts` enforces — a getter that throws would take this path down one
72
+ // line before the guard meant to make it safe, and this guard decides whether a second `x dev`
73
+ // is allowed to open a single-writer data directory.
74
+ return stringField(error, 'code') === 'EPERM';
72
75
  }
73
76
  };
74
77
 
@@ -96,7 +99,6 @@ export class DevAlreadyRunningError extends UltimateError {
96
99
  code: 'X_DEV_ALREADY_RUNNING',
97
100
  cause: `pid ${input.lock.pid} is already running x dev on ${input.lock.url} and holds ${input.stateDir}${single}`,
98
101
  fix: `use the one already running at ${input.lock.url}, or stop it: kill ${input.lock.pid}`,
99
- docs: docsFor('X_DEV_ALREADY_RUNNING'),
100
102
  meta: { pid: input.lock.pid, port: input.lock.port, stateDir: input.stateDir },
101
103
  });
102
104
  }
@@ -118,7 +120,6 @@ export class DevLockUnreadableError extends UltimateError {
118
120
  code: 'X_DEV_LOCK_UNREADABLE',
119
121
  cause: `${input.path} could not be parsed as a dev lock and could not be removed, so x dev cannot tell whether another process owns ${input.stateDir}`,
120
122
  fix: `rm ${quoteArg(input.path)} # then re-run x dev`,
121
- docs: docsFor('X_DEV_LOCK_UNREADABLE'),
122
123
  meta: { path: input.path, stateDir: input.stateDir },
123
124
  });
124
125
  }
@@ -198,7 +199,6 @@ export class DevPortInUseError extends UltimateError {
198
199
  holder.pid === undefined
199
200
  ? `x dev --port ${input.suggestion}`
200
201
  : `x dev --port ${input.suggestion} # or free it, if that pid is yours: kill ${holder.pid}`,
201
- docs: docsFor('X_PORT_IN_USE'),
202
202
  meta: { port: input.port, ...holder },
203
203
  });
204
204
  }
@@ -247,7 +247,8 @@ function claimExclusive(path: string, lock: DevLock): boolean {
247
247
  try {
248
248
  fd = openSync(path, 'wx');
249
249
  } catch (error) {
250
- if ((error as { code?: string }).code === 'EEXIST') return false;
250
+ // Same rule as `isProcessAlive` above: read the field, never cast and dereference.
251
+ if (stringField(error, 'code') === 'EEXIST') return false;
251
252
  throw error;
252
253
  }
253
254
  try {
@@ -0,0 +1,120 @@
1
+ // The retention sweep this boot owns: the three framework tables that grow with traffic, the
2
+ // `purge()` job that empties them and the `task` that fires it hourly.
3
+ //
4
+ // WHY here and not in the packages that own the tables: `postgresIdempotencyStore` (tier 3),
5
+ // `postgresRateLimitStore` (tier 2) and `postgresAuthLimiter` (tier 2) cannot see each other and
6
+ // none of them may import `@ultimat3/jobs`. Boot is the one place that holds all three, which is
7
+ // the same reason it is boot that applies their DDL and installs them.
8
+ //
9
+ // WHY they had no caller at all until now: each store shipped a `purgeExpired()` documented as
10
+ // "an app runs this from a `task`" — and a task only ENQUEUES, so there was no job for one to
11
+ // enqueue and no app wrote either half. `x_rate_limit` takes one upsert per HTTP request the web
12
+ // role serves, assets included, so every deployment the framework produces was accumulating a row
13
+ // per source address forever.
14
+
15
+ import type { PostgresIdempotencyStore } from '@ultimat3/action';
16
+ import { purgeAuthLimits } from '@ultimat3/auth';
17
+ import type { PostgresRateLimitStore } from '@ultimat3/http';
18
+ import type { JobHandle, PurgeInput, PurgeTarget } from '@ultimat3/jobs';
19
+ import { DEFAULT_PURGE_CRON, getJob, getTask, purge, task } from '@ultimat3/jobs';
20
+
21
+ /** The durable queue key. Pinned, like every framework-owned job name — rows carry it. */
22
+ export const PURGE_JOB_NAME = 'x.purge';
23
+ /** The scheduler's key for the same sweep: `lastFiredAt` and the occurrence lock read it. */
24
+ export const PURGE_TASK_NAME = 'x.purge.hourly';
25
+
26
+ /**
27
+ * The two stores this boot BUILT, handed over rather than rebuilt here. A second
28
+ * `postgresIdempotencyStore({ executor })` would sweep on the default window even where the boot
29
+ * had configured another — two answers to "how long is a record kept", and the shorter one
30
+ * deletes reservations a retry is still entitled to.
31
+ *
32
+ * The auth limiter is absent on purpose: it does not exist yet at boot. `defineAuth` builds it
33
+ * through the factory `configureAuthLimiters` installed, and that happens when the app's modules
34
+ * import — after this. `purgeAuthLimits()` is the seam that reads whatever was built.
35
+ */
36
+ export interface RetentionStores {
37
+ readonly idempotency: PostgresIdempotencyStore;
38
+ readonly rateLimit: PostgresRateLimitStore;
39
+ }
40
+
41
+ /**
42
+ * Read per attempt by the job, and emptied by the disposer `installRetentionSweep` returns: a
43
+ * sweep left declared after its boot has stopped must not reach through a closed pool. Module
44
+ * scope for the reason `setJobDriver` and `configureKdfGate` are — a process has one boot at a
45
+ * time, and the registry the job lives in is process-wide too.
46
+ */
47
+ let installed: readonly PurgeTarget[] = [];
48
+
49
+ /** Declared once per process and re-declared only if a `resetJobs()` took it out of the registry. */
50
+ let sweep: JobHandle<PurgeInput> | undefined;
51
+
52
+ /**
53
+ * `x_auth_failures` and `x_auth_lockouts` together, under the prefix they share: one target,
54
+ * because `purgeAuthLimits()` clears both in one statement and two targets under one pair of
55
+ * tables would be a second delete that removes nothing.
56
+ */
57
+ const authTarget: PurgeTarget = {
58
+ name: 'x_auth',
59
+ // No `nowMs`: a limiter built through the seam holds the clock its host handed it, which is the
60
+ // clock every `at_ms` in those tables was written from.
61
+ purgeExpired: () => purgeAuthLimits(),
62
+ };
63
+
64
+ function retentionTargets(stores: RetentionStores): readonly PurgeTarget[] {
65
+ return [
66
+ {
67
+ name: 'x_idempotency',
68
+ purgeExpired: (): Promise<number> => stores.idempotency.purgeExpired(),
69
+ },
70
+ {
71
+ name: 'x_rate_limit',
72
+ // The job's clock, not the server's. `last_ms` is written by whichever process took the
73
+ // token, so a purge measured against `now()` in Postgres computes a refill from the offset
74
+ // between two clocks — and against a frozen one it read 20,000,000 seconds of refill and
75
+ // deleted a bucket holding 0 of 4 tokens, which is a free limit reset from the cleanup.
76
+ purgeExpired: (nowMs: number): Promise<number> => stores.rateLimit.purgeExpired(nowMs),
77
+ },
78
+ authTarget,
79
+ ];
80
+ }
81
+
82
+ /**
83
+ * Declared lazily and guarded on the registry rather than on a module flag alone: `resetJobs()` /
84
+ * `resetTasks()` empty the registries a test shares with the next boot, and a memoised handle that
85
+ * is no longer seated is one the worker's `getJob` can never find.
86
+ */
87
+ function declareSweep(): void {
88
+ if (getJob(PURGE_JOB_NAME) === undefined) {
89
+ sweep = purge({ name: PURGE_JOB_NAME, targets: () => installed });
90
+ }
91
+ const handle = sweep;
92
+ if (handle !== undefined && getTask(PURGE_TASK_NAME) === undefined) {
93
+ task({
94
+ name: PURGE_TASK_NAME,
95
+ cron: DEFAULT_PURGE_CRON,
96
+ // The one zone a framework-shipped schedule may name: an app's business hours are the app's,
97
+ // and a retention sweep has none. `tz` is required by `task()` and never inferred.
98
+ tz: 'UTC',
99
+ // `skip`, the default: a scheduler that was down across four occurrences has four sweeps'
100
+ // worth of expired rows in one table, and one pass removes all of them. Replaying the missed
101
+ // occurrences would be three passes that each delete nothing.
102
+ enqueue: () => [[handle, {}]],
103
+ });
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Put the sweep on the queue's registry and its schedule, and answer the release.
109
+ *
110
+ * IF NOTHING EVER RUNS IT the tables grow exactly as they did before — the sweep is a `job`, so it
111
+ * needs a `worker` to claim it and a `scheduler` to enqueue it. A deployment with neither has no
112
+ * background work at all, and this is one more thing it does not do.
113
+ */
114
+ export function installRetentionSweep(stores: RetentionStores): () => void {
115
+ installed = retentionTargets(stores);
116
+ declareSweep();
117
+ return () => {
118
+ installed = [];
119
+ };
120
+ }
package/src/dev-queue.ts CHANGED
@@ -5,11 +5,13 @@
5
5
  // a driver over a closed socket.
6
6
 
7
7
  import {
8
+ type PostgresIdempotencyStore,
8
9
  postgresIdempotencyStore,
9
10
  resetIdempotency,
10
11
  SQL_IDEMPOTENCY_TABLE,
11
12
  setIdempotencyStore,
12
13
  } from '@ultimat3/action';
14
+ import { SQL_AUTH_LIMIT_TABLES } from '@ultimat3/auth';
13
15
  import type { DbClient, PgliteClient, PostgresClient, SqlFragment } from '@ultimat3/db';
14
16
  import {
15
17
  createPgliteClient,
@@ -50,6 +52,13 @@ export interface RunningQueue {
50
52
  readonly outbox: OutboxStore;
51
53
  /** The `x_job_events` bus a `step.waitForEvent` resumes from. Durable, not per-process. */
52
54
  readonly events: EventBus;
55
+ /**
56
+ * The `x_idempotency` store this boot installed behind `idempotent: true`. Returned for the
57
+ * reason `outbox` is: the retention sweep over that table is a ROLE's work, not the queue's,
58
+ * and it has to sweep the store that was INSTALLED — a second one built beside it would purge
59
+ * on the default window even where this boot had configured another.
60
+ */
61
+ readonly idempotency: PostgresIdempotencyStore;
53
62
  stop(): Promise<void>;
54
63
  }
55
64
 
@@ -86,17 +95,25 @@ export function pgExecutorFor(client: DbClient): PgExecutor {
86
95
  * applied statement by statement. Safe to split on `;`: every constant is fixed, with no semicolon
87
96
  * inside a literal, and each package's own SQL test is where that stays true.
88
97
  *
89
- * `SQL_IDEMPOTENCY_TABLE` and `SQL_RATE_LIMIT_TABLE` are here and not in `@ultimat3/action` or
90
- * `@ultimat3/http` because a package that holds no database dependency cannot apply its own schema
98
+ * `SQL_IDEMPOTENCY_TABLE`, `SQL_RATE_LIMIT_TABLE` and `SQL_AUTH_LIMIT_TABLES` are here and not in
99
+ * `@ultimat3/action`, `@ultimat3/http` or `@ultimat3/auth` because a package that holds no
100
+ * database dependency cannot apply its own schema
91
101
  * — the same reason `SQL_JOBS_TABLE` is applied here. Each one absent is the same failure at a
92
102
  * different door: a retried `POST /api/payments/charge` charges the card twice, and the FIRST
93
103
  * request a `rateLimitStore` deployment serves dies on a missing `x_rate_limit` relation. The
94
104
  * table is installed whether or not this boot passes `runtime.rateLimitStore` — `create table if
95
105
  * not exists` on an unused table costs one round trip at boot, and a store installed later must
96
- * not be the thing that discovers the schema was never applied.
106
+ * not be the thing that discovers the schema was never applied. The auth pair is the strongest
107
+ * case for that rule: `defineAuth` builds its limiter when the APP's modules import, which is
108
+ * after this, so the first failed sign-in would otherwise be what discovers the missing relation.
97
109
  */
98
110
  async function applySchema(client: DevDbClient): Promise<void> {
99
- for (const ddl of [SQL_JOBS_TABLE, SQL_IDEMPOTENCY_TABLE, SQL_RATE_LIMIT_TABLE]) {
111
+ for (const ddl of [
112
+ SQL_JOBS_TABLE,
113
+ SQL_IDEMPOTENCY_TABLE,
114
+ SQL_RATE_LIMIT_TABLE,
115
+ SQL_AUTH_LIMIT_TABLES,
116
+ ]) {
100
117
  for (const statement of ddl.split(';')) {
101
118
  if (statement.trim().length > 0) await client.execute(raw(statement));
102
119
  }
@@ -146,8 +163,16 @@ async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Pro
146
163
  );
147
164
  const events = createPgEventBus({ executor });
148
165
  setEventBus(events);
149
- setIdempotencyStore(postgresIdempotencyStore({ executor }));
150
- return { db: client, jobs: driver, outbox, events, stop: () => releaseQueue(client, driver) };
166
+ const idempotency = postgresIdempotencyStore({ executor });
167
+ setIdempotencyStore(idempotency);
168
+ return {
169
+ db: client,
170
+ jobs: driver,
171
+ outbox,
172
+ events,
173
+ idempotency,
174
+ stop: () => releaseQueue(client, driver),
175
+ };
151
176
  }
152
177
 
153
178
  /**
package/src/dev-render.ts CHANGED
@@ -11,32 +11,29 @@ import type { Ctx } from '@ultimat3/core';
11
11
  import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
12
12
  import { asCtx, html, stream } from '@ultimat3/http';
13
13
  import { currentLocale } from '@ultimat3/i18n';
14
- import type {
15
- IslandCollector,
16
- IsrController,
17
- RenderResult,
18
- RouteData,
19
- RouteEntry,
20
- } from '@ultimat3/render';
14
+ import type { IslandCollector, RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
21
15
  import {
22
- contentHash,
23
16
  createIslandCollector,
24
- createIsrController,
25
17
  headFromMeta,
26
18
  hydrateRuntime,
27
- isrKey,
28
19
  metaContextFor,
29
- ROOT_ELEMENT_ID,
30
- renderComponent,
31
20
  renderHead,
32
- renderSsr,
33
21
  routeDataFor,
34
22
  routeEntries,
35
23
  seoRenderers,
24
+ } from '@ultimat3/render';
25
+ import type { IsrController } from '@ultimat3/render/server';
26
+ import {
27
+ contentHash,
28
+ createIsrController,
29
+ isrKey,
30
+ ROOT_ELEMENT_ID,
31
+ renderComponent,
32
+ renderSsr,
36
33
  staticHeaders,
37
34
  streamResult,
38
35
  stylesFor,
39
- } from '@ultimat3/render';
36
+ } from '@ultimat3/render/server';
40
37
 
41
38
  /**
42
39
  * Specifier → built chunk URL, bound to the route file the specifier is written relative to.