@fuzdev/fuz_app 0.118.0 → 0.119.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 (155) hide show
  1. package/dist/actions/CLAUDE.md +301 -73
  2. package/dist/actions/action_rpc.d.ts.map +1 -1
  3. package/dist/actions/action_rpc.js +16 -1
  4. package/dist/actions/connection_closer.d.ts +136 -51
  5. package/dist/actions/connection_closer.d.ts.map +1 -1
  6. package/dist/actions/connection_closer.js +133 -34
  7. package/dist/actions/register_action_ws.d.ts +176 -24
  8. package/dist/actions/register_action_ws.d.ts.map +1 -1
  9. package/dist/actions/register_action_ws.js +581 -241
  10. package/dist/actions/register_ws_endpoint.d.ts +1 -0
  11. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  12. package/dist/actions/register_ws_endpoint.js +1 -0
  13. package/dist/actions/socket.svelte.d.ts +43 -9
  14. package/dist/actions/socket.svelte.d.ts.map +1 -1
  15. package/dist/actions/socket.svelte.js +59 -8
  16. package/dist/actions/transports.d.ts +55 -1
  17. package/dist/actions/transports.d.ts.map +1 -1
  18. package/dist/actions/transports.js +55 -1
  19. package/dist/actions/transports_ws_auth_guard.d.ts +53 -67
  20. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  21. package/dist/actions/transports_ws_auth_guard.js +87 -111
  22. package/dist/actions/transports_ws_backend.d.ts +212 -30
  23. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  24. package/dist/actions/transports_ws_backend.js +327 -70
  25. package/dist/actions/ws_endpoint_spec.d.ts +26 -13
  26. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  27. package/dist/auth/CLAUDE.md +113 -32
  28. package/dist/auth/account_actions.d.ts +12 -18
  29. package/dist/auth/account_actions.d.ts.map +1 -1
  30. package/dist/auth/account_actions.js +47 -33
  31. package/dist/auth/account_routes.d.ts +5 -11
  32. package/dist/auth/account_routes.d.ts.map +1 -1
  33. package/dist/auth/account_routes.js +40 -36
  34. package/dist/auth/account_schema.d.ts +27 -0
  35. package/dist/auth/account_schema.d.ts.map +1 -1
  36. package/dist/auth/account_schema.js +22 -0
  37. package/dist/auth/admin_actions.d.ts +8 -17
  38. package/dist/auth/admin_actions.d.ts.map +1 -1
  39. package/dist/auth/admin_actions.js +33 -21
  40. package/dist/auth/api_token_queries.d.ts +23 -2
  41. package/dist/auth/api_token_queries.d.ts.map +1 -1
  42. package/dist/auth/api_token_queries.js +39 -3
  43. package/dist/auth/audit_emitter.d.ts +61 -38
  44. package/dist/auth/audit_emitter.d.ts.map +1 -1
  45. package/dist/auth/audit_emitter.js +57 -14
  46. package/dist/auth/audit_log_route_schema.js +7 -7
  47. package/dist/auth/audit_log_routes.d.ts +31 -6
  48. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  49. package/dist/auth/audit_log_routes.js +85 -11
  50. package/dist/auth/audit_log_schema.d.ts +48 -0
  51. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  52. package/dist/auth/audit_log_schema.js +58 -0
  53. package/dist/auth/cleanup.d.ts +203 -28
  54. package/dist/auth/cleanup.d.ts.map +1 -1
  55. package/dist/auth/cleanup.js +222 -26
  56. package/dist/auth/deps.d.ts +28 -0
  57. package/dist/auth/deps.d.ts.map +1 -1
  58. package/dist/auth/resolved_auth.d.ts +105 -0
  59. package/dist/auth/resolved_auth.d.ts.map +1 -0
  60. package/dist/auth/resolved_auth.js +124 -0
  61. package/dist/auth/session_middleware.d.ts +8 -1
  62. package/dist/auth/session_middleware.d.ts.map +1 -1
  63. package/dist/auth/session_middleware.js +9 -3
  64. package/dist/auth/session_queries.d.ts +31 -6
  65. package/dist/auth/session_queries.d.ts.map +1 -1
  66. package/dist/auth/session_queries.js +31 -6
  67. package/dist/auth/standard_rpc_actions.d.ts +9 -9
  68. package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
  69. package/dist/auth/standard_rpc_actions.js +3 -3
  70. package/dist/db/CLAUDE.md +7 -1
  71. package/dist/db/status.d.ts +58 -1
  72. package/dist/db/status.d.ts.map +1 -1
  73. package/dist/db/status.js +205 -77
  74. package/dist/http/CLAUDE.md +7 -3
  75. package/dist/http/db_routes.d.ts +6 -2
  76. package/dist/http/db_routes.d.ts.map +1 -1
  77. package/dist/http/db_routes.js +2 -2
  78. package/dist/periodic.d.ts +80 -0
  79. package/dist/periodic.d.ts.map +1 -0
  80. package/dist/periodic.js +122 -0
  81. package/dist/realtime/sse_auth_guard.d.ts +79 -49
  82. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  83. package/dist/realtime/sse_auth_guard.js +103 -73
  84. package/dist/realtime/subscriber_registry.d.ts +119 -8
  85. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  86. package/dist/realtime/subscriber_registry.js +184 -25
  87. package/dist/server/app_backend.d.ts +12 -1
  88. package/dist/server/app_backend.d.ts.map +1 -1
  89. package/dist/server/app_backend.js +5 -2
  90. package/dist/server/app_server.d.ts +55 -15
  91. package/dist/server/app_server.d.ts.map +1 -1
  92. package/dist/server/app_server.js +41 -13
  93. package/dist/testing/CLAUDE.md +140 -48
  94. package/dist/testing/app_server.d.ts +2 -2
  95. package/dist/testing/app_server.d.ts.map +1 -1
  96. package/dist/testing/app_server.js +11 -2
  97. package/dist/testing/audit_drift_guard.d.ts +1 -48
  98. package/dist/testing/audit_drift_guard.d.ts.map +1 -1
  99. package/dist/testing/audit_drift_guard.js +0 -42
  100. package/dist/testing/connection_closer_helpers.d.ts +9 -22
  101. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  102. package/dist/testing/connection_closer_helpers.js +11 -17
  103. package/dist/testing/cross_backend/capabilities.d.ts +67 -13
  104. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  105. package/dist/testing/cross_backend/capabilities.js +27 -12
  106. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  107. package/dist/testing/cross_backend/default_backend_configs.js +21 -0
  108. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  109. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +13 -4
  110. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  111. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +4 -1
  112. package/dist/testing/cross_backend/session_cap.d.ts +13 -0
  113. package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
  114. package/dist/testing/cross_backend/session_cap.js +55 -19
  115. package/dist/testing/cross_backend/sse_round_trip.d.ts +10 -2
  116. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
  117. package/dist/testing/cross_backend/sse_round_trip.js +93 -26
  118. package/dist/testing/cross_backend/testing_server_bun.js +4 -4
  119. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  120. package/dist/testing/cross_backend/ts_spine_backend_config.js +10 -2
  121. package/dist/testing/cross_backend/ws_connection_cap.d.ts +35 -0
  122. package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -0
  123. package/dist/testing/cross_backend/ws_connection_cap.js +131 -0
  124. package/dist/testing/cross_backend/ws_round_trip.d.ts +2 -1
  125. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  126. package/dist/testing/cross_backend/ws_round_trip.js +156 -19
  127. package/dist/testing/sse_round_trip.d.ts +6 -4
  128. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  129. package/dist/testing/stubs.d.ts +1 -1
  130. package/dist/testing/stubs.d.ts.map +1 -1
  131. package/dist/testing/stubs.js +6 -4
  132. package/dist/testing/transports/sse_transport.d.ts +1 -1
  133. package/dist/testing/transports/ws_client.d.ts +7 -0
  134. package/dist/testing/transports/ws_client.d.ts.map +1 -1
  135. package/dist/testing/transports/ws_raw_client.d.ts +51 -0
  136. package/dist/testing/transports/ws_raw_client.d.ts.map +1 -0
  137. package/dist/testing/transports/ws_raw_client.js +156 -0
  138. package/dist/testing/transports/ws_transport.d.ts +28 -0
  139. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  140. package/dist/testing/transports/ws_transport.js +45 -1
  141. package/dist/testing/ws_round_trip.d.ts +8 -3
  142. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  143. package/dist/testing/ws_round_trip.js +19 -4
  144. package/dist/ui/AdminAccounts.svelte +4 -3
  145. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  146. package/dist/ui/AdminOverview.svelte +3 -1
  147. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  148. package/dist/ui/CLAUDE.md +22 -6
  149. package/dist/ui/admin_accounts_state.svelte.d.ts +15 -1
  150. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  151. package/dist/ui/admin_accounts_state.svelte.js +16 -0
  152. package/dist/ui/auth_state.svelte.d.ts +25 -1
  153. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  154. package/dist/ui/auth_state.svelte.js +28 -1
  155. package/package.json +1 -1
