@fuzdev/fuz_app 0.108.0 → 0.109.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/README.md +4 -0
- package/dist/actions/CLAUDE.md +1 -1
- package/dist/auth/CLAUDE.md +1 -1
- package/dist/auth/audit_emitter.d.ts +9 -5
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.d.ts +7 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +16 -1
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +11 -1
- package/dist/auth/migrations.d.ts +7 -0
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +25 -8
- package/dist/db/CLAUDE.md +9 -5
- package/dist/db/cell_audit_queries.d.ts +4 -2
- package/dist/db/cell_audit_queries.d.ts.map +1 -1
- package/dist/db/cell_audit_queries.js +4 -2
- package/dist/db/pg_error.d.ts +12 -0
- package/dist/db/pg_error.d.ts.map +1 -1
- package/dist/db/pg_error.js +15 -1
- package/dist/http/CLAUDE.md +78 -18
- package/dist/http/db_routes.d.ts +96 -3
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +263 -46
- package/dist/http/error_schemas.d.ts +12 -1
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +12 -1
- package/dist/http/pending_effects.d.ts +5 -5
- package/dist/http/pending_effects.d.ts.map +1 -1
- package/dist/http/pending_effects.js +1 -1
- package/dist/testing/CLAUDE.md +21 -0
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +5 -1
- package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_relations.js +53 -1
- package/dist/testing/cross_backend/query_shape.d.ts +10 -0
- package/dist/testing/cross_backend/query_shape.d.ts.map +1 -0
- package/dist/testing/cross_backend/query_shape.js +128 -0
- package/dist/ui/CLAUDE.md +9 -4
- package/dist/ui/table_state.svelte.d.ts +18 -5
- package/dist/ui/table_state.svelte.d.ts.map +1 -1
- package/dist/ui/table_state.svelte.js +22 -7
- package/dist/ui/ui_format.d.ts.map +1 -1
- package/dist/ui/ui_format.js +4 -0
- package/package.json +1 -1
package/dist/http/db_routes.d.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API route specs for database administration.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Allowlist-gated PostgreSQL table browser using `information_schema` —
|
|
5
|
+
* consumers declare `browsable_tables` and the credential floor
|
|
6
|
+
* (`NON_BROWSABLE_TABLES`) is always subtracted. Provides: list tables, view
|
|
7
|
+
* columns/rows (paginated, bytea values placeholdered), delete rows by PK
|
|
8
|
+
* (audited as `db_admin_row_delete`), health check.
|
|
6
9
|
*
|
|
7
10
|
* @module
|
|
8
11
|
*/
|
|
9
12
|
import type { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
13
|
+
import type { Uuid } from '@fuzdev/fuz_util/id.ts';
|
|
10
14
|
import type { Db, DbType } from '../db/db.js';
|
|
11
15
|
import { type RouteSpec } from './route_spec.js';
|
|
12
16
|
/**
|
|
@@ -40,19 +44,108 @@ export interface ColumnInfo {
|
|
|
40
44
|
export declare const DB_TABLE_ROWS_DEFAULT_LIMIT = 100;
|
|
41
45
|
/** Maximum page size for `GET /tables/:name` rows. */
|
|
42
46
|
export declare const DB_TABLE_ROWS_LIMIT_MAX = 1000;
|
|
47
|
+
/**
|
|
48
|
+
* Per-statement timeout applied (`SET LOCAL statement_timeout`) inside the
|
|
49
|
+
* table-list, table-detail, and row-`DELETE` transactions — no single
|
|
50
|
+
* statement outlives it. (Per statement, not per request: the table listing's
|
|
51
|
+
* one-`COUNT(*)`-per-table loop can still take several in sequence.)
|
|
52
|
+
* Milliseconds.
|
|
53
|
+
*/
|
|
54
|
+
export declare const DB_ADMIN_STATEMENT_TIMEOUT_MS = 5000;
|
|
55
|
+
/**
|
|
56
|
+
* Tables the browser can never expose, whatever `browsable_tables` names —
|
|
57
|
+
* the credential floor, subtracted from the consumer's allowlist and not
|
|
58
|
+
* overridable:
|
|
59
|
+
*
|
|
60
|
+
* - `account` — the one surface that would return the argon2 password-hash
|
|
61
|
+
* corpus in bulk (every other read path excludes the hash by projection).
|
|
62
|
+
* - `auth_session` / `api_token` — credential digests plus per-account
|
|
63
|
+
* session/token metadata.
|
|
64
|
+
* - `bootstrap_lock` — the first-admin gate; its row is load-bearing state,
|
|
65
|
+
* not content.
|
|
66
|
+
*
|
|
67
|
+
* A table on the floor answers exactly like one that doesn't exist (404
|
|
68
|
+
* `table_not_found`) so the browser doesn't confirm its presence.
|
|
69
|
+
*/
|
|
70
|
+
export declare const NON_BROWSABLE_TABLES: ReadonlyArray<string>;
|
|
71
|
+
/**
|
|
72
|
+
* Tables this endpoint refuses to delete rows from, whatever their key shape.
|
|
73
|
+
*
|
|
74
|
+
* Two kinds, one rule — a generic storage endpoint has no business deleting a
|
|
75
|
+
* row whose meaning lives in the domain layer:
|
|
76
|
+
*
|
|
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
|
|
80
|
+
* raw row delete here is invisible to them.
|
|
81
|
+
* `bootstrap_lock` — deleting the singleton leaves `check_bootstrap_status`
|
|
82
|
+
* advertising an open bootstrap window on the next boot that
|
|
83
|
+
* `bootstrap_account.ts`'s atomic flip then always refuses.
|
|
84
|
+
* - `app_settings` — deleting the singleton makes every settings read throw.
|
|
85
|
+
* - `schema_version` — the migration tracker. Already refused by the
|
|
86
|
+
* single-column-primary-key rule (its key is `(namespace, name)`), but that
|
|
87
|
+
* is incidental: naming it here keeps the protection if the key shape ever
|
|
88
|
+
* changes.
|
|
89
|
+
*
|
|
90
|
+
* Consumers extend this with `DbRouteOptions.non_deletable_tables` — the
|
|
91
|
+
* builtin set is a floor, never replaced.
|
|
92
|
+
*/
|
|
93
|
+
export declare const NON_DELETABLE_TABLES: ReadonlyArray<string>;
|
|
94
|
+
/**
|
|
95
|
+
* Capabilities the db routes need — a narrow structural slice of `AppDeps`
|
|
96
|
+
* (`auth/deps.ts`), declared here so `http/` stays auth-free. The bound
|
|
97
|
+
* `auth/audit_emitter.ts` `AuditEmitter` (and therefore `AppDeps.audit` /
|
|
98
|
+
* `RouteFactoryDeps.audit`) satisfies `audit` structurally.
|
|
99
|
+
*/
|
|
100
|
+
export interface DbRouteDeps {
|
|
101
|
+
/**
|
|
102
|
+
* Pool-routed fire-and-forget audit emit. The row-`DELETE` handler defers
|
|
103
|
+
* the call via `emit_after_commit`, so the success-only trail row can
|
|
104
|
+
* never claim a delete whose transaction failed at COMMIT — the pool
|
|
105
|
+
* routing means the write itself never rides the request transaction.
|
|
106
|
+
*/
|
|
107
|
+
audit: {
|
|
108
|
+
emit: (ctx: {
|
|
109
|
+
pending_effects: Array<Promise<void>>;
|
|
110
|
+
}, input: {
|
|
111
|
+
event_type: 'db_admin_row_delete';
|
|
112
|
+
account_id: Uuid | null;
|
|
113
|
+
ip: string;
|
|
114
|
+
metadata: {
|
|
115
|
+
table: string;
|
|
116
|
+
pk_column: string;
|
|
117
|
+
id: string;
|
|
118
|
+
};
|
|
119
|
+
}) => void;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
43
122
|
/**
|
|
44
123
|
* Per-factory configuration for db routes.
|
|
45
124
|
*/
|
|
46
125
|
export interface DbRouteOptions {
|
|
47
126
|
db_type: DbType;
|
|
48
127
|
db_name: string;
|
|
128
|
+
/**
|
|
129
|
+
* The tables the browser exposes — an explicit allowlist gating the table
|
|
130
|
+
* list, table detail, and row-`DELETE` alike. Required with no "all"
|
|
131
|
+
* escape hatch, so a future secret-bearing table stays unlisted until a
|
|
132
|
+
* consumer names it, and `NON_BROWSABLE_TABLES` is subtracted even when
|
|
133
|
+
* named. An unlisted table 404s as `table_not_found` — the same answer as
|
|
134
|
+
* a table that doesn't exist.
|
|
135
|
+
*/
|
|
136
|
+
browsable_tables: ReadonlyArray<string>;
|
|
49
137
|
/** Optional callback to provide app-specific stats in the health response. */
|
|
50
138
|
extra_stats?: (db: Db) => Promise<Record<string, unknown>>;
|
|
51
139
|
/** Optional logger for server-side diagnostics (e.g. FK violation details). */
|
|
52
140
|
log?: Logger;
|
|
141
|
+
/**
|
|
142
|
+
* Consumer tables to exclude from row deletion, unioned with the builtin
|
|
143
|
+
* `NON_DELETABLE_TABLES` (which this never replaces).
|
|
144
|
+
*/
|
|
145
|
+
non_deletable_tables?: ReadonlyArray<string>;
|
|
53
146
|
}
|
|
54
147
|
/**
|
|
55
148
|
* Create the db API route specs.
|
|
56
149
|
*/
|
|
57
|
-
export declare const create_db_route_specs: (options: DbRouteOptions) => Array<RouteSpec>;
|
|
150
|
+
export declare const create_db_route_specs: (deps: DbRouteDeps, options: DbRouteOptions) => Array<RouteSpec>;
|
|
58
151
|
//# sourceMappingURL=db_routes.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"db_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/db_routes.ts"],"names":[],"mappings":"AAAA
|
|
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"}
|
package/dist/http/db_routes.js
CHANGED
|
@@ -1,25 +1,155 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API route specs for database administration.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Allowlist-gated PostgreSQL table browser using `information_schema` —
|
|
5
|
+
* consumers declare `browsable_tables` and the credential floor
|
|
6
|
+
* (`NON_BROWSABLE_TABLES`) is always subtracted. Provides: list tables, view
|
|
7
|
+
* columns/rows (paginated, bytea values placeholdered), delete rows by PK
|
|
8
|
+
* (audited as `db_admin_row_delete`), health check.
|
|
6
9
|
*
|
|
7
10
|
* @module
|
|
8
11
|
*/
|
|
9
12
|
import { z } from 'zod';
|
|
10
13
|
import { get_route_params, get_route_query } from './route_spec.js';
|
|
11
14
|
import { ActingActor } from './auth_shape.js';
|
|
12
|
-
import { ForeignKeyError, ERROR_TABLE_NOT_FOUND, ERROR_TABLE_NO_PRIMARY_KEY, ERROR_ROW_NOT_FOUND, ERROR_FOREIGN_KEY_VIOLATION, ERROR_INVALID_ROUTE_PARAMS, ERROR_DATABASE_CONNECTION_FAILED } from './error_schemas.js';
|
|
15
|
+
import { ForeignKeyError, ERROR_TABLE_NOT_FOUND, ERROR_TABLE_NO_PRIMARY_KEY, ERROR_TABLE_NOT_DELETABLE, ERROR_ROW_NOT_FOUND, ERROR_FOREIGN_KEY_VIOLATION, ERROR_INVALID_ROUTE_PARAMS, ERROR_DATABASE_CONNECTION_FAILED } from './error_schemas.js';
|
|
13
16
|
import { assert_valid_sql_identifier, VALID_SQL_IDENTIFIER } from '../db/sql_identifier.js';
|
|
17
|
+
import { pg_error_code } from '../db/pg_error.js';
|
|
18
|
+
import { get_client_ip } from './client_ip.js';
|
|
19
|
+
import { emit_after_commit } from './pending_effects.js';
|
|
14
20
|
/** Default page size for `GET /tables/:name` rows. */
|
|
15
21
|
export const DB_TABLE_ROWS_DEFAULT_LIMIT = 100;
|
|
16
22
|
/** Maximum page size for `GET /tables/:name` rows. */
|
|
17
23
|
export const DB_TABLE_ROWS_LIMIT_MAX = 1000;
|
|
24
|
+
/**
|
|
25
|
+
* Per-statement timeout applied (`SET LOCAL statement_timeout`) inside the
|
|
26
|
+
* table-list, table-detail, and row-`DELETE` transactions — no single
|
|
27
|
+
* statement outlives it. (Per statement, not per request: the table listing's
|
|
28
|
+
* one-`COUNT(*)`-per-table loop can still take several in sequence.)
|
|
29
|
+
* Milliseconds.
|
|
30
|
+
*/
|
|
31
|
+
export const DB_ADMIN_STATEMENT_TIMEOUT_MS = 5000;
|
|
32
|
+
/**
|
|
33
|
+
* Tables the browser can never expose, whatever `browsable_tables` names —
|
|
34
|
+
* the credential floor, subtracted from the consumer's allowlist and not
|
|
35
|
+
* overridable:
|
|
36
|
+
*
|
|
37
|
+
* - `account` — the one surface that would return the argon2 password-hash
|
|
38
|
+
* corpus in bulk (every other read path excludes the hash by projection).
|
|
39
|
+
* - `auth_session` / `api_token` — credential digests plus per-account
|
|
40
|
+
* session/token metadata.
|
|
41
|
+
* - `bootstrap_lock` — the first-admin gate; its row is load-bearing state,
|
|
42
|
+
* not content.
|
|
43
|
+
*
|
|
44
|
+
* A table on the floor answers exactly like one that doesn't exist (404
|
|
45
|
+
* `table_not_found`) so the browser doesn't confirm its presence.
|
|
46
|
+
*/
|
|
47
|
+
export const NON_BROWSABLE_TABLES = Object.freeze([
|
|
48
|
+
'account',
|
|
49
|
+
'auth_session',
|
|
50
|
+
'api_token',
|
|
51
|
+
'bootstrap_lock'
|
|
52
|
+
]);
|
|
53
|
+
/**
|
|
54
|
+
* Tables this endpoint refuses to delete rows from, whatever their key shape.
|
|
55
|
+
*
|
|
56
|
+
* Two kinds, one rule — a generic storage endpoint has no business deleting a
|
|
57
|
+
* row whose meaning lives in the domain layer:
|
|
58
|
+
*
|
|
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
|
|
62
|
+
* raw row delete here is invisible to them.
|
|
63
|
+
* `bootstrap_lock` — deleting the singleton leaves `check_bootstrap_status`
|
|
64
|
+
* advertising an open bootstrap window on the next boot that
|
|
65
|
+
* `bootstrap_account.ts`'s atomic flip then always refuses.
|
|
66
|
+
* - `app_settings` — deleting the singleton makes every settings read throw.
|
|
67
|
+
* - `schema_version` — the migration tracker. Already refused by the
|
|
68
|
+
* single-column-primary-key rule (its key is `(namespace, name)`), but that
|
|
69
|
+
* is incidental: naming it here keeps the protection if the key shape ever
|
|
70
|
+
* changes.
|
|
71
|
+
*
|
|
72
|
+
* Consumers extend this with `DbRouteOptions.non_deletable_tables` — the
|
|
73
|
+
* builtin set is a floor, never replaced.
|
|
74
|
+
*/
|
|
75
|
+
export const NON_DELETABLE_TABLES = Object.freeze([
|
|
76
|
+
'audit_log',
|
|
77
|
+
'bootstrap_lock',
|
|
78
|
+
'app_settings',
|
|
79
|
+
'schema_version'
|
|
80
|
+
]);
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a table's primary-key column names in key order
|
|
83
|
+
* (`key_column_usage.ordinal_position`). Empty for a table with no primary key;
|
|
84
|
+
* length `> 1` for a composite primary key.
|
|
85
|
+
*
|
|
86
|
+
* Every column is returned (no `LIMIT`) so callers see the full key shape: the
|
|
87
|
+
* row-`DELETE` route acts only on a single-column primary key, and an earlier
|
|
88
|
+
* `LIMIT 1` (no `ORDER BY`) hid a composite PK behind one arbitrary column,
|
|
89
|
+
* causing single-column deletes to over-match.
|
|
90
|
+
*/
|
|
91
|
+
const query_primary_key_columns = async (db, name) => {
|
|
92
|
+
const pk_rows = await db.query(`SELECT kcu.column_name
|
|
93
|
+
FROM information_schema.table_constraints tc
|
|
94
|
+
JOIN information_schema.key_column_usage kcu
|
|
95
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
96
|
+
AND tc.table_schema = kcu.table_schema
|
|
97
|
+
AND tc.table_name = kcu.table_name
|
|
98
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
99
|
+
AND tc.table_schema = 'public'
|
|
100
|
+
AND tc.table_name = $1
|
|
101
|
+
ORDER BY kcu.ordinal_position`, [name]);
|
|
102
|
+
return pk_rows.map((row) => row.column_name);
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Whether `name` is a table in the `public` schema. Both the detail and
|
|
106
|
+
* row-`DELETE` routes gate on this before interpolating the name into SQL, so
|
|
107
|
+
* an unknown table is a 404 rather than a raw PG error.
|
|
108
|
+
*/
|
|
109
|
+
const query_table_exists = async (db, name) => {
|
|
110
|
+
const row = await db.query_one(`SELECT table_name FROM information_schema.tables
|
|
111
|
+
WHERE table_schema = 'public' AND table_name = $1`, [name]);
|
|
112
|
+
return row !== undefined;
|
|
113
|
+
};
|
|
18
114
|
/**
|
|
19
115
|
* Create the db API route specs.
|
|
20
116
|
*/
|
|
21
|
-
export const create_db_route_specs = (options) => {
|
|
22
|
-
const { db_type, db_name, extra_stats, log } = options;
|
|
117
|
+
export const create_db_route_specs = (deps, options) => {
|
|
118
|
+
const { db_type, db_name, browsable_tables, extra_stats, log, non_deletable_tables } = options;
|
|
119
|
+
const non_deletable = new Set([
|
|
120
|
+
...NON_DELETABLE_TABLES,
|
|
121
|
+
...(non_deletable_tables ?? [])
|
|
122
|
+
]);
|
|
123
|
+
// The credential floor is subtracted, not merely documented — a consumer
|
|
124
|
+
// naming `account` in its allowlist still gets no `account` browsing.
|
|
125
|
+
const browsable = new Set(browsable_tables.filter((name) => !NON_BROWSABLE_TABLES.includes(name)));
|
|
126
|
+
// `SET` can't be parameterized; the value is a module constant, never input.
|
|
127
|
+
const set_statement_timeout = async (db) => {
|
|
128
|
+
await db.query(`SET LOCAL statement_timeout = ${DB_ADMIN_STATEMENT_TIMEOUT_MS}`);
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* The shared masked gate prologue for the detail and row-`DELETE`
|
|
132
|
+
* handlers: allowlist check (before any catalog read), then the statement
|
|
133
|
+
* timeout, then existence — an unbrowsable table and a missing one get
|
|
134
|
+
* the same 404 from the same place, so the masking invariant has one
|
|
135
|
+
* home. Returns whether the table is browsable AND exists.
|
|
136
|
+
*/
|
|
137
|
+
const gate_browsable_table = async (db, name) => {
|
|
138
|
+
if (!browsable.has(name))
|
|
139
|
+
return false;
|
|
140
|
+
await set_statement_timeout(db);
|
|
141
|
+
return query_table_exists(db, name);
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* Strict integer query param — accepts exactly what the Rust twin's
|
|
145
|
+
* `str::parse::<i64>` does (`[+-]?digits`), so the twins refuse the same
|
|
146
|
+
* spellings (`z.coerce` alone admits `""`, `" 5"`, `"1e2"`, `"5.0"`).
|
|
147
|
+
*/
|
|
148
|
+
const query_int = (bounds, fallback) => z.preprocess((v) => v === undefined
|
|
149
|
+
? undefined
|
|
150
|
+
: typeof v === 'string' && /^[+-]?\d+$/.test(v)
|
|
151
|
+
? Number(v)
|
|
152
|
+
: Number.NaN, bounds.default(fallback));
|
|
23
153
|
return [
|
|
24
154
|
{
|
|
25
155
|
method: 'GET',
|
|
@@ -40,6 +170,9 @@ export const create_db_route_specs = (options) => {
|
|
|
40
170
|
handler: async (c, route) => {
|
|
41
171
|
try {
|
|
42
172
|
await route.db.query('SELECT 1');
|
|
173
|
+
// Deliberately schema-wide (not allowlist-filtered): the count
|
|
174
|
+
// is a did-migrations-run diagnostic, and a bare number leaks
|
|
175
|
+
// no table contents or names.
|
|
43
176
|
const table_result = await route.db.query(`SELECT COUNT(*) as count FROM information_schema.tables WHERE table_schema = 'public'`);
|
|
44
177
|
const table_count = table_result[0] ? parseInt(table_result[0].count, 10) : 0;
|
|
45
178
|
const stats = extra_stats ? await extra_stats(route.db) : {};
|
|
@@ -70,18 +203,24 @@ export const create_db_route_specs = (options) => {
|
|
|
70
203
|
roles: ['keeper'],
|
|
71
204
|
credential_types: ['daemon_token']
|
|
72
205
|
},
|
|
73
|
-
description: 'List
|
|
206
|
+
description: 'List browsable tables with row counts',
|
|
74
207
|
query: z.strictObject({ acting: ActingActor }),
|
|
75
208
|
input: z.null(),
|
|
76
209
|
output: z.looseObject({
|
|
77
210
|
tables: z.array(z.strictObject({ name: z.string(), row_count: z.number() }))
|
|
78
211
|
}),
|
|
212
|
+
transaction: true,
|
|
79
213
|
handler: async (c, route) => {
|
|
214
|
+
await set_statement_timeout(route.db);
|
|
215
|
+
// Existence still comes from `information_schema` — a listed table
|
|
216
|
+
// that hasn't migrated in yet is silently absent, not a 500.
|
|
80
217
|
const table_names = await route.db.query(`SELECT table_name FROM information_schema.tables
|
|
81
218
|
WHERE table_schema = 'public'
|
|
82
219
|
ORDER BY table_name`);
|
|
83
220
|
const tables = [];
|
|
84
221
|
for (const { table_name } of table_names) {
|
|
222
|
+
if (!browsable.has(table_name))
|
|
223
|
+
continue;
|
|
85
224
|
const result = await route.db.query_one(`SELECT COUNT(*) as count FROM "${assert_valid_sql_identifier(table_name)}"`);
|
|
86
225
|
tables.push({
|
|
87
226
|
name: table_name,
|
|
@@ -104,13 +243,8 @@ export const create_db_route_specs = (options) => {
|
|
|
104
243
|
params: z.strictObject({ name: z.string().regex(VALID_SQL_IDENTIFIER) }),
|
|
105
244
|
query: z.strictObject({
|
|
106
245
|
acting: ActingActor,
|
|
107
|
-
offset: z.
|
|
108
|
-
limit: z.
|
|
109
|
-
.number()
|
|
110
|
-
.int()
|
|
111
|
-
.min(1)
|
|
112
|
-
.max(DB_TABLE_ROWS_LIMIT_MAX)
|
|
113
|
-
.default(DB_TABLE_ROWS_DEFAULT_LIMIT)
|
|
246
|
+
offset: query_int(z.number().int().min(0), 0),
|
|
247
|
+
limit: query_int(z.number().int().min(1).max(DB_TABLE_ROWS_LIMIT_MAX), DB_TABLE_ROWS_DEFAULT_LIMIT)
|
|
114
248
|
}),
|
|
115
249
|
input: z.null(),
|
|
116
250
|
errors: {
|
|
@@ -127,34 +261,59 @@ export const create_db_route_specs = (options) => {
|
|
|
127
261
|
total: z.number(),
|
|
128
262
|
offset: z.number(),
|
|
129
263
|
limit: z.number(),
|
|
130
|
-
primary_key: z.string().nullable()
|
|
264
|
+
primary_key: z.string().nullable(),
|
|
265
|
+
deletable: z.boolean()
|
|
131
266
|
}),
|
|
267
|
+
transaction: true,
|
|
132
268
|
handler: async (c, route) => {
|
|
133
269
|
const { name } = get_route_params(c);
|
|
134
270
|
const { offset, limit } = get_route_query(c);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (!
|
|
271
|
+
// Allowlist gate, masked: an unlisted (or floor) table answers
|
|
272
|
+
// exactly like one that doesn't exist.
|
|
273
|
+
if (!(await gate_browsable_table(route.db, name))) {
|
|
138
274
|
return c.json({ error: ERROR_TABLE_NOT_FOUND }, 404);
|
|
139
275
|
}
|
|
140
|
-
|
|
276
|
+
// `udt_name` is internal (drives the byte-value placeholder;
|
|
277
|
+
// `data_type` reports only `ARRAY` for array columns) — it is
|
|
278
|
+
// stripped from the response so the wire shape twins Rust's.
|
|
279
|
+
const column_rows = await route.db.query(`SELECT column_name, data_type, is_nullable, udt_name
|
|
141
280
|
FROM information_schema.columns
|
|
142
281
|
WHERE table_schema = 'public' AND table_name = $1
|
|
143
282
|
ORDER BY ordinal_position`, [name]);
|
|
283
|
+
const columns = column_rows.map(({ column_name, data_type, is_nullable }) => ({ column_name, data_type, is_nullable }));
|
|
144
284
|
const count_result = await route.db.query_one(`SELECT COUNT(*) as count FROM "${assert_valid_sql_identifier(name)}"`);
|
|
145
285
|
const total = count_result ? parseInt(count_result.count, 10) : 0;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
286
|
+
// Surface a single-column PK for the delete affordance; a
|
|
287
|
+
// composite (or absent) PK has no single deletable column, so
|
|
288
|
+
// report null — twinning the row-DELETE's single-column rule.
|
|
289
|
+
const pk_columns = await query_primary_key_columns(route.db, name);
|
|
290
|
+
const primary_key = pk_columns.length === 1 ? pk_columns[0] : null;
|
|
291
|
+
// `deletable` is exactly what the row-DELETE will accept, so a
|
|
292
|
+
// client can hide the affordance rather than discover the refusal.
|
|
293
|
+
// `primary_key` stays a truthful report of the key shape — a policy
|
|
294
|
+
// exclusion must not masquerade as one.
|
|
295
|
+
const deletable = primary_key !== null && !non_deletable.has(name);
|
|
296
|
+
// byte values never leave the server — a full page of e.g.
|
|
297
|
+
// `fact.bytes` would otherwise materialize GiBs in one response.
|
|
298
|
+
// The column stays in the result with a `<N bytes>` placeholder
|
|
299
|
+
// per cell (NULL stays NULL); `columns` metadata is untouched.
|
|
300
|
+
// Keyed on `udt_name` so `bytea[]` (`data_type: 'ARRAY'`,
|
|
301
|
+
// `udt_name: '_bytea'`) is covered too — `octet_length` doesn't
|
|
302
|
+
// take arrays, so those report the stored `pg_column_size`.
|
|
303
|
+
const select_list = column_rows.length === 0 // zero-column tables are legal PG
|
|
304
|
+
? '*'
|
|
305
|
+
: column_rows
|
|
306
|
+
.map(({ column_name, udt_name }) => {
|
|
307
|
+
const ident = assert_valid_sql_identifier(column_name);
|
|
308
|
+
if (udt_name === 'bytea')
|
|
309
|
+
return `('<' || octet_length("${ident}") || ' bytes>') AS "${ident}"`;
|
|
310
|
+
if (udt_name === '_bytea')
|
|
311
|
+
return `('<' || pg_column_size("${ident}") || ' bytes>') AS "${ident}"`;
|
|
312
|
+
return `"${ident}"`;
|
|
313
|
+
})
|
|
314
|
+
.join(', ');
|
|
315
|
+
const rows = await route.db.query(`SELECT ${select_list} FROM "${assert_valid_sql_identifier(name)}" LIMIT $1 OFFSET $2`, [limit, offset]);
|
|
316
|
+
return c.json({ columns, rows, total, offset, limit, primary_key, deletable });
|
|
158
317
|
}
|
|
159
318
|
},
|
|
160
319
|
{
|
|
@@ -176,7 +335,11 @@ export const create_db_route_specs = (options) => {
|
|
|
176
335
|
output: z.looseObject({ success: z.boolean() }),
|
|
177
336
|
errors: {
|
|
178
337
|
400: z.looseObject({
|
|
179
|
-
error: z.enum([
|
|
338
|
+
error: z.enum([
|
|
339
|
+
ERROR_INVALID_ROUTE_PARAMS,
|
|
340
|
+
ERROR_TABLE_NO_PRIMARY_KEY,
|
|
341
|
+
ERROR_TABLE_NOT_DELETABLE
|
|
342
|
+
])
|
|
180
343
|
}),
|
|
181
344
|
404: z.looseObject({
|
|
182
345
|
error: z.enum([ERROR_TABLE_NOT_FOUND, ERROR_ROW_NOT_FOUND])
|
|
@@ -185,32 +348,86 @@ export const create_db_route_specs = (options) => {
|
|
|
185
348
|
},
|
|
186
349
|
handler: async (c, route) => {
|
|
187
350
|
const { name, id } = get_route_params(c);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (!
|
|
351
|
+
// Same allowlist gate and masking as the detail route — an
|
|
352
|
+
// unbrowsable table exposes no rows to delete either.
|
|
353
|
+
if (!(await gate_browsable_table(route.db, name))) {
|
|
191
354
|
return c.json({ error: ERROR_TABLE_NOT_FOUND }, 404);
|
|
192
355
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
356
|
+
// Policy exclusion, checked before the key shape: the trail and the
|
|
357
|
+
// framework's singleton bookkeeping rows are never row-deletable through
|
|
358
|
+
// a generic storage endpoint (see `NON_DELETABLE_TABLES`). 400 rather
|
|
359
|
+
// than 403 — this says nothing about the caller's authority, and it sits
|
|
360
|
+
// beside the structural refusal below as the same kind of answer: this
|
|
361
|
+
// table exposes no deletable row here. A 403 would also have to be
|
|
362
|
+
// declared as a union with whatever the route's `auth` derives, which a
|
|
363
|
+
// consumer rewriting `auth` (as fuz_forge does) would silently invalidate.
|
|
364
|
+
if (non_deletable.has(name)) {
|
|
365
|
+
return c.json({ error: ERROR_TABLE_NOT_DELETABLE }, 400);
|
|
366
|
+
}
|
|
367
|
+
// Single-column primary keys only. Deleting by a single
|
|
368
|
+
// `WHERE "<col>" = $1` is safe only when the PK is exactly one
|
|
369
|
+
// column: on a composite PK that filter matches every row sharing
|
|
370
|
+
// the column's value and silently over-deletes (deleting one
|
|
371
|
+
// cell_field row would wipe every field of that name on every
|
|
372
|
+
// cell); an absent PK has nothing to target. Refuse both.
|
|
373
|
+
const pk_columns = await query_primary_key_columns(route.db, name);
|
|
374
|
+
if (pk_columns.length !== 1) {
|
|
203
375
|
return c.json({ error: ERROR_TABLE_NO_PRIMARY_KEY }, 400);
|
|
204
376
|
}
|
|
377
|
+
const pk_column = pk_columns[0];
|
|
205
378
|
try {
|
|
206
|
-
|
|
379
|
+
// Typed PK compare, twinning the Rust spine: `$1` travels as an
|
|
380
|
+
// untyped text-format parameter, so PG infers the PK column's
|
|
381
|
+
// type and coerces the id with that type's input function —
|
|
382
|
+
// index-scan deletes and canonical per-type semantics (citext
|
|
383
|
+
// case-insensitivity, char(n) padding, any valid uuid spelling).
|
|
384
|
+
// An id that cannot be a value of the PK type errors at bind
|
|
385
|
+
// (22P02/22003), mapped below to the same 404 as a typed miss.
|
|
386
|
+
// `RETURNING "<pk>"::text` reads back only the PK cell — the
|
|
387
|
+
// deleted row's other values (e.g. a bytea column) are never
|
|
388
|
+
// materialized — so the audit trail records the row's canonical
|
|
389
|
+
// PK rendering rather than whatever spelling the URL supplied.
|
|
390
|
+
const pk_ident = assert_valid_sql_identifier(pk_column);
|
|
391
|
+
const result = await route.db.query(`DELETE FROM "${assert_valid_sql_identifier(name)}" WHERE "${pk_ident}" = $1 RETURNING "${pk_ident}"::text AS pk_text`, [id]);
|
|
207
392
|
if (result.length === 0) {
|
|
208
393
|
return c.json({ error: ERROR_ROW_NOT_FOUND }, 404);
|
|
209
394
|
}
|
|
395
|
+
const deleted_id = result[0].pk_text;
|
|
396
|
+
// The trail for the trail-adjacent surface: every successful row
|
|
397
|
+
// delete through this endpoint is audited. Deferred via
|
|
398
|
+
// `emit_after_commit` so the pool-routed row can never claim a
|
|
399
|
+
// delete whose transaction failed at COMMIT — the Rust twin
|
|
400
|
+
// commits before emitting for the same reason. Account-grain
|
|
401
|
+
// attribution — the browser is a raw storage surface, so no actor
|
|
402
|
+
// is claimed.
|
|
403
|
+
const account_id = c.var.request_context?.account.id ?? null;
|
|
404
|
+
const ip = get_client_ip(c);
|
|
405
|
+
emit_after_commit(route, () => {
|
|
406
|
+
deps.audit.emit(route, {
|
|
407
|
+
event_type: 'db_admin_row_delete',
|
|
408
|
+
account_id,
|
|
409
|
+
ip,
|
|
410
|
+
metadata: { table: name, pk_column, id: deleted_id }
|
|
411
|
+
});
|
|
412
|
+
});
|
|
210
413
|
return c.json({ success: true });
|
|
211
414
|
}
|
|
212
415
|
catch (err) {
|
|
213
|
-
|
|
416
|
+
const code = pg_error_code(err);
|
|
417
|
+
// 22P02 invalid_text_representation / 22003 numeric_value_out_of_range
|
|
418
|
+
// / 22021 character_not_in_repertoire: the id cannot be coerced
|
|
419
|
+
// to the PK column's type — or cannot even be a text value (a
|
|
420
|
+
// NUL byte, rejected by the server encoding whatever the PK
|
|
421
|
+
// type) — so no row can match; answer with the same masked 404
|
|
422
|
+
// as a typed miss.
|
|
423
|
+
if (code === '22P02' || code === '22003' || code === '22021') {
|
|
424
|
+
return c.json({ error: ERROR_ROW_NOT_FOUND }, 404);
|
|
425
|
+
}
|
|
426
|
+
// 23503 foreign_key_violation / 23001 restrict_violation — the
|
|
427
|
+
// same referencing-row refusal: PG 18+ raises 23001 for `ON
|
|
428
|
+
// DELETE RESTRICT` constraints where PG ≤17 (PGlite today)
|
|
429
|
+
// raises 23503 for both.
|
|
430
|
+
if (code === '23503' || code === '23001') {
|
|
214
431
|
const pg_err = err;
|
|
215
432
|
log?.warn('Foreign key violation:', pg_err.detail, pg_err.constraint);
|
|
216
433
|
return c.json({ error: ERROR_FOREIGN_KEY_VIOLATION }, 409);
|
|
@@ -120,8 +120,19 @@ export declare const ERROR_INVALID_EVENT_TYPE: "invalid_event_type";
|
|
|
120
120
|
export declare const ERROR_FOREIGN_KEY_VIOLATION: "foreign_key_violation";
|
|
121
121
|
/** Table name not found in `information_schema`. */
|
|
122
122
|
export declare const ERROR_TABLE_NOT_FOUND: "table_not_found";
|
|
123
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* Table has no single-column primary key, so there is no column the row-`DELETE`
|
|
125
|
+
* can target: either no primary key at all, or a composite one (a single-column
|
|
126
|
+
* `WHERE` on one member of a composite key over-matches).
|
|
127
|
+
*/
|
|
124
128
|
export declare const ERROR_TABLE_NO_PRIMARY_KEY: "table_no_primary_key";
|
|
129
|
+
/**
|
|
130
|
+
* Table is excluded from row deletion by policy — the audit trail and the
|
|
131
|
+
* framework's singleton bookkeeping rows (`bootstrap_lock`, `app_settings`,
|
|
132
|
+
* `schema_version`), whose invariants live in the domain layer rather than in
|
|
133
|
+
* a generic storage endpoint. See `http/db_routes.ts`'s `NON_DELETABLE_TABLES`.
|
|
134
|
+
*/
|
|
135
|
+
export declare const ERROR_TABLE_NOT_DELETABLE: "table_not_deletable";
|
|
125
136
|
/** Row with the given PK value not found. */
|
|
126
137
|
export declare const ERROR_ROW_NOT_FOUND: "row_not_found";
|
|
127
138
|
/** Database health-check query failed (connectivity or query error). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"error_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/error_schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAI9D,0CAA0C;AAC1C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,uDAAuD;AACvD,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;AAEpE,6CAA6C;AAC7C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,8CAA8C;AAC9C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAI1E,wCAAwC;AACxC,eAAO,MAAM,6BAA6B,EAAG,yBAAkC,CAAC;AAEhF,+CAA+C;AAC/C,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAElF;;;;;;;;GAQG;AACH,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAElF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,yCAAyC;AACzC,eAAO,MAAM,yBAAyB,EAAG,qBAA8B,CAAC;AAExE,sFAAsF;AACtF,eAAO,MAAM,yBAAyB,EAAG,qBAA8B,CAAC;AAExE,qDAAqD;AACrD,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;AAIpE,uCAAuC;AACvC,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,uEAAuE;AACvE,eAAO,MAAM,mBAAmB,EAAG,eAAwB,CAAC;AAE5D,0CAA0C;AAC1C,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;AAEpE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,EAAG,gBAAyB,CAAC;AAE9D;;;GAGG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAIlE,uDAAuD;AACvD,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAIlF,qEAAqE;AACrE,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,8CAA8C;AAC9C,eAAO,MAAM,wBAAwB,EAAG,oBAA6B,CAAC;AAItE,0DAA0D;AAC1D,eAAO,MAAM,wBAAwB,EAAG,oBAA6B,CAAC;AAEtE,0GAA0G;AAC1G,eAAO,MAAM,qBAAqB,EAAG,iBAA0B,CAAC;AAEhE,gDAAgD;AAChD,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,6DAA6D;AAC7D,eAAO,MAAM,oCAAoC,EAAG,gCAAyC,CAAC;AAE9F,0DAA0D;AAC1D,eAAO,MAAM,iCAAiC,EAAG,6BAAsC,CAAC;AAIxF,6DAA6D;AAC7D,eAAO,MAAM,4BAA4B,EAAG,wBAAiC,CAAC;AAE9E,gEAAgE;AAChE,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,oEAAoE;AACpE,eAAO,MAAM,wBAAwB,EAAG,oBAA6B,CAAC;AAItE,kDAAkD;AAClD,eAAO,MAAM,2BAA2B,EAAG,uBAAgC,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,qBAAqB,EAAG,iBAA0B,CAAC;AAEhE
|
|
1
|
+
{"version":3,"file":"error_schemas.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/error_schemas.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAI9D,0CAA0C;AAC1C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,uDAAuD;AACvD,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;AAEpE,6CAA6C;AAC7C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,8CAA8C;AAC9C,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAI1E,wCAAwC;AACxC,eAAO,MAAM,6BAA6B,EAAG,yBAAkC,CAAC;AAEhF,+CAA+C;AAC/C,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAElF;;;;;;;;GAQG;AACH,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAElF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,yCAAyC;AACzC,eAAO,MAAM,yBAAyB,EAAG,qBAA8B,CAAC;AAExE,sFAAsF;AACtF,eAAO,MAAM,yBAAyB,EAAG,qBAA8B,CAAC;AAExE,qDAAqD;AACrD,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;AAIpE,uCAAuC;AACvC,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,uEAAuE;AACvE,eAAO,MAAM,mBAAmB,EAAG,eAAwB,CAAC;AAE5D,0CAA0C;AAC1C,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAC;AAEpE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,EAAG,gBAAyB,CAAC;AAE9D;;;GAGG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAIlE,uDAAuD;AACvD,eAAO,MAAM,8BAA8B,EAAG,0BAAmC,CAAC;AAIlF,qEAAqE;AACrE,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,8CAA8C;AAC9C,eAAO,MAAM,wBAAwB,EAAG,oBAA6B,CAAC;AAItE,0DAA0D;AAC1D,eAAO,MAAM,wBAAwB,EAAG,oBAA6B,CAAC;AAEtE,0GAA0G;AAC1G,eAAO,MAAM,qBAAqB,EAAG,iBAA0B,CAAC;AAEhE,gDAAgD;AAChD,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,EAAG,kBAA2B,CAAC;AAElE,6DAA6D;AAC7D,eAAO,MAAM,oCAAoC,EAAG,gCAAyC,CAAC;AAE9F,0DAA0D;AAC1D,eAAO,MAAM,iCAAiC,EAAG,6BAAsC,CAAC;AAIxF,6DAA6D;AAC7D,eAAO,MAAM,4BAA4B,EAAG,wBAAiC,CAAC;AAE9E,gEAAgE;AAChE,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E,oEAAoE;AACpE,eAAO,MAAM,wBAAwB,EAAG,oBAA6B,CAAC;AAItE,kDAAkD;AAClD,eAAO,MAAM,2BAA2B,EAAG,uBAAgC,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,qBAAqB,EAAG,iBAA0B,CAAC;AAEhE;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,EAAG,sBAA+B,CAAC;AAE1E;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,EAAG,qBAA8B,CAAC;AAExE,6CAA6C;AAC7C,eAAO,MAAM,mBAAmB,EAAG,eAAwB,CAAC;AAE5D,wEAAwE;AACxE,eAAO,MAAM,gCAAgC,EAAG,4BAAqC,CAAC;AAKtF,iFAAiF;AACjF,eAAO,MAAM,QAAQ;;iBAAuC,CAAC;AAC7D,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAEhD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,eAAe;;;;;;;;;;;;iBAgB1B,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe;;;iBAG1B,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,eAAO,MAAM,2BAA2B;;;iBAGtC,CAAC;AACH,MAAM,MAAM,2BAA2B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,2BAA2B,CAAC,CAAC;AAEtF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,uBAAuB;;;iBAGlC,CAAC;AACH,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAE9E,2EAA2E;AAC3E,eAAO,MAAM,cAAc;;;iBAGzB,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,uFAAuF;AACvF,eAAO,MAAM,oBAAoB;;iBAE/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE,qFAAqF;AACrF,eAAO,MAAM,eAAe;;iBAE1B,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,kBAAkB;;;;;;iBAG7B,CAAC;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,eAAO,MAAM,sBAAsB;;iBAEjC,CAAC;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAE5E,eAAO,MAAM,sBAAsB;;iBAEjC,CAAC;AACH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAE5E,eAAO,MAAM,oBAAoB;;iBAE/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;AAEnE;;;;;;;;;GASG;AACH,eAAO,MAAM,YAAY;;;;EAAoC,CAAC;AAC9D,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,WAAW,yBAAyB;IACzC,IAAI,EAAE,SAAS,CAAC;IAChB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,UAAU,CAAC,EAAE,YAAY,CAAC;CAC1B;AAED,eAAO,MAAM,oBAAoB,GAAI,wDAMlC,yBAAyB,KAAG,iBA0C9B,CAAC"}
|
|
@@ -128,8 +128,19 @@ export const ERROR_INVALID_EVENT_TYPE = 'invalid_event_type';
|
|
|
128
128
|
export const ERROR_FOREIGN_KEY_VIOLATION = 'foreign_key_violation';
|
|
129
129
|
/** Table name not found in `information_schema`. */
|
|
130
130
|
export const ERROR_TABLE_NOT_FOUND = 'table_not_found';
|
|
131
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Table has no single-column primary key, so there is no column the row-`DELETE`
|
|
133
|
+
* can target: either no primary key at all, or a composite one (a single-column
|
|
134
|
+
* `WHERE` on one member of a composite key over-matches).
|
|
135
|
+
*/
|
|
132
136
|
export const ERROR_TABLE_NO_PRIMARY_KEY = 'table_no_primary_key';
|
|
137
|
+
/**
|
|
138
|
+
* Table is excluded from row deletion by policy — the audit trail and the
|
|
139
|
+
* framework's singleton bookkeeping rows (`bootstrap_lock`, `app_settings`,
|
|
140
|
+
* `schema_version`), whose invariants live in the domain layer rather than in
|
|
141
|
+
* a generic storage endpoint. See `http/db_routes.ts`'s `NON_DELETABLE_TABLES`.
|
|
142
|
+
*/
|
|
143
|
+
export const ERROR_TABLE_NOT_DELETABLE = 'table_not_deletable';
|
|
133
144
|
/** Row with the given PK value not found. */
|
|
134
145
|
export const ERROR_ROW_NOT_FOUND = 'row_not_found';
|
|
135
146
|
/** Database health-check query failed (connectivity or query error). */
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
*/
|
|
42
42
|
import type { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
43
43
|
/**
|
|
44
|
-
* Minimal structural context required by `emit_after_commit
|
|
45
|
-
* `RouteContext` and `ActionContext` satisfy this
|
|
46
|
-
* `log`
|
|
44
|
+
* Minimal structural context required by `emit_after_commit` — just the
|
|
45
|
+
* deferred queue. Both `RouteContext` and `ActionContext` satisfy this.
|
|
46
|
+
* (No `log` here: the flush middleware owns the per-thunk `try/catch` +
|
|
47
|
+
* `log.error`, with a logger of its own.)
|
|
47
48
|
*/
|
|
48
49
|
export interface EmitAfterCommitContext {
|
|
49
|
-
log: Logger;
|
|
50
50
|
post_commit_effects: Array<() => void | Promise<void>>;
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
@@ -71,7 +71,7 @@ export interface EmitAfterCommitContext {
|
|
|
71
71
|
* land even when the handler fails) belong on the eager `pending_effects` queue
|
|
72
72
|
* instead.
|
|
73
73
|
*
|
|
74
|
-
* @param ctx - context carrying
|
|
74
|
+
* @param ctx - context carrying the `post_commit_effects` queue
|
|
75
75
|
* @param fn - side effect to run after commit; may return `void` or `Promise<void>`
|
|
76
76
|
* @mutates `ctx.post_commit_effects` - appends `fn` verbatim
|
|
77
77
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pending_effects.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/pending_effects.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD
|
|
1
|
+
{"version":3,"file":"pending_effects.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/pending_effects.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD;;;;;GAKG;AACH,MAAM,WAAW,sBAAsB;IACtC,mBAAmB,EAAE,KAAK,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;CACvD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,iBAAiB,GAC7B,KAAK,sBAAsB,EAC3B,IAAI,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAC5B,IAEF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,kCAAkC,GAAU,CAAC,EACzD,qBAAqB,KAAK,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,SAAS,EAClE,UAAU,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,KAC5B,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAQpB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GACjC,SAAS,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,EACrC,KAAK,MAAM,EACX,eAAe,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,KACtC,OAAO,CAAC,IAAI,CASd,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,GACrC,SAAS,aAAa,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,EAClD,KAAK,MAAM,KACT,OAAO,CAAC,IAAI,CAiBd,CAAC"}
|