@k2b/cloud 0.19.0 → 0.21.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 (42) hide show
  1. package/package.json +14 -14
  2. package/scripts/build.ts +3 -1
  3. package/scripts/sync-recovery-smoke.ts +1 -4
  4. package/src/_internal/define-app.ts +4 -0
  5. package/src/_internal/font-preloads.ts +25 -0
  6. package/src/_internal/nats-connection.ts +3 -10
  7. package/src/_internal/process-sync.ts +100 -21
  8. package/src/_internal/status-preserving-ssr.ts +9 -1
  9. package/src/_internal/sync-budget.ts +306 -0
  10. package/src/access/PermissionEditor.tsx +31 -12
  11. package/src/access/PrincipalPicker.tsx +23 -19
  12. package/src/access/messages.ts +2 -0
  13. package/src/access/service-account-kind.ts +28 -0
  14. package/src/account/EntitySearch.tsx +9 -8
  15. package/src/ai/chat-quotas.ts +10 -6
  16. package/src/ai/code-mode-skill.ts +2 -2
  17. package/src/ai/inference-calls.ts +2 -2
  18. package/src/ai/model-access.ts +5 -2
  19. package/src/ai/projects.ts +7 -2
  20. package/src/ai/quota-report.ts +6 -1
  21. package/src/ai/quotas.ts +1 -1
  22. package/src/ai/skills.ts +7 -2
  23. package/src/cli/access.ts +1 -0
  24. package/src/config/env.ts +5 -0
  25. package/src/server/locale.ts +17 -4
  26. package/src/server/middleware/validator.ts +23 -1
  27. package/src/services/gateway.ts +5 -0
  28. package/src/services/index.ts +2 -0
  29. package/src/services/pdf/index.ts +2 -0
  30. package/src/services/pdf/markdown.ts +50 -12
  31. package/src/services/settings/core-settings.ts +2 -0
  32. package/src/shared/ai-quotas.ts +16 -3
  33. package/src/ssr/LayoutPreferences.island.tsx +2 -17
  34. package/src/ssr/MinimalLayout.tsx +44 -20
  35. package/src/ssr/MinimalLayoutPreferences.island.tsx +52 -0
  36. package/src/ssr/PageError.tsx +1 -1
  37. package/src/ssr/profile-preferences-messages.ts +4 -0
  38. package/src/styles/resource-search.css +2 -2
  39. package/src/styles/utilities-feedback.css +2 -2
  40. package/src/styles/utilities-markdown-editor.css +2 -2
  41. package/src/styles/utilities-navigation.css +94 -20
  42. package/src/types/k2b-ui-fonts.d.ts +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -93,23 +93,23 @@
93
93
  "@firecrawl/anydoc": "0.2.4",
94
94
  "@napi-rs/canvas": "1.0.9",
95
95
  "pdfjs-dist": "6.3.289",
96
- "@hono/standard-validator": "0.2.2",
97
- "@simplewebauthn/server": "14.0.2",
98
- "@tabler/icons": "3.47.0",
96
+ "@simplewebauthn/server": "14.0.3",
97
+ "@tabler/icons": "3.48.0",
99
98
  "@tailwindcss/typography": "0.5.20",
100
99
  "@k2b/nessi": "0.12.1",
101
100
  "@k2b/ssr": "0.14.0",
102
- "@k2b/ui": "0.9.0",
103
- "@k2b/stdlib": "0.26.0",
104
- "@k2b/sync": "6.5.0",
101
+ "@k2b/ui": "0.10.0",
102
+ "@k2b/stdlib": "0.27.0",
103
+ "@k2b/sync": "7.0.0",
104
+ "@nats-io/jetstream": "3.4.0",
105
105
  "@nats-io/transport-node": "3.4.0",
106
- "@modelcontextprotocol/sdk": "1.30.0",
106
+ "@modelcontextprotocol/sdk": "1.30.1",
107
107
  "bun-plugin-tailwind": "0.1.2",
108
- "hono-openapi": "1.3.1",
108
+ "hono-openapi": "1.3.3",
109
109
  "jose": "6.2.12",
110
- "katex": "0.18.7",
110
+ "katex": "0.18.9",
111
111
  "liquidjs": "10.29.0",
112
- "marked": "17.0.6",
112
+ "marked": "18.0.14",
113
113
  "mermaid": "12.0.0",
114
114
  "nodemailer": "10.0.10",
115
115
  "postcss": "8.5.28",
@@ -129,10 +129,10 @@
129
129
  "@babel/preset-typescript": "8.0.1",
130
130
  "@types/bun": "1.4.2",
131
131
  "babel-preset-solid": "1.9.15",