package/dist/db/status.js CHANGED
@@ -19,6 +19,49 @@ const has_table = async (db, table_name) => {
19
19
  ) as exists`, [table_name]);
20
20
  return row?.exists ?? false;
21
21
  };
22
+ /**
23
+ * Classify one namespace's tracker rows against the code's list — the pure
24
+ * core of `query_db_status`.
25
+ *
26
+ * Name-prefix verifies `applied_names` (sequence-ascending, empty when the
27
+ * tracker is absent), mirroring `run_migrations`: they must equal the first
28
+ * `applied_names.length` code names by position. A divergent history (a
29
+ * renamed/reordered migration, same count or not) is the exact state the runner
30
+ * refuses to boot against, so a count-only check would report a database the
31
+ * runner rejects as up to date. The pending list is the code suffix past the
32
+ * applied count, clamped, so a binary-older history has no pending tail.
33
+ *
34
+ * @param ns - the namespace and its code-side migrations
35
+ * @param applied_names - the tracker's names for `ns`, sequence-ascending
36
+ * @returns the namespace's migration status
37
+ */
38
+ const migration_status = (ns, applied_names) => {
39
+ const code_names = ns.migrations.map((m) => m.name);
40
+ const declared = code_names.length;
41
+ const applied = applied_names.length;
42
+ let divergence;
43
+ if (applied > declared) {
44
+ divergence = { kind: 'binary_older', applied, declared };
45
+ }
46
+ else {
47
+ const position = applied_names.findIndex((name, i) => name !== code_names[i]);
48
+ if (position !== -1) {
49
+ divergence = {
50
+ kind: 'name_mismatch',
51
+ position,
52
+ applied: applied_names[position],
53
+ expected: code_names[position]
54
+ };
55
+ }
56
+ }
57
+ return {
58
+ namespace: ns.namespace,
59
+ applied_names,
60
+ pending_names: code_names.slice(Math.min(applied, declared)),
61
+ up_to_date: applied === declared && divergence === undefined,
62
+ ...(divergence ? { divergence } : {})
63
+ };
64
+ };
22
65
  /**
23
66
  * Query database status including connectivity, tables, and migration state.
24
67
  *
@@ -61,59 +104,17 @@ export const query_db_status = async (db, namespaces) => {
61
104
  // check migration state
62
105
  const migrations = [];
63
106
  if (namespaces?.length) {
107
+ // no tracker (a never-migrated database) reads as nothing applied yet
64
108
  const sv_exists = await has_table(db, 'schema_version');
65
- if (sv_exists) {
66
- for (const { namespace, migrations: ns_migrations } of namespaces) {
109
+ for (const ns of namespaces) {
110
+ let applied_names = [];
111
+ if (sv_exists) {
67
112
  const rows = await db.query(`SELECT name FROM schema_version
