@fuzdev/fuz_app 0.118.0 → 0.120.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.
- package/dist/actions/CLAUDE.md +301 -73
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +16 -1
- package/dist/actions/connection_closer.d.ts +136 -51
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/connection_closer.js +133 -34
- package/dist/actions/register_action_ws.d.ts +176 -24
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +581 -241
- package/dist/actions/register_ws_endpoint.d.ts +1 -0
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +1 -0
- package/dist/actions/socket.svelte.d.ts +43 -9
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +59 -8
- package/dist/actions/transports.d.ts +55 -1
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +55 -1
- package/dist/actions/transports_ws_auth_guard.d.ts +53 -67
- package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
- package/dist/actions/transports_ws_auth_guard.js +87 -111
- package/dist/actions/transports_ws_backend.d.ts +212 -30
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +327 -70
- package/dist/actions/ws_endpoint_spec.d.ts +26 -13
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/auth/CLAUDE.md +113 -32
- package/dist/auth/account_actions.d.ts +12 -18
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +47 -33
- package/dist/auth/account_routes.d.ts +5 -11
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +40 -36
- package/dist/auth/account_schema.d.ts +27 -0
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +22 -0
- package/dist/auth/admin_actions.d.ts +8 -17
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +33 -21
- package/dist/auth/api_token_queries.d.ts +23 -2
- package/dist/auth/api_token_queries.d.ts.map +1 -1
- package/dist/auth/api_token_queries.js +39 -3
- package/dist/auth/audit_emitter.d.ts +61 -38
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +57 -14
- package/dist/auth/audit_log_route_schema.js +7 -7
- package/dist/auth/audit_log_routes.d.ts +31 -6
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +85 -11
- package/dist/auth/audit_log_schema.d.ts +48 -0
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +58 -0
- package/dist/auth/cleanup.d.ts +203 -28
- package/dist/auth/cleanup.d.ts.map +1 -1
- package/dist/auth/cleanup.js +222 -26
- package/dist/auth/deps.d.ts +31 -3
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/resolved_auth.d.ts +105 -0
- package/dist/auth/resolved_auth.d.ts.map +1 -0
- package/dist/auth/resolved_auth.js +124 -0
- package/dist/auth/session_middleware.d.ts +8 -1
- package/dist/auth/session_middleware.d.ts.map +1 -1
- package/dist/auth/session_middleware.js +9 -3
- package/dist/auth/session_queries.d.ts +31 -6
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +31 -6
- package/dist/auth/standard_rpc_actions.d.ts +9 -9
- package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
- package/dist/auth/standard_rpc_actions.js +3 -3
- package/dist/db/CLAUDE.md +19 -8
- package/dist/db/fact_disk_storage.d.ts +10 -8
- package/dist/db/fact_disk_storage.d.ts.map +1 -1
- package/dist/db/fact_disk_storage.js +13 -17
- package/dist/db/fact_store.d.ts +39 -38
- package/dist/db/fact_store.d.ts.map +1 -1
- package/dist/db/fact_store.js +48 -86
- package/dist/db/file_fact_url.d.ts +6 -6
- package/dist/db/file_fact_url.js +6 -6
- package/dist/db/status.d.ts +58 -1
- package/dist/db/status.d.ts.map +1 -1
- package/dist/db/status.js +205 -77
- package/dist/http/CLAUDE.md +7 -3
- package/dist/http/db_routes.d.ts +6 -2
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +2 -2
- package/dist/periodic.d.ts +80 -0
- package/dist/periodic.d.ts.map +1 -0
- package/dist/periodic.js +122 -0
- package/dist/realtime/sse_auth_guard.d.ts +79 -49
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +103 -73
- package/dist/realtime/subscriber_registry.d.ts +119 -8
- package/dist/realtime/subscriber_registry.d.ts.map +1 -1
- package/dist/realtime/subscriber_registry.js +184 -25
- package/dist/server/app_backend.d.ts +12 -1
- package/dist/server/app_backend.d.ts.map +1 -1
- package/dist/server/app_backend.js +5 -2
- package/dist/server/app_server.d.ts +55 -15
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +41 -13
- package/dist/server/serve_fact_route.d.ts +10 -7
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +11 -8
- package/dist/testing/CLAUDE.md +140 -48
- package/dist/testing/app_server.d.ts +2 -2
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +11 -2
- package/dist/testing/audit_drift_guard.d.ts +1 -48
- package/dist/testing/audit_drift_guard.d.ts.map +1 -1
- package/dist/testing/audit_drift_guard.js +0 -42
- package/dist/testing/connection_closer_helpers.d.ts +9 -22
- package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
- package/dist/testing/connection_closer_helpers.js +11 -17
- package/dist/testing/cross_backend/capabilities.d.ts +67 -13
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +27 -12
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +21 -0
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +13 -4
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +4 -1
- package/dist/testing/cross_backend/session_cap.d.ts +13 -0
- package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
- package/dist/testing/cross_backend/session_cap.js +55 -19
- package/dist/testing/cross_backend/sse_round_trip.d.ts +10 -2
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/sse_round_trip.js +93 -26
- package/dist/testing/cross_backend/testing_server_bun.js +4 -4
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +10 -2
- package/dist/testing/cross_backend/ws_connection_cap.d.ts +35 -0
- package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_connection_cap.js +131 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts +2 -1
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_round_trip.js +156 -19
- package/dist/testing/sse_round_trip.d.ts +6 -4
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/stubs.d.ts +1 -1
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +6 -4
- package/dist/testing/transports/sse_transport.d.ts +1 -1
- package/dist/testing/transports/ws_client.d.ts +7 -0
- package/dist/testing/transports/ws_client.d.ts.map +1 -1
- package/dist/testing/transports/ws_raw_client.d.ts +51 -0
- package/dist/testing/transports/ws_raw_client.d.ts.map +1 -0
- package/dist/testing/transports/ws_raw_client.js +156 -0
- package/dist/testing/transports/ws_transport.d.ts +28 -0
- package/dist/testing/transports/ws_transport.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.js +45 -1
- package/dist/testing/ws_round_trip.d.ts +8 -3
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +19 -4
- package/dist/ui/AdminAccounts.svelte +4 -3
- package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
- package/dist/ui/AdminOverview.svelte +3 -1
- package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +22 -6
- package/dist/ui/admin_accounts_state.svelte.d.ts +15 -1
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +16 -0
- package/dist/ui/auth_state.svelte.d.ts +25 -1
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +28 -1
- package/package.json +2 -2
- package/dist/server/file_fact_fetcher.d.ts +0 -42
- package/dist/server/file_fact_fetcher.d.ts.map +0 -1
- package/dist/server/file_fact_fetcher.js +0 -60
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
148
|
-
|
|
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(
|
|
151
|
-
lines.push(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
};
|
package/dist/http/CLAUDE.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
package/dist/http/db_routes.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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"}
|
package/dist/http/db_routes.js
CHANGED
|
@@ -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
|
|
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"}
|
package/dist/periodic.js
ADDED
|
@@ -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
|
+
};
|