132
- "hono": "4.13.8",
133
- "playwright": "1.62.0",
132
+ "hono": "4.13.9",
133
+ "playwright": "1.63.0",
134
134
  "solid-js": "1.9.15",
135
- "typescript": "5.9.3",
135
+ "typescript": "7.0.2",
136
136
  "zod": "4.6.5"
137
137
  },
138
138
  "peerDependencies": {
package/scripts/build.ts CHANGED
@@ -32,6 +32,7 @@ import { promisify } from "node:util";
32
32
  import { brotliCompress, gzip, constants as zlibConstants } from "node:zlib";
33
33
  import { Glob } from "bun";
34
34
  import tailwind from "bun-plugin-tailwind";
35
+ import type { AppCliModules } from "../src/contracts/app";
35
36
  import { writeAppFavicon } from "./app-favicon";
36
37
  import { buildBrowserPerformance } from "./browser-performance";
37
38
  import { buildPdfRenderer } from "./build-pdf-renderer";
@@ -181,7 +182,8 @@ if (existsSync(appAssets)) {
181
182
  // `cld` plugins: one self-contained module bundle plus references per module.
182
183
  // Imported after the app config so the framework sees the APP_DIR set above.
183
184
  const { buildCliPlugin, writeCliPlugin } = await import("../src/_internal/cli-plugins");
184
- for (const [name, declaration] of Object.entries(app?.meta.cli ?? {})) {
185
+ const cliModules: AppCliModules = app?.meta.cli ?? {};
186
+ for (const [name, declaration] of Object.entries(cliModules)) {
185
187
  const plugin = await buildCliPlugin({ appDir, appId, name, declaration, version });
186
188
  await writeCliPlugin(resolve(dist, "cli", name), plugin);
187
189
  }
@@ -68,10 +68,7 @@ Run "prepare", restart the application fleet while NATS stays up, then run "reco
68
68
  check(phase === "prepare" || phase === "recover", "Pass prepare or recover");
69
69
  const namespace = options.namespace?.trim() ?? "";
70
70
  check(/^cloud-recovery-smoke-[A-Za-z0-9_-]{6,60}$/.test(namespace), "Pass a unique --namespace cloud-recovery-smoke-<suffix>");
71
- const servers = (env.NATS_SERVERS ?? "nats://127.0.0.1:4222")
72
- .split(",")
73
- .map((value) => value.trim())
74
- .filter(Boolean);
71
+ const servers = env.NATS_SERVERS.length ? env.NATS_SERVERS : ["nats://127.0.0.1:4222"];
75
72
  const connection = await connect({ servers, name: `${APPLICATION}-${phase}`, ignoreClusterUpdates: true });
76
73
  const sync = createSync({ connection, namespace, application: APPLICATION, defaults: { replicas: env.SYNC_REPLICAS } });
77
74
  const job = sync.job<{ preparedAt: string }>({
@@ -48,6 +48,7 @@ import { requireInvocation } from "../server/middleware/invocation";
48
48
  import { matchedRouteTemplate, routeTemplate } from "../server/middleware/route-template";
49
49
  import { runtime as runtimeMiddleware } from "../server/middleware/runtime";
50
50
  import { preloadLayoutAnnouncements, settings as settingsMiddleware } from "../server/middleware/settings";
51
+ import { validationErrorResponse } from "../server/middleware/validator";
51
52
  import {
52
53
  capabilityInvocationOperation,
53
54
  searchInvocationOperation,
@@ -72,6 +73,7 @@ import { readBoundedJson } from "./bounded-json";
72
73
  import { appRuntimeMetadata } from "./build-metadata";
73
74
  import { compileCapabilities, invokeCompiledCapability, reviewCompiledCapability, serializeCapabilityProviderResult } from "./capabilities";
74
75
  import { cliPluginRoutePrefixes, createCliPluginRoutes, validateAppCliModules } from "./cli-plugins";
76
+ import { FONT_PRELOAD_LINKS } from "./font-preloads";
75
77
  import { createHeartbeat } from "./heartbeat";
76
78
  import { compileHelp } from "./help";
77
79
  import { createPageResponses } from "./page-responses";
@@ -355,6 +357,7 @@ export const defineApp = <
355
357
  <link rel="icon" href="${appFaviconHref(opts.id, v)}">
356
358
  <style data-cloud-css-layers>@layer properties, theme, base, components, utilities;</style>
357
359
  <link rel="preload" href="/public/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
360
+ ${FONT_PRELOAD_LINKS}
358
361
  <link rel="stylesheet" href="/public/fonts.css?v=${v}">
359
362
  <link rel="stylesheet" href="/public/tabler-icons.css?v=${v}">
360
363
  <link rel="stylesheet" href="/public/${opts.id}/app.css?v=${v}">
@@ -774,6 +777,7 @@ export const defineApp = <
774
777
  if (advertiseOpenapi) {
775
778
  const apiPrefix = opts.openapi!.replace(/\/openapi\.json$/, "") || "/";
776
779
  const spec = await generateSpecs(startOpts.openapi!, {
780
+ defaultValidationErrorResponse: validationErrorResponse,
777
781
  documentation: {
778
782
  info: {
779
783
  title: meta.name,
@@ -0,0 +1,25 @@
1
+ /// <reference path="../types/k2b-ui-fonts.d.ts" />
2
+ import plexPreset from "@k2b/ui/fonts/plex.css" with { type: "text" };
3
+
4
+ /**
5
+ * Preload tags for the IBM Plex Sans faces that every page sets in its first
6
+ * frame: Latin text at 400, 500 (buttons, labels) and 600 (headings).
7
+ *
8
+ * The preset declares its faces with `font-display: swap`. Without a preload
9
+ * the browser requests a face only when layout needs it, paints the fallback
10
+ * font and re-wraps the text once Plex arrives. That moves every box after the
11
+ * first changed line, and on a vertically centered page such as sign-in the
12
+ * whole form. Preloaded, the faces download next to the render-blocking
13
+ * stylesheets and the first frame already uses them.
14
+ *
15
+ * Core serves the faces under `/public/fonts/` with the preset's
16
+ * content-hashed file names (`packages/core/scripts/font-assets.ts`). The
17
+ * preset is bundled as text, so production servers need no `node_modules`.
18
+ */
19
+ export const FONT_PRELOAD_LINKS = ["400", "500", "600"]
20
+ .map((weight) => {
21
+ const file = new RegExp(`url\\(\\./fonts/(ibm-plex-sans-latin-${weight}-normal-[0-9a-f]+\\.woff2)\\)`).exec(plexPreset)?.[1];
22
+ if (!file) throw new Error(`The @k2b/ui Plex preset has no Latin IBM Plex Sans ${weight} face`);
23
+ return `<link rel="preload" href="/public/fonts/${file}" as="font" type="font/woff2" crossorigin>`;
24
+ })
25
+ .join("\n ");
@@ -12,15 +12,9 @@ export type ConnectNatsOptions = {
12
12
  name: string;
13
13
  };
14
14
 
15
- export const connectNats = async ({ name }: ConnectNatsOptions): Promise<NatsConnection> => {
16
- if (env.NATS_SERVERS.length === 0) {
17
- throw new Error(
18
- `NATS_SERVERS is not set (connection "${name}"). @k2b/sync needs a comma-separated list of ` +
19
- "nats://host:port bootstrap servers, e.g. NATS_SERVERS=nats://ipa_nats_1:4222,nats://ipa_nats_2:4222.",
20
- );
21
- }
22
-
23
- return connect({
15
+ /** Dial `NATS_SERVERS` once. `startProcessSync()` requires the list and retries while NATS does not answer. */
16
+ export const connectNats = async ({ name }: ConnectNatsOptions): Promise<NatsConnection> =>
17
+ connect({
24
18
  servers: env.NATS_SERVERS,
25
19
  name,
26
20
  ignoreClusterUpdates: env.NATS_IGNORE_CLUSTER_UPDATES,
@@ -31,4 +25,3 @@ export const connectNats = async ({ name }: ConnectNatsOptions): Promise<NatsCon
31
25
  ...(env.NATS_CREDS_FILE ? { authenticator: credsAuthenticator(readFileSync(env.NATS_CREDS_FILE)) } : {}),
32
26
  ...(env.NATS_TLS_CA_FILE ? { tls: { caFile: env.NATS_TLS_CA_FILE } } : {}),
33
27
  });
34
- };
@@ -8,10 +8,14 @@
8
8
  * nothing touches sync during module evaluation.
9
9
  */
10
10
  import { hostname } from "node:os";
11
- import { createSync, type Sync } from "@k2b/sync";
11
+ import { createSync, type Sync, SyncError } from "@k2b/sync";
12
+ import { expBackoff } from "@k2b/sync/retry";
13
+ import type { NatsConnection } from "@nats-io/transport-node";
12
14
  import { env } from "../config/env";
15
+ import { logger } from "../services/logging";
13
16
  import { flushSyncTraceEvents, observeSyncEvent } from "../services/logging/trace";
14
17
  import { connectNats } from "./nats-connection";
18
+ import { withSyncBudgets } from "./sync-budget";
15
19
 
16
20
  let current: Sync | undefined;
17
21
  let starting = false;
@@ -64,7 +68,94 @@ export type ProcessSync = {
64
68
  stop: () => Promise<void>;
65
69
  };
66
70
 
67
- /** Connect NATS, create and bind the process Sync instance, and wait until it is ready. */
71
+ /**
72
+ * How long a starting process waits for NATS and JetStream. After a host or
73
+ * stack restart, NATS nodes come up in any order and JetStream must recover
74
+ * its streams and elect a leader before its API answers; five minutes covers
75
+ * that. A process that still gets no answer exits instead of staying alive
76
+ * without readiness, so Docker or Kubernetes restarts it and the outage shows
77
+ * up as restarts.
78
+ */
79
+ const NATS_STARTUP_BUDGET_MS = 5 * 60_000;
80
+ // About 1 s, doubling to 20 s, each ±50 % so processes that restarted together spread their retries.
81
+ const NATS_STARTUP_BACKOFF = { baseMs: 1_000, maxMs: 20_000, jitter: 0.5 };
82
+
83
+ const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
84
+
85
+ /**
86
+ * Run `attempt` until it succeeds, with exponential backoff and jitter. A
87
+ * `SyncError` describes the server or the declarations (unsupported version,
88
+ * JetStream disabled, drift), which waiting does not change, so it fails at
89
+ * once. Every other failure (refused or unresolved address, timeout, JetStream
90
+ * without a leader) is logged and retried until `budgetMs` is spent; then the
91
+ * process exits with status 1.
92
+ */
93
+ export const waitForNats = async <T>(
94
+ attempt: () => Promise<T>,
95
+ { application, budgetMs = NATS_STARTUP_BUDGET_MS }: { application: string; budgetMs?: number },
96
+ ): Promise<T> => {
97
+ const log = logger("sync");
98
+ const deadline = Date.now() + budgetMs;
99
+ for (let attempts = 1; ; attempts++) {
100
+ try {
101
+ const result = await attempt();
102
+ if (attempts > 1) log.info("NATS and JetStream answered", { application, attempts });
103
+ return result;
104
+ } catch (error) {
105
+ if (error instanceof SyncError) throw error;
106
+ const remainingMs = deadline - Date.now();
107
+ if (remainingMs <= 0) {
108
+ log.error("NATS and JetStream did not answer within the startup budget; exiting so the process is restarted", {
109
+ application,
110
+ attempts,
111
+ budgetMs,
112
+ error: errorMessage(error),
113
+ });
114
+ process.exit(1);
115
+ }
116
+ const retryInMs = Math.min(remainingMs, expBackoff(attempts, NATS_STARTUP_BACKOFF));
117
+ log.warn("NATS or JetStream is not ready; retrying", { application, attempt: attempts, retryInMs, error: errorMessage(error) });
118
+ await Bun.sleep(retryInMs);
119
+ }
120
+ }
121
+ };
122
+
123
+ /** One start attempt: connect, bind a new Sync instance, and wait for `ready()`; release both when it fails. */
124
+ const connectReadySync = async (application: string): Promise<{ sync: Sync; connection: NatsConnection }> => {
125
+ const connection = await connectNats({ name: `${application}@${hostname()}` });
126
+ let sync: Sync | undefined;
127
+ try {
128
+ sync = withSyncBudgets(
129
+ createSync({
130
+ connection,
131
+ namespace: env.SYNC_NAMESPACE,
132
+ application,
133
+ defaults: { replicas: env.SYNC_REPLICAS },
134
+ observe: (event) => observeSyncEvent(event, application),
135
+ }),
136
+ { connection, namespace: env.SYNC_NAMESPACE },
137
+ );
138
+ bindProcessSync(sync);
139
+ await sync.ready();
140
+ return { sync, connection };
141
+ } catch (error) {
142
+ if (current === sync) unbindProcessSync();
143
+ await flushSyncTraceEvents();
144
+ await connection.close();
145
+ throw error;
146
+ }
147
+ };
148
+
149
+ /**
150
+ * Connect NATS, create and bind the process Sync instance, and wait until it is
151
+ * ready. Existing streams of its jobs, queues, and topics take the byte
152
+ * limits they declare before first use (see `sync-budget.ts`).
153
+ *
154
+ * While NATS or JetStream does not answer, it retries with a fresh connection
155
+ * and instance (see `waitForNats`), and exits the process once
156
+ * `NATS_STARTUP_BUDGET_MS` is spent. Callers are process entry points; nothing
157
+ * serves readiness before this returns.
158
+ */
68
159
  export const startProcessSync = async ({ application }: { application: string }): Promise<ProcessSync> => {
69
160
  if (!env.SYNC_NAMESPACE.trim()) {
70
161
  throw new Error(
@@ -72,28 +163,16 @@ export const startProcessSync = async ({ application }: { application: string })
72
163
  "must share the same @k2b/sync namespace.",
73
164
  );
74
165
  }
166
+ if (env.NATS_SERVERS.length === 0) {
167
+ throw new Error(
168
+ `NATS_SERVERS is not set (application "${application}"). @k2b/sync needs a comma-separated list of ` +
169
+ "nats://host:port bootstrap servers, e.g. NATS_SERVERS=nats://ipa_nats_1:4222,nats://ipa_nats_2:4222.",
170
+ );
171
+ }
75
172
  if (current || starting) throw new Error("A Sync instance is already starting or bound to this process");
76
173
  starting = true;
77
174
  try {
78
- const connection = await connectNats({ name: `${application}@${hostname()}` });
79
- let sync: Sync | undefined;
80
- try {
81
- sync = createSync({
82
- connection,
83
- namespace: env.SYNC_NAMESPACE,
84
- application,
85
- defaults: { replicas: env.SYNC_REPLICAS },
86
- observe: (event) => observeSyncEvent(event, application),
87
- });
88
- bindProcessSync(sync);
89
- await sync.ready();
90
- } catch (error) {
91
- if (current === sync) unbindProcessSync();
92
- await flushSyncTraceEvents();
93
- await connection.close();
94
- throw error;
95
- }
96
- const active = sync;
175
+ const { sync: active, connection } = await waitForNats(() => connectReadySync(application), { application });
97
176
  let stopPromise: Promise<void> | undefined;
98
177
  return {
99
178
  sync: active,
@@ -28,6 +28,9 @@ type SsrHandler<E extends Env, T extends object> = (context: Context<E & PageEnv
28
28
  * (such as the request locale for `<html lang>`) are present on every SSR
29
29
  * page without per-route plumbing. Redirects and other passthrough Responses
30
30
  * skip it.
31
+ *
32
+ * Rendered documents default to `Cache-Control: private, no-store` unless the
33
+ * handler or a middleware already chose a policy.
31
34
  */
32
35
  export const createStatusPreservingSsrHandler = <T extends object>(
33
36
  html: HtmlFn<T>,
@@ -55,7 +58,12 @@ export const createStatusPreservingSsrHandler = <T extends object>(
55
58
  response.headers.forEach((value, key) => {
56
59
  headers[key] = value;
57
60
  });
58
- return context.newResponse(response.body, status as StatusCode, headers);
61
+ const document = context.newResponse(response.body, status as StatusCode, headers);
62
+ // A rendered document carries the signed-in user's state. Without an
63
+ // explicit policy, a browser may show it again on Back from its HTTP
64
+ // cache, even after sign-out. Handlers that set their own policy keep it.
65
+ if (!document.headers.has("Cache-Control")) document.headers.set("Cache-Control", "private, no-store");
66
+ return document;
59
67
  });
60
68
  };
61
69
  };
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Brings existing JetStream streams to the byte limits that the Sync jobs,
3
+ * queues, and topics of one Cloud process declare.
4
+ *
5
+ * JetStream reserves a stream's whole `max_bytes` on every replica as soon as
6
+ * the stream exists. @k2b/sync 7 declares small limits: a job or queue without
7
+ * `retention` holds 256 messages at its payload limit, and `deadLetterRetention`
8
+ * sizes dead letters apart from the work or event stream. Sync never
9
+ * reconfigures an existing stream, though. A stream created with other limits,
10
+ * such as the 1 GiB that Sync 6 gave every job and queue without `retention`,
11
+ * fails the declaration with `ResourceDriftError` on `max_bytes`.
12
+ *
13
+ * Before a job, queue, or topic is first used, this module therefore changes
14
+ * the byte limit of such a stream in place: both streams of a job or queue,
15
+ * and the dead-letter stream of a topic. A stream that holds more than its new
16
+ * limit would lose its oldest messages; it keeps its limit instead:
17
+ *
18
+ * - a job or queue is declared with the stream's current limit, so its work
19
+ * stays usable, and a later start applies the new limit;
20
+ * - a topic is declared synchronously, before its streams can be inspected,
21
+ * so its declaration keeps reporting the drift until the stream holds less.
22
+ */
23
+ import {
24
+ type DeadLetterStore,
25
+ type Job,
26
+ type JobConfig,
27
+ type Queue,
28
+ type QueueConfig,
29
+ ResourceDriftError,
30
+ type Sync,
31
+ type Topic,
32
+ type TopicConfig,
33
+ } from "@k2b/sync";
34
+ import { jetstreamManager, RetentionPolicy, type StreamInfo } from "@nats-io/jetstream";
35
+ import type { NatsConnection } from "@nats-io/transport-node";
36
+ import { logger } from "../services/logging";
37
+
38
+ /** Sync 7's limits for a job or queue declared without `retention`, which @k2b/sync does not export. */
39
+ const SYNC_PAYLOAD_BYTES = 128 * 1024;
40
+ const SYNC_DEAD_LETTER_HEADROOM_BYTES = 4096;
41
+ const SYNC_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
42
+
43
+ /** Byte limit Sync 7 gives a job or queue without `retention`: 256 messages at its payload limit, at most 1 GiB. */
44
+ export const syncDefaultMaxBytes = (maxPayloadBytes = SYNC_PAYLOAD_BYTES): number =>
45
+ Math.min(1024 ** 3, 256 * (maxPayloadBytes + SYNC_DEAD_LETTER_HEADROOM_BYTES));
46
+
47
+ const log = logger("sync:budget");
48
+
49
+ type Kind = "job" | "queue" | "topic";
50
+ type Limits = { work: number; deadLetters: number };
51
+
52
+ /** Memoizes a promise, forgetting a rejection so the next call retries. */
53
+ const once = <T>(create: () => Promise<T>): (() => Promise<T>) => {
54
+ let pending: Promise<T> | undefined;
55
+ return () =>
56
+ (pending ??= create().catch((error: unknown) => {
57
+ pending = undefined;
58
+ throw error;
59
+ }));
60
+ };
61
+
62
+ /** The stream and declared byte limit when Sync refused a declaration only for an existing stream's byte limit. */
63
+ const byteLimitDrift = (error: unknown): { stream: string; maxBytes: number } | undefined => {
64
+ if (!(error instanceof ResourceDriftError) || error.differences.length !== 1) return undefined;
65
+ const [difference] = error.differences;
66
+ if (difference?.field !== "max_bytes" || typeof difference.declared !== "number") return undefined;
67
+ return { stream: error.resource, maxBytes: difference.declared };
68
+ };
69
+
70
+ /** Byte limits a job or queue declares for its work and dead-letter streams. */
71
+ const declaredLimits = (config: QueueConfig | JobConfig): Limits => {
72
+ const work = config.retention?.maxBytes ?? syncDefaultMaxBytes(config.maxPayloadBytes);
73
+ return { work, deadLetters: config.deadLetterRetention?.maxBytes ?? work };
74
+ };
75
+
76
+ /** The declaration with the given limits, or unchanged when they are its own. */
77
+ const withLimits = <Config extends QueueConfig | JobConfig>(config: Config, target: Limits, limits: Limits): Config =>
78
+ limits.work === target.work && limits.deadLetters === target.deadLetters
79
+ ? config
80
+ : {
81
+ ...config,
82
+ retention: { maxAgeMs: SYNC_MAX_AGE_MS, ...config.retention, maxBytes: limits.work },
83
+ deadLetterRetention: { ...config.deadLetterRetention, maxBytes: limits.deadLetters },
84
+ };
85
+
86
+ const createStreamLimits = (connection: NatsConnection, namespace: string) => {
87
+ const manager = once(() => jetstreamManager(connection));
88
+
89
+ /**
90
+ * Work and dead-letter streams per job and queue of this namespace as they
91
+ * are now. Concurrent callers share one listing; a later call lists again,
92
+ * so streams that another process created or changed since are seen.
93
+ */
94
+ let listing: Promise<Map<string, StreamInfo[]>> | undefined;
95
+ const list = () =>
96
+ (listing ??= (async () => {
97
+ const jsm = await manager();
98
+ const streams = new Map<string, StreamInfo[]>();
99
+ for await (const info of jsm.streams.list()) {
100
+ const metadata = info.config.metadata;
101
+ const kind = metadata?.["sync.kind"];
102
+ if (metadata?.["sync.namespace"] !== namespace || metadata["sync.managed"] !== "true") continue;
103
+ // A job's coalescing claims live in a KV bucket without a byte limit.
104
+ if ((kind !== "job" && kind !== "queue") || info.config.name.startsWith("KV_")) continue;
105
+ const key = `${kind}:${metadata["sync.id"]}`;
106
+ streams.set(key, [...(streams.get(key) ?? []), info]);
107
+ }
108
+ return streams;
109
+ })().finally(() => {
110
+ listing = undefined;
111
+ }));
112
+
113
+ /**
114
+ * Limits a job or queue declares: its own, except for an existing stream
115
+ * that holds more than its new limit, which keeps its current limit.
116
+ */
117
+ const keep = async (kind: Kind, id: string, target: Limits): Promise<Limits> => {
118
+ const streams = (await list()).get(`${kind}:${id}`) ?? [];
119
+ const kept = (info: StreamInfo | undefined, maxBytes: number): number => {
120
+ if (!info || info.config.max_bytes === maxBytes || info.state.bytes <= maxBytes) return maxBytes;
121
+ log.warn("Kept the byte limit of a Sync stream that holds more than its new limit", {
122
+ kind,
123
+ id,
124
+ stream: info.config.name,
125
+ limit: info.config.max_bytes,
126
+ target: maxBytes,
127
+ held: info.state.bytes,
128
+ });
129
+ return info.config.max_bytes;
130
+ };
131
+ return {
132
+ work: kept(
133
+ streams.find((info) => info.config.retention === RetentionPolicy.Workqueue),
134
+ target.work,
135
+ ),
136
+ deadLetters: kept(
137
+ streams.find((info) => info.config.retention !== RetentionPolicy.Workqueue),
138
+ target.deadLetters,
139
+ ),
140
+ };
141
+ };
142
+
143
+ /** Streams reported as too full to lower; a topic retries on every use, but logs once. */
144
+ const reported = new Set<string>();
145
+
146
+ /**
147
+ * Provisions a declaration. Each existing stream whose byte limit differs
148
+ * from the declared one gets the declared limit if it holds no more; then
149
+ * the declaration is tried again. Each stream is changed at most once. Of a
150
+ * topic, only the dead-letter stream, whose subjects end in `.dlq.>`, is
151
+ * changed: its event stream keeps Sync's drift check, so applications that
152
+ * declare one topic differently still fail instead of overwriting each
153
+ * other's limit.
154
+ */
155
+ const adopt = async (kind: Kind, id: string, ready: () => Promise<void>): Promise<void> => {
156
+ const changed = new Set<string>();
157
+ for (;;) {
158
+ try {
159
+ return await ready();
160
+ } catch (error) {
161
+ const drift = byteLimitDrift(error);
162
+ if (!drift || changed.has(drift.stream)) throw error;
163
+ changed.add(drift.stream);
164
+ const jsm = await manager();
165
+ const info = await jsm.streams.info(drift.stream);
166
+ if (kind === "topic" && !info.config.subjects.some((subject) => subject.endsWith(".dlq.>"))) throw error;
167
+ if (info.state.bytes > drift.maxBytes) {
168
+ if (reported.has(drift.stream)) throw error;
169
+ reported.add(drift.stream);
170
+ log.warn("Kept the byte limit of a Sync stream that holds more than its new limit", {
171
+ kind,
172
+ id,
173
+ stream: drift.stream,
174
+ limit: info.config.max_bytes,
175
+ target: drift.maxBytes,
176
+ held: info.state.bytes,
177
+ });
178
+ throw error;
179
+ }
180
+ await jsm.streams.update(drift.stream, { max_bytes: drift.maxBytes });
181
+ log.info("Changed the byte limit of a Sync stream", {
182
+ kind,
183
+ id,
184
+ stream: drift.stream,
185
+ from: info.config.max_bytes,
186
+ to: drift.maxBytes,
187
+ });
188
+ }
189
+ }
190
+ };
191
+
192
+ return { keep, adopt };
193
+ };
194
+
195
+ const deferDeadLetters = <T>(resolve: () => Promise<{ deadLetters: DeadLetterStore<T> }>): DeadLetterStore<T> => ({
196
+ page: async (options) => (await resolve()).deadLetters.page(options),
197
+ get: async (input) => (await resolve()).deadLetters.get(input),
198
+ list: async (options) => (await resolve()).deadLetters.list(options),
199
+ requeue: async (input) => (await resolve()).deadLetters.requeue(input),
200
+ delete: async (input) => (await resolve()).deadLetters.delete(input),
201
+ });
202
+
203
+ const deferJob = <Input>(resolve: () => Promise<Job<Input>>): Job<Input> => ({
204
+ ready: async () => (await resolve()).ready(),
205
+ submit: async (job) => (await resolve()).submit(job),
206
+ submitMany: async (jobs, options) => (await resolve()).submitMany(jobs, options),
207
+ submitBatch: async (jobs) => (await resolve()).submitBatch(jobs),
208
+ pause: async (options) => (await resolve()).pause(options),
209
+ resume: async () => (await resolve()).resume(),
210
+ process: async (options, handler) => (await resolve()).process(options, handler),
211
+ deadLetters: deferDeadLetters(resolve),
212
+ });
213
+
214
+ const deferQueue = <T>(resolve: () => Promise<Queue<T>>): Queue<T> => ({
215
+ ready: async () => (await resolve()).ready(),
216
+ send: async (message) => (await resolve()).send(message),
217
+ sendBatch: async (messages) => (await resolve()).sendBatch(messages),
218
+ pause: async (options) => (await resolve()).pause(options),
219
+ resume: async () => (await resolve()).resume(),
220
+ process: async (options, handler) => (await resolve()).process(options, handler),
221
+ reader: async (options) => (await resolve()).reader(options),
222
+ deadLetters: deferDeadLetters(resolve),
223
+ });
224
+
225
+ /** A declared topic whose every provisioning use waits until `ready` has adopted its streams. */
226
+ const deferTopic = <T>(topic: Topic<T>, ready: () => Promise<void>): Topic<T> => {
227
+ const whenReady = <Result>(use: () => Promise<Result>): Promise<Result> => ready().then(use);
228
+ const iterateWhenReady = async function* <Event>(open: () => AsyncIterable<Event>): AsyncIterable<Event> {
229
+ await ready();
230
+ yield* open();
231
+ };
232
+ return {
233
+ ready,
234
+ publish: (input) => whenReady(() => topic.publish(input)),
235
+ publishBatch: (input) => whenReady(() => topic.publishBatch(input)),
236
+ hub: (options) => {
237
+ const hub = topic.hub(options);
238
+ return { subscribe: (subscription) => iterateWhenReady(() => hub.subscribe(subscription)), close: () => hub.close() };
239
+ },
240
+ cursorSequence: (cursor) => topic.cursorSequence(cursor),
241
+ cursorAt: (sequence) => topic.cursorAt(sequence),
242
+ pauseConsumer: (input) => whenReady(() => topic.pauseConsumer(input)),
243
+ resumeConsumer: (input) => whenReady(() => topic.resumeConsumer(input)),
244
+ latestCursor: (options) => whenReady(() => topic.latestCursor(options)),
245
+ head: () => whenReady(() => topic.head()),
246
+ live: (options) => iterateWhenReady(() => topic.live(options)),
247
+ replay: (options) => iterateWhenReady(() => topic.replay(options)),
248
+ follow: (options) => iterateWhenReady(() => topic.follow(options)),
249
+ destroy: () => topic.destroy(),
250
+ process: (options, handler) => whenReady(() => topic.process(options, handler)),
251
+ deadLetters: {
252
+ list: (options) => whenReady(() => topic.deadLetters.list(options)),
253
+ get: (input) => whenReady(() => topic.deadLetters.get(input)),
254
+ delete: (input) => whenReady(() => topic.deadLetters.delete(input)),
255
+ replay: (input) => whenReady(() => topic.deadLetters.replay(input)),
256
+ },
257
+ };
258
+ };
259
+
260
+ /**
261
+ * The process Sync whose jobs, queues, and topics adopt the byte limits of
262
+ * their existing streams before first use. A job or queue is declared to Sync
263
+ * on its first use or on `ready()`; a topic is declared at once. Every other
264
+ * primitive is Sync's own.
265
+ */
266
+ export const withSyncBudgets = (sync: Sync, { connection, namespace }: { connection: NatsConnection; namespace: string }): Sync => {
267
+ const { keep, adopt } = createStreamLimits(connection, namespace);
268
+ /** Every job, queue, and topic declared so far, so `ready()` provisions them like Sync does. */
269
+ const declared = new Map<string, () => Promise<unknown>>();
270
+
271
+ const declareWork = <Config extends QueueConfig | JobConfig, Handle extends { ready(): Promise<void> }>(
272
+ kind: "job" | "queue",
273
+ config: Config,
274
+ create: (config: Config) => Handle,
275
+ ): (() => Promise<Handle>) => {
276
+ // Declared once: Sync refuses a second declaration with other limits, so a
277
+ // failed first use retries provisioning with the limits chosen first.
278
+ const declaration = once(async () => {
279
+ const target = declaredLimits(config);
280
+ return create(withLimits(config, target, await keep(kind, config.id, target)));
281
+ });
282
+ const resolve = once(async () => {
283
+ const handle = await declaration();
284
+ await adopt(kind, config.id, () => handle.ready());
285
+ return handle;
286
+ });
287
+ declared.set(`${kind}:${config.id}`, resolve);
288
+ return resolve;
289
+ };
290
+
291
+ return {
292
+ ...sync,
293
+ ready: async () => {
294
+ await Promise.all([...declared.values()].map((resolve) => resolve()));
295
+ await sync.ready();
296
+ },
297
+ job: <Input>(config: JobConfig) => deferJob(declareWork("job", config, (declaration) => sync.job<Input>(declaration))),
298
+ queue: <T>(config: QueueConfig) => deferQueue(declareWork("queue", config, (declaration) => sync.queue<T>(declaration))),
299
+ topic: <T>(config: TopicConfig) => {
300
+ const topic = sync.topic<T>(config);
301
+ const ready = once(() => adopt("topic", config.id, () => topic.ready()));
302
+ declared.set(`topic:${config.id}`, ready);
303
+ return deferTopic(topic, ready);
304
+ },
305
+ };
306
+ };