68
113
  WHERE namespace = $1
69
- ORDER BY sequence ASC`, [namespace]);
70
- const applied_names = rows.map((r) => r.name);
71
- const code_names = ns_migrations.map((m) => m.name);
72
- const total = code_names.length;
73
- const applied = applied_names.length;
74
- // Name-prefix verify, mirroring the migration runner: the applied
75
- // names must equal the first `applied` code names by position. A
76
- // divergent history (a renamed/reordered migration, same count or not)
77
- // is the exact state `run_migrations` refuses to boot against, so a
78
- // count-only check would report a DB the runner rejects as up-to-date.
79
- let divergence;
80
- if (applied > total) {
81
- divergence = { kind: 'binary_older', applied, declared: total };
82
- }
83
- else {
84
- const position = applied_names.findIndex((name, i) => name !== code_names[i]);
85
- if (position !== -1) {
86
- divergence = {
87
- kind: 'name_mismatch',
88
- position,
89
- applied: applied_names[position],
90
- expected: code_names[position]
91
- };
92
- }
93
- }
94
- // pending = the suffix of code names past the applied count (clamped:
95
- // a binary-older history has no pending tail)
96
- const pending_names = code_names.slice(Math.min(applied, total));
97
- migrations.push({
98
- namespace,
99
- applied_names,
100
- pending_names,
101
- up_to_date: applied === total && pending_names.length === 0 && divergence === undefined,
102
- ...(divergence ? { divergence } : {})
103
- });
104
- }
105
- }
106
- else {
107
- // no tracker — every namespace shows as "nothing applied yet"
108
- for (const { namespace, migrations: ns_migrations } of namespaces) {
109
- const code_names = ns_migrations.map((m) => m.name);
110
- migrations.push({
111
- namespace,
112
- applied_names: [],
113
- pending_names: code_names,
114
- up_to_date: code_names.length === 0
115
- });
114
+ ORDER BY sequence ASC`, [ns.namespace]);
115
+ applied_names = rows.map((r) => r.name);
116
116
  }
117
+ migrations.push(migration_status(ns, applied_names));
117
118
  }
118
119
  }
119
120
  return {
@@ -130,48 +131,175 @@ export const query_db_status = async (db, namespaces) => {
130
131
  const format_divergence = (divergence) => {
131
132
  switch (divergence.kind) {
132
133
  case 'name_mismatch':
133
- return `position ${divergence.position}: database has '${divergence.applied}', code has '${divergence.expected}'`;
134
+ return `position ${divergence.position}: database has \`${divergence.applied}\`, code has \`${divergence.expected}\``;
134
135
  case 'binary_older':
135
136
  return `database has ${divergence.applied} applied but code declares ${divergence.declared} (binary older than database)`;
136
137
  }
137
138
  };
138
139
  /**
139
- * Format a `DbStatus` as a human-readable string for CLI output.
140
+ * The code's declared migration count for a namespace — the report's
141
+ * denominator. `applied + pending` everywhere but a binary-older history, where
142
+ * the tracker holds more rows than the code declares and the pending tail is
143
+ * empty; the declared count is then the one the divergence carries.
144
+ */
145
+ const declared_count = (m) => m.divergence?.kind === 'binary_older'
146
+ ? m.divergence.declared
147
+ : m.applied_names.length + m.pending_names.length;
148
+ /**
149
+ * Format a `DbStatus` as the body of the human-facing operator report.
150
+ *
151
+ * A `tables:` count with one aligned `name N rows` line per table, then a
152
+ * `migrations:` header (always printed, even with no namespaces) and one
153
+ * aligned line per namespace: its state (`up-to-date`, `PENDING <n>`, or
154
+ * `DIVERGED`), `applied/declared`, and the latest applied name (`(none)` when
155
+ * nothing is applied). A diverged namespace adds a `diverged:` line, a pending
156
+ * one a `pending:` line naming the tail. A failed connection renders the single
157
+ * line `connection: FAILED (<error>)`. Every line ends in a newline.
158
+ *
159
+ * The body only — the `database status` / `url:` header belongs to the CLI
160
+ * script, which holds the URL a `DbStatus` never sees. For a connected status
161
+ * the output is byte-identical with the Rust twin's `format_db_status`; both
162
+ * test suites pin the same fixture report.
140
163
  *
141
164
  * @param status - the status to format
142
165
  * @returns multi-line string suitable for console output
143
166
  */
144
167
  export const format_db_status = (status) => {
145
- const lines = [];
146
168
  if (!status.connected) {
147
- lines.push(` Connection: FAILED${status.error ? ` (${status.error})` : ''}`);
148
- return lines.join('\n');
169
+ return ` connection: FAILED${status.error ? ` (${status.error})` : ''}\n`;
170
+ }
171
+ const lines = [` tables: ${status.table_count}`];
172
+ const table_width = Math.max(0, ...status.tables.map((t) => t.name.length));
173
+ for (const t of status.tables) {
174
+ lines.push(` ${t.name.padEnd(table_width)} ${t.row_count} rows`);
149
175
  }
150
- lines.push(` Connection: OK`);
151
- lines.push(` Tables: ${status.table_count}`);
152
- if (status.tables.length > 0) {
153
- lines.push('');
154
- const max_name = Math.max(...status.tables.map((t) => t.name.length));
155
- for (const t of status.tables) {
156
- lines.push(` ${t.name.padEnd(max_name)} ${t.row_count} rows`);
176
+ lines.push('');
177
+ lines.push(' migrations:');
178
+ const ns_width = Math.max(0, ...status.migrations.map((m) => m.namespace.length));
179
+ const detail_indent = ` ${''.padEnd(ns_width)} `;
180
+ for (const m of status.migrations) {
181
+ const state = m.divergence
182
+ ? 'DIVERGED'
183
+ : m.up_to_date
184
+ ? 'up-to-date'
185
+ : `PENDING ${m.pending_names.length}`;
186
+ const latest = m.applied_names.at(-1) ?? '(none)';
187
+ lines.push(` ${m.namespace.padEnd(ns_width)} ${state.padEnd(12)} ${m.applied_names.length}/${declared_count(m)} latest: ${latest}`);
188
+ if (m.divergence) {
189
+ lines.push(`${detail_indent}diverged: ${format_divergence(m.divergence)}`);
190
+ }
191
+ else if (m.pending_names.length > 0) {
192
+ lines.push(`${detail_indent}pending: ${m.pending_names.join(', ')}`);
157
193
  }
158
194
  }
159
- if (status.migrations.length > 0) {
160
- lines.push('');
161
- lines.push(' Migrations:');
162
- for (const m of status.migrations) {
163
- const total = m.applied_names.length + m.pending_names.length;
164
- if (m.divergence) {
165
- lines.push(` ${m.namespace}: DIVERGED (${m.applied_names.length}/${total}) — ${format_divergence(m.divergence)}`);
166
- }
167
- else if (m.up_to_date) {
168
- lines.push(` ${m.namespace}: up to date (${m.applied_names.length}/${total})`);
169
- }
170
- else {
171
- const pending_list = m.pending_names.join(', ');
172
- lines.push(` ${m.namespace}: applied ${m.applied_names.length}/${total} (pending: ${pending_list})`);
173
- }
195
+ return lines.join('\n') + '\n';
196
+ };
197
+ /** Printed in place of a database URL `display_db_url` won't render. */
198
+ const UNPARSEABLE_DB_URL = '(not a URL; hidden)';
199
+ /** The port node-postgres dials when the URL names none. */
200
+ const DEFAULT_PG_PORT = '5432';
201
+ const MALFORMED_ESCAPE = /%(?![0-9A-Fa-f]{2})/;
202
+ const HOSTNAME = /^[A-Za-z0-9._-]+$/;
203
+ const SOCKET_DIR = /^\/[A-Za-z0-9._/-]*$/;
204
+ const MISPARSED_USER = /[?=&/#]/;
205
+ const PORT = /^\d{1,5}$/;
206
+ /**
207
+ * A host as `display_db_url` prints it, or `null` when it isn't an IP literal,
208
+ * a hostname, or a socket directory. An IPv6 literal serializes through
209
+ * `new URL`, which canonicalizes it.
210
+ */
211
+ const display_host = (host) => {
212
+ if (SOCKET_DIR.test(host))
213
+ return host.replaceAll('/', '%2F');
214
+ if (HOSTNAME.test(host))
215
+ return host;
216
+ if (!host.includes(':'))
217
+ return null;
218
+ try {
219
+ return new URL(`http://[${host}]`).hostname;
220
+ }
221
+ catch {
222
+ return null;
223
+ }
224
+ };
225
+ /**
226
+ * Render a database URL for printing from the parse node-postgres dials —
227
+ * `new URL`, with pg-connection-string's precedence, where a `user`, `host`, or
228
+ * `port` query parameter overrides the URL's own — so the line names the server
229
+ * dialed and no credential can leak however the input spells it.
230
+ *
231
+ * A `postgres://` or `postgresql://` URL renders as
232
+ * `postgres://[user@]host:port[/dbname]`: never the password, and never the
233
+ * query or fragment (`sslmode` and the like are dropped by design). The scheme
234
+ * always prints as `postgres://`. The port always prints, `5432` when the URL
235
+ * names none. The user and database name print percent-encoded with
236
+ * `encodeURIComponent`, so no control character, escape sequence, or bidi
237
+ * override reaches the terminal and an `@` can't pose as the separator; each
238
+ * prints only when non-empty. An IP-literal host prints canonically (IPv6
239
+ * bracketed); a Unix-socket directory prints with each `/` as `%2F`. A PGlite
240
+ * `file://` or `memory://` URL prints as scheme, host, and path.
241
+ *
242
+ * The whole line is `(not a URL; hidden)` for any other scheme, when `new URL`
243
+ * rejects the input, when it holds a malformed percent-escape (`%ZZ`), when
244
+ * there is no host, when the host is neither an IP literal, a hostname of
245
+ * `A-Z a-z 0-9 . _ -`, nor a socket directory of those characters and `/`, when
246
+ * the user holds `?`, `=`, `&`, `/`, or `#`, or when the port isn't a number up
247
+ * to 65535. The host and user rules match the Rust twin's, where they catch
248
+ * tokio-postgres's split at the first `@`.
249
+ *
250
+ * Twin of the Rust `fuz_db` CLI's `display_db_url`, byte-identical on the URLs
251
+ * both drivers read alike. They part where the drivers do: a `host=` or `port=`
252
+ * query parameter overrides the URL's here (tokio-postgres appends it to a list
253
+ * whose first entry the pool dials); a `dbname=` query parameter, which
254
+ * tokio-postgres honours and node-postgres ignores, and a `#…` fragment, which
255
+ * tokio-postgres keeps as part of the database name; tokio-postgres also
256
+ * accepts `key=value` strings, host lists, and an unencoded `/` or `?` in the
257
+ * password (here, the placeholder); an unencoded `@` in the userinfo splits at
258
+ * the last `@` here, rendering the user and host node-postgres dials, where the
259
+ * Rust twin prints the placeholder; node-postgres decodes the database name
260
+ * with `decodeURI`, so a reserved-character escape such as `%2F` stays literal
261
+ * and prints as `%252F`; an IPv4-mapped IPv6 address serializes differently;
262
+ * and tokio-postgres refuses an unknown query parameter (`sslpassword=`) that
263
+ * node-postgres passes.
264
+ *
265
+ * @param url - the database URL
266
+ * @returns the URL without its password, query, or fragment, or the placeholder
267
+ */
268
+ export const display_db_url = (url) => {
269
+ if (MALFORMED_ESCAPE.test(url))
270
+ return UNPARSEABLE_DB_URL;
271
+ try {
272
+ const parsed = new URL(url);
273
+ if (parsed.protocol === 'file:' || parsed.protocol === 'memory:') {
274
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
275
+ }
276
+ if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') {
277
+ return UNPARSEABLE_DB_URL;
174
278
  }
279
+ const params = parsed.searchParams;
280
+ const user = params.get('user') || decodeURIComponent(parsed.username);
281
+ const param_host = params.get('host');
282
+ const host = display_host(param_host || decodeURIComponent(parsed.hostname.replace(/^\[(.*)\]$/, '$1')));
283
+ const port = params.get('port') || parsed.port || DEFAULT_PG_PORT;
284
+ // pg-connection-string moves an encoded socket host into the path when a
285
+ // query `host=` replaces it
286
+ const pathname = param_host && /^%2f/i.test(parsed.hostname)
287
+ ? parsed.hostname + parsed.pathname
288
+ : parsed.pathname;
289
+ const dbname = decodeURI(pathname.slice(1));
290
+ if (host === null || MISPARSED_USER.test(user) || !PORT.test(port) || Number(port) > 65535) {
291
+ return UNPARSEABLE_DB_URL;
292
+ }
293
+ let shown = 'postgres://';
294
+ if (user)
295
+ shown += `${encodeURIComponent(user)}@`;
296
+ shown += `${host}:${port}`;
297
+ if (dbname)
298
+ shown += `/${encodeURIComponent(dbname)}`;
299
+ return shown;
300
+ }
301
+ catch {
302
+ // `new URL`, a decode, or an encode of a lone surrogate threw
303
+ return UNPARSEABLE_DB_URL;
175
304
  }
176
- return lines.join('\n');
177
305
  };
@@ -463,13 +463,17 @@ emit_after_commit(ctx, () => notification_sender.send_to_account(account_id, msg
463
463
  ```
464
464
 
465
465
  Used for WS sends (`NotificationSender.send_to_account` for
466
- role-grant-offer notifications — see `auth/CLAUDE.md` §WS notifications)
467
- and any side effect that must run only after the transaction commits.
466
+ role-grant-offer notifications — see `auth/CLAUDE.md` §WS notifications),
467
+ a revocation's connection closes (`queue_connection_close` — see
468
+ `actions/CLAUDE.md` §Connection closer), the audit emitter's listener fan-out
469
+ for a success row (`auth/CLAUDE.md` §Audit emitter), and any side effect that
470
+ must run only after the transaction commits.
468
471
 
469
472
  ### Key properties
470
473
 
471
474
  - **The flush owns the safety net.** `flush_post_commit_effects` wraps every thunk in `try/catch` and routes errors through `ctx.log.error`, so one failing send cannot starve sibling effects in the same batch nor corrupt the already-committed response. Per-thunk `try/catch` inside `emit_after_commit` would skip directly-pushed thunks (e.g. tests); centralizing the wrap in the flush closes that gap.
472
475
  - **Test mode (`await_pending_effects: true`) flushes both queues.** Eager: `await flush_pending_effects(pending_effects, log)`. Deferred: `await flush_post_commit_effects(post_commit_effects, log)`. Both complete before the response returns. Production mode wraps the same helpers in `void ...` and threads `on_effect_error` into `flush_pending_effects`'s `on_rejection` callback for fan-out.
476
+ - **The deferred thunks never wait on the eager writes.** Every flush site (both modes here, and the per-message WS flush) invokes the `post_commit_effects` thunks before it awaits `pending_effects`, so a revocation's connection close is not held behind a slow audit INSERT. A thunk that needs a write — the audit emitter's success-row fan-out — awaits that write itself.
473
477
  - **Drain at the outer flush; discard at the dispatch site.** The successful-path _drain_ of both queues stays at the outer flush middleware (`server/app_server.ts`) + the per-message WS flush — adjacent, one location, no per-wrapper drain timing. What the dispatch sites (route-spec wrapper / `perform_action`) own is the _rollback discard_, via the shared `dispatch_with_post_commit_rollback` helper: on a handler throw they truncate `post_commit_effects` before the outer flush ever sees it, so the flush only drains effects from a committed handler. The eager `pending_effects` queue is never truncated — it drains regardless (attempt audits survive rollback).
474
478
  - Structurally satisfied by both `RouteContext` (HTTP) and `ActionContext` (RPC + WS) — they share the `{log, post_commit_effects}` shape, which is why this helper lives in `http/` rather than `actions/` or `auth/`.
475
479
 
@@ -533,7 +537,7 @@ a request. Twin of the Rust spine's `fuz_db_admin` router.
533
537
  400 `ERROR_TABLE_NOT_DELETABLE` before the key-shape check, because a generic
534
538
  storage endpoint has no business deleting a row whose meaning lives in the
535
539
  domain layer. `audit_log` is the sharpest: it is both the trail and, since
536
- `realtime/sse_auth_guard.ts` and the WS auth guard close live streams by
540
+ `realtime/sse_auth_guard.ts` and the WS auth guard close live connections by
537
541
  listening to audit events, part of how revocation propagates. The three
538
542
  singletons each fail differently on deletion — `bootstrap_lock` leaves
539
543
  `check_bootstrap_status` advertising a window `bootstrap_account.ts` then always
@@ -75,8 +75,8 @@ export declare const NON_BROWSABLE_TABLES: ReadonlyArray<string>;
75
75
  * row whose meaning lives in the domain layer:
76
76
  *
77
77
  * - `audit_log` — the trail and the tamper path would otherwise be the same
78
- * surface. It is also how revocation propagates: `realtime/sse_auth_guard.ts`
79
- * and the WS auth guard close live streams by listening to audit events, so a
78
+ * surface. It is also part of how revocation propagates: `realtime/sse_auth_guard.ts`
79
+ * and the WS auth guard close live connections by listening to audit events, so a
80
80
  * raw row delete here is invisible to them.
81
81
  * `bootstrap_lock` — deleting the singleton leaves `check_bootstrap_status`
82
82
  * advertising an open bootstrap window on the next boot that
@@ -103,10 +103,14 @@ export interface DbRouteDeps {
103
103
  * the call via `emit_after_commit`, so the success-only trail row can
104
104
  * never claim a delete whose transaction failed at COMMIT — the pool
105
105
  * routing means the write itself never rides the request transaction.
106
+ * The context carries both of the request's side-effect queues: the write
107
+ * goes on `pending_effects`, the row's listener fan-out on
108
+ * `post_commit_effects`.
106
109
  */
107
110
  audit: {
108
111
  emit: (ctx: {
109
112
  pending_effects: Array<Promise<void>>;
113
+ post_commit_effects: Array<() => void | Promise<void>>;
110
114
  }, input: {
111
115
  event_type: 'db_admin_row_delete';
112
116
  account_id: Uuid | null;
@@ -1 +1 @@
1
- {"version":3,"file":"db_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/db_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAEnD,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAqC,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAiBpF;;GAEG;AACH,MAAM,WAAW,SAAS;IACzB,UAAU,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC9B,WAAW,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,sDAAsD;AACtD,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAC/C,sDAAsD;AACtD,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,OAAO,CAAC;AAElD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,oBAAoB,EAAE,aAAa,CAAC,MAAM,CAKrD,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,oBAAoB,EAAE,aAAa,CAAC,MAAM,CAKrD,CAAC;AAEH;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B;;;;;OAKG;IACH,KAAK,EAAE;QACN,IAAI,EAAE,CACL,GAAG,EAAE;YAAE,eAAe,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAA;SAAE,EAC9C,KAAK,EAAE;YACN,UAAU,EAAE,qBAAqB,CAAC;YAClC,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;YACxB,EAAE,EAAE,MAAM,CAAC;YACX,QAAQ,EAAE;gBAAE,KAAK,EAAE,MAAM,CAAC;gBAAC,SAAS,EAAE,MAAM,CAAC;gBAAC,EAAE,EAAE,MAAM,CAAA;aAAE,CAAC;SAC3D,KACG,IAAI,CAAC;KACV,CAAC;CACF;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;;OAOG;IACH,gBAAgB,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC,8EAA8E;IAC9E,WAAW,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC3D,+EAA+E;IAC/E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAC7C;AA2CD;;GAEG;AACH,eAAO,MAAM,qBAAqB,GACjC,MAAM,WAAW,EACjB,SAAS,cAAc,KACrB,KAAK,CAAC,SAAS,CA4XjB,CAAC"}
1
+ {"version":3,"file":"db_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/db_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAEnD,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAqC,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAiBpF;;GAEG;AACH,MAAM,WAAW,SAAS;IACzB,UAAU,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC9B,WAAW,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,sDAAsD;AACtD,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAC/C,sDAAsD;AACtD,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,OAAO,CAAC;AAElD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,oBAAoB,EAAE,aAAa,CAAC,MAAM,CAKrD,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,oBAAoB,EAAE,aAAa,CAAC,MAAM,CAKrD,CAAC;AAEH;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B;;;;;;;;OAQG;IACH,KAAK,EAAE;QACN,IAAI,EAAE,CACL,GAAG,EAAE;YACJ,eAAe,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;YACtC,mBAAmB,EAAE,KAAK,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;SACvD,EACD,KAAK,EAAE;YACN,UAAU,EAAE,qBAAqB,CAAC;YAClC,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;YACxB,EAAE,EAAE,MAAM,CAAC;YACX,QAAQ,EAAE;gBAAE,KAAK,EAAE,MAAM,CAAC;gBAAC,SAAS,EAAE,MAAM,CAAC;gBAAC,EAAE,EAAE,MAAM,CAAA;aAAE,CAAC;SAC3D,KACG,IAAI,CAAC;KACV,CAAC;CACF;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;;OAOG;IACH,gBAAgB,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC,8EAA8E;IAC9E,WAAW,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC3D,+EAA+E;IAC/E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAC7C;AA2CD;;GAEG;AACH,eAAO,MAAM,qBAAqB,GACjC,MAAM,WAAW,EACjB,SAAS,cAAc,KACrB,KAAK,CAAC,SAAS,CA4XjB,CAAC"}
@@ -57,8 +57,8 @@ export const NON_BROWSABLE_TABLES = Object.freeze([
57
57
  * row whose meaning lives in the domain layer:
58
58
  *
59
59
  * - `audit_log` — the trail and the tamper path would otherwise be the same
60
- * surface. It is also how revocation propagates: `realtime/sse_auth_guard.ts`
61
- * and the WS auth guard close live streams by listening to audit events, so a
60
+ * surface. It is also part of how revocation propagates: `realtime/sse_auth_guard.ts`
61
+ * and the WS auth guard close live connections by listening to audit events, so a
62
62
  * raw row delete here is invisible to them.
63
63
  * `bootstrap_lock` — deleting the singleton leaves `check_bootstrap_status`
64
64
  * advertising an open bootstrap window on the next boot that
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Periodic background task primitive.
3
+ *
4
+ * The loop every long-running process reaches for when it owns upkeep — an
5
+ * expiry sweep, a cleanup pass: one pass at startup, then one per interval,
6
+ * until stopped. `start_periodic` is that loop in one place, so a sweep is
7
+ * its pass plus one call, and every sweep keeps the same rules for a slow
8
+ * pass, a failed pass, and shutdown. `start_auth_cleanup` (`auth/cleanup.ts`)
9
+ * is built on it; a consumer's own sweeps use it the same way:
10
+ *
11
+ * ```ts
12
+ * const temp_sweep = start_periodic('temp sweep', 60 * 60 * 1000, () => sweep_temps(root), log);
13
+ * // … serve …
14
+ * // on shutdown, before what the pass uses is torn down
15
+ * await temp_sweep.stop();
16
+ * ```
17
+ *
18
+ * Twin of the Rust spine's `fuz_sys::periodic::spawn_periodic`. The schedule
19
+ * is the same; shutdown differs, because a promise cannot be dropped — see
20
+ * `start_periodic`.
21
+ *
22
+ * @module
23
+ */
24
+ import type { Logger } from '@fuzdev/fuz_util/log.ts';
25
+ /**
26
+ * The longest interval `start_periodic` accepts: the largest delay a timer
27
+ * honors (a signed 32-bit count of milliseconds, about 24.8 days). Past it
28
+ * `setTimeout` fires after one millisecond instead, which would turn a long
29
+ * interval into a pass loop.
30
+ */
31
+ export declare const PERIODIC_INTERVAL_MS_MAX: number;
32
+ /** A running periodic task — the result of `start_periodic`. */
33
+ export interface PeriodicTask {
34
+ /**
35
+ * End the schedule. No pass starts once this is called, and the returned
36
+ * promise resolves when the pass in progress, if any, has finished — it
37
+ * never rejects. Await it before tearing down what the pass uses (the
38
+ * database, say). The wait has no bound of its own; a shutdown with a
39
+ * deadline races it. Safe to call more than once.
40
+ */
41
+ stop: () => Promise<void>;
42
+ }
43
+ /**
44
+ * Run `pass` now, then once per `interval_ms`, until `stop` is called.
45
+ *
46
+ * - **Startup pass.** The first pass starts before this returns; later passes
47
+ * follow one `interval_ms` apart, start to start.
48
+ * - **A slow pass delays the schedule.** Passes never overlap, and a pass that
49
+ * overruns its interval pushes the next one back: the next pass starts when
50
+ * the slow one ends, the one after a full `interval_ms` later — no burst of
51
+ * catch-up passes.
52
+ * - **A failed pass is logged, never fatal.** A pass that throws or rejects
53
+ * is logged at `warn` with `name` and the error object itself — so the log
54
+ * carries its stack and its `cause` — and the loop goes on to the next
55
+ * interval, so an outage is retried rather than ending the upkeep for the
56
+ * life of the process.
57
+ * - **`stop` finishes the pass in progress.** It ends the schedule at once and
58
+ * resolves when a pass in progress has finished; nothing is dropped
59
+ * mid-flight.
60
+ *
61
+ * The timer is unref'd where the runtime hands back a timer object (Node,
62
+ * Bun), so a task nobody stopped does not hold the process open.
63
+ *
64
+ * `pass` is called once per pass and returns that pass's promise. `name`
65
+ * labels the task in the failure log.
66
+ *
67
+ * Twin of the Rust `spawn_periodic`. The schedule is that loop's
68
+ * `MissedTickBehavior::Delay`. Shutdown differs: the Rust task drops a pass
69
+ * in progress when its token is cancelled, so joining it is prompt and a pass
70
+ * must be safe to drop; here the pass runs to its end and `stop` waits for it.
71
+ *
72
+ * @param name - labels the task in the failure log
73
+ * @param interval_ms - milliseconds between the start of one pass and the start of the next
74
+ * @param pass - one pass; called once per pass, its resolved value is ignored
75
+ * @param log - where a failed pass is logged
76
+ * @returns the task's `stop`
77
+ * @throws Error if `interval_ms` is not a positive finite number or exceeds `PERIODIC_INTERVAL_MS_MAX` — at the call site, before any pass starts
78
+ */
79
+ export declare const start_periodic: (name: string, interval_ms: number, pass: () => Promise<unknown>, log: Logger) => PeriodicTask;
80
+ //# sourceMappingURL=periodic.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"periodic.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/periodic.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,QAAc,CAAC;AAEpD,gEAAgE;AAChE,MAAM,WAAW,YAAY;IAC5B;;;;;;OAMG;IACH,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,cAAc,GAC1B,MAAM,MAAM,EACZ,aAAa,MAAM,EACnB,MAAM,MAAM,OAAO,CAAC,OAAO,CAAC,EAC5B,KAAK,MAAM,KACT,YA4DF,CAAC"}
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Periodic background task primitive.
3
+ *
4
+ * The loop every long-running process reaches for when it owns upkeep — an
5
+ * expiry sweep, a cleanup pass: one pass at startup, then one per interval,
6
+ * until stopped. `start_periodic` is that loop in one place, so a sweep is
7
+ * its pass plus one call, and every sweep keeps the same rules for a slow
8
+ * pass, a failed pass, and shutdown. `start_auth_cleanup` (`auth/cleanup.ts`)
9
+ * is built on it; a consumer's own sweeps use it the same way:
10
+ *
11
+ * ```ts
12
+ * const temp_sweep = start_periodic('temp sweep', 60 * 60 * 1000, () => sweep_temps(root), log);
13
+ * // … serve …
14
+ * // on shutdown, before what the pass uses is torn down
15
+ * await temp_sweep.stop();
16
+ * ```
17
+ *
18
+ * Twin of the Rust spine's `fuz_sys::periodic::spawn_periodic`. The schedule
19
+ * is the same; shutdown differs, because a promise cannot be dropped — see
20
+ * `start_periodic`.
21
+ *
22
+ * @module
23
+ */
24
+ /**
25
+ * The longest interval `start_periodic` accepts: the largest delay a timer
26
+ * honors (a signed 32-bit count of milliseconds, about 24.8 days). Past it
27
+ * `setTimeout` fires after one millisecond instead, which would turn a long
28
+ * interval into a pass loop.
29
+ */
30
+ export const PERIODIC_INTERVAL_MS_MAX = 2 ** 31 - 1;
31
+ /**
32
+ * Run `pass` now, then once per `interval_ms`, until `stop` is called.
33
+ *
34
+ * - **Startup pass.** The first pass starts before this returns; later passes
35
+ * follow one `interval_ms` apart, start to start.
36
+ * - **A slow pass delays the schedule.** Passes never overlap, and a pass that
37
+ * overruns its interval pushes the next one back: the next pass starts when
38
+ * the slow one ends, the one after a full `interval_ms` later — no burst of
39
+ * catch-up passes.
40
+ * - **A failed pass is logged, never fatal.** A pass that throws or rejects
41
+ * is logged at `warn` with `name` and the error object itself — so the log
42
+ * carries its stack and its `cause` — and the loop goes on to the next
43
+ * interval, so an outage is retried rather than ending the upkeep for the
44
+ * life of the process.
45
+ * - **`stop` finishes the pass in progress.** It ends the schedule at once and
46
+ * resolves when a pass in progress has finished; nothing is dropped
47
+ * mid-flight.
48
+ *
49
+ * The timer is unref'd where the runtime hands back a timer object (Node,
50
+ * Bun), so a task nobody stopped does not hold the process open.
51
+ *
52
+ * `pass` is called once per pass and returns that pass's promise. `name`
53
+ * labels the task in the failure log.
54
+ *
55
+ * Twin of the Rust `spawn_periodic`. The schedule is that loop's
56
+ * `MissedTickBehavior::Delay`. Shutdown differs: the Rust task drops a pass
57
+ * in progress when its token is cancelled, so joining it is prompt and a pass
58
+ * must be safe to drop; here the pass runs to its end and `stop` waits for it.
59
+ *
60
+ * @param name - labels the task in the failure log
61
+ * @param interval_ms - milliseconds between the start of one pass and the start of the next
62
+ * @param pass - one pass; called once per pass, its resolved value is ignored
63
+ * @param log - where a failed pass is logged
64
+ * @returns the task's `stop`
65
+ * @throws Error if `interval_ms` is not a positive finite number or exceeds `PERIODIC_INTERVAL_MS_MAX` — at the call site, before any pass starts
66
+ */
67
+ export const start_periodic = (name, interval_ms, pass, log) => {
68
+ if (!Number.isFinite(interval_ms) || interval_ms <= 0 || interval_ms > PERIODIC_INTERVAL_MS_MAX) {
69
+ throw new Error(`start_periodic (${name}): interval_ms must be a positive number of milliseconds no greater than ${PERIODIC_INTERVAL_MS_MAX}, got ${interval_ms}`);
70
+ }
71
+ let stopped = false;
72
+ let timer = null;
73
+ let in_progress = null;
74
+ const run_pass = async () => {
75
+ try {
76
+ await pass();
77
+ }
78
+ catch (error) {
79
+ // the error object itself, so the log carries its stack and cause
80
+ log.warn(`${name} pass failed:`, error);
81
+ }
82
+ };
83
+ // One pass plus the timer for the next one's earliest start. The next pass
84
+ // starts when both have happened — the interval elapsed and this pass
85
+ // finished — whichever comes last, so passes never overlap and an overrun
86
+ // delays the schedule by exactly its length.
87
+ const start_pass = () => {
88
+ let due = false;
89
+ let finished = false;
90
+ timer = setTimeout(() => {
91
+ timer = null;
92
+ due = true;
93
+ if (finished)
94
+ start_pass();
95
+ }, interval_ms);
96
+ // allow the process to exit even if the timer is still pending
97
+ if (typeof timer === 'object' && 'unref' in timer) {
98
+ timer.unref();
99
+ }
100
+ in_progress = run_pass()
101
+ // `run_pass` rejects only if the logger itself throws; swallowed so
102
+ // `stop` never rejects and nothing surfaces as an unhandled rejection
103
+ .catch(() => { })
104
+ .finally(() => {
105
+ in_progress = null;
106
+ finished = true;
107
+ if (due && !stopped)
108
+ start_pass();
109
+ });
110
+ };
111
+ start_pass();
112
+ return {
113
+ stop: async () => {
114
+ stopped = true;
115
+ if (timer !== null) {
116
+ clearTimeout(timer);
117
+ timer = null;
118
+ }
119
+ await in_progress;
120
+ }
121
+ };
122
+ };