Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
154 commits
Select commit Hold shift + click to select a range
c45473d
fix: render SQL Server table functions as inline TVFs and list wareho…
kvlonge Nov 7, 2026
bc86f06
feat(lint): warn when SQL lint is unavailable for a dialect
kvlonge Nov 7, 2026
15b863e
fix(sqlserver): quote schema names in EXEC, end MERGE with a semicolo…
kvlonge Nov 7, 2026
57b0c20
refactor(analysis): read B101 spellings from a recorded table instead…
kvlonge Nov 7, 2026
7edf70d
feat(lint): keep L007 out of every exit decision and show it in compi…
kvlonge Nov 7, 2026
2fb4547
fix(lint): keep L007 out of rule exceptions and the plan rules gate
kvlonge Nov 7, 2026
3572408
docs(rules): name plan and build, not format, in the L007 exit note
kvlonge Nov 7, 2026
c03cec2
Merge remote-tracking branch 'origin/main' into wip/b101-function-cat…
kvlonge Nov 7, 2026
bc89e18
Merge branch 'main' into feat/lint-unavailable-warning
kvlonge Nov 7, 2026
ff7e1b9
Merge branch 'main' into fix/clickhouse-recall-sqlserver-tvf
kvlonge Nov 7, 2026
47c7a6b
test(orchestration): count the store statements a wide catalog listin…
kvlonge Nov 7, 2026
c00a9da
refactor(analysis): share diagnostic location and the catalog's optio…
kvlonge Nov 7, 2026
5aea666
Merge branch 'main' into feat/lint-unavailable-warning
kvlonge Nov 7, 2026
b620a2f
Merge branch 'main' into fix/clickhouse-recall-sqlserver-tvf
kvlonge Nov 7, 2026
355219b
Merge branch 'main' into test/catalog-statement-count
kvlonge Nov 7, 2026
2436dc7
Merge branch 'main' into refactor/b101-recorded-spellings
kvlonge Nov 7, 2026
3cd6dff
feat(contracts): prove dynamic pivots from SQLQuack parse trees
kvlonge Nov 8, 2026
db2387f
fix(contracts): refuse pivot SQL nested deeper than 512 levels before…
kvlonge Nov 8, 2026
630b2d8
wip: read rule facts with SQLQuack
kvlonge Nov 8, 2026
d9e86d0
wip: locate cast types and Snowflake date parts without a generator
kvlonge Nov 8, 2026
27e4cb4
Merge remote-tracking branch 'origin/feat/lint-unavailable-warning' i…
kvlonge Nov 8, 2026
a2909a3
wip: report built-in rules unavailable for unparsed dialects
kvlonge Nov 8, 2026
7a85582
wip: e2e for built-in rules on unparsed dialects
kvlonge Nov 8, 2026
4fad955
chore(deps): bump sqlquack to a9dde6a0
kvlonge Nov 8, 2026
adc7984
wip: docs and tests for built-in rules on unparsed dialects
kvlonge Nov 8, 2026
088bd97
wip: format tests
kvlonge Nov 8, 2026
8b2a9d3
fix(rules): refuse rule facts for statements deeper than 512 levels
kvlonge Nov 8, 2026
d9fa004
fix(rules): anchor nested comparisons and Snowflake LAST_DAY date parts
kvlonge Nov 8, 2026
449de8d
feat(scenario): map scenario column types with SQLQuack's type parsers
kvlonge Nov 8, 2026
7655ef3
fix(scenario): map SQL Server date, time, uuid and money types locally
kvlonge Nov 8, 2026
5ad2d42
Merge branch 'main' into feat/sqlquack-type-and-local-sql
kvlonge Nov 8, 2026
4637fb4
chore(fensu): allow the cast-target walk next to the other JSON tree …
kvlonge Nov 8, 2026
0352b44
refactor(rules): name the date-part and unavailable-rule loops
kvlonge Nov 8, 2026
e9221bc
Merge remote-tracking branch 'origin/main' into feat/rule-facts-sqlquack
kvlonge Nov 8, 2026
5f253c1
docs(analysis): keep the warehouse type binding's doc comment to one …
kvlonge Nov 8, 2026
28a28c5
Merge remote-tracking branch 'origin/main' into feat/sqlquack-type-an…
kvlonge Nov 8, 2026
2d146f5
feat(types): normalize adapter types without Polyglot
kvlonge Nov 8, 2026
d2e1fb8
fix(ui): stop a burst of control API requests on every live refresh
kvlonge Nov 8, 2026
9c09e54
fix(types): read only shipped dialects and ClickHouse nested parameters
kvlonge Nov 8, 2026
e0638cd
refactor(rules): read the shared-parser dialect from one lookup
kvlonge Nov 8, 2026
cb84e52
Merge main into the sqlquack bump
kvlonge Nov 8, 2026
fea889c
chore(deps): bump sqlquack to 19c51fa2
kvlonge Nov 8, 2026
183b210
build(deps): pin SQLQuack before its Snowflake grammar checks
kvlonge Nov 8, 2026
36342ee
Merge branch 'feat/sqlquack-type-and-local-sql' into feat/sqlquack-ty…
kvlonge Nov 8, 2026
7d1ef9a
fix(rules): read relation markers as tables in custom-rule syntax trees
kvlonge Nov 8, 2026
fd5faf5
Merge branch 'main' into fix/ui-refresh-burst
kvlonge Nov 8, 2026
de3cdc8
Merge fix/sql-tree-placeholder-relations (#1370)
kvlonge Nov 8, 2026
2d2eaa5
Merge feat/rule-facts-sqlquack (#1361)
kvlonge Nov 8, 2026
2a8cfa2
Merge feat/sqlquack-type-normalization (#1366)
kvlonge Nov 8, 2026
cff088f
Merge chore/sqlquack-rev-bump (#1368)
kvlonge Nov 8, 2026
a1e6b0f
build(deps): keep Windows windows-sys edges at main's resolution
kvlonge Nov 8, 2026
9d01621
docs(types): keep the ClickHouse type parameter doc comment to one line
kvlonge Nov 8, 2026
1e4aa20
feat(ui): wire the lineage and changes pages to the live control API
kvlonge Nov 8, 2026
0793704
feat(server): page the security audit newest first with actor names
kvlonge Nov 8, 2026
eb7c880
feat(ui): wire the audit log to the security audit read
kvlonge Nov 8, 2026
96ad1ba
feat(server): list people and role holdings for administrators
kvlonge Nov 8, 2026
5fa1089
feat(ui): wire the people page to the people and role reads
kvlonge Nov 8, 2026
118d6d3
fix(server): count no active sessions for a disabled person
kvlonge Nov 8, 2026
19ba7be
Merge the SQLQuack parser train into the pivot proof
kvlonge Nov 8, 2026
d27158a
fix(contracts): read relation markers as relations in dynamic pivot p…
kvlonge Nov 8, 2026
9b3282c
refactor(analysis): share the statement depth guard between pivot pro…
kvlonge Nov 8, 2026
87b8bd9
fix(contracts): keep non-dependency markers out of pivot relations
kvlonge Nov 8, 2026
42579ef
fix(types): spell ClickHouse Nullable and BINARY as the engine does
kvlonge Nov 8, 2026
6a32d49
Merge main into the SQLQuack parser train
kvlonge Nov 8, 2026
c9fa2ad
Merge main into test/catalog-statement-count
kvlonge Nov 8, 2026
1b1d0b3
Merge branch 'main' into fix/ui-refresh-burst
kvlonge Nov 8, 2026
7c64f85
Merge the SQLQuack parser train into the pivot proof
kvlonge Nov 8, 2026
5494e24
feat(server): let people read a target's location health
kvlonge Nov 8, 2026
fd39408
feat(ui): show live location health on the Locations page
kvlonge Nov 8, 2026
8dc27f1
fix(server): limit a target's health read to its own observations
kvlonge Nov 8, 2026
1f04a86
feat(server): let people read live platform problems
kvlonge Nov 8, 2026
8929fae
feat(ui): show live platform problems in the alert banner
kvlonge Nov 8, 2026
58de3d6
docs(ui): keep the locations doc comment on listLocations
kvlonge Nov 8, 2026
35f7a3a
feat(server): let role administrators read a target's role definitions
kvlonge Nov 8, 2026
a49d346
feat(ui): show role definitions on the Roles pages
kvlonge Nov 8, 2026
d220b21
feat(ui): show warehouse sync status in the top bar
kvlonge Nov 8, 2026
b12327f
fix(ui): stop rereading warehouse sync after a refused read
kvlonge Nov 8, 2026
03b49ca
fix(rules): wrap Snowflake table functions in TABLE( ) before parsing
kvlonge Nov 8, 2026
a84c6ea
Merge origin/main into the platform and admin slices
kvlonge Nov 8, 2026
689deba
fix(rules): read Snowflake table function markers inside TABLE( ) in …
kvlonge Nov 8, 2026
5974f71
feat(ui): show catalog node detail from the control API and finish li…
kvlonge Nov 8, 2026
a1d030b
Merge remote-tracking branch 'origin/main' into fix/ui-refresh-burst
kvlonge Nov 8, 2026
f729c4c
fix(ui): ask for refused node SQL once until reload
kvlonge Nov 8, 2026
5b2b272
fix(rules): keep columns after a wrapped table function in rule trees
kvlonge Nov 8, 2026
001d78c
fix(ui): wrap SQL previews and drop the kind prefix from SQL file labels
kvlonge Nov 8, 2026
2994b2a
feat: publish column lineage and show it on catalog node pages
kvlonge Nov 8, 2026
2c59909
fix(ui): show full names and transform kinds in column lineage
kvlonge Nov 8, 2026
2ff5a9e
refactor: move column lineage to its own change
kvlonge Nov 8, 2026
c8e186b
Merge branch 'main' into test/catalog-statement-count
kvlonge Nov 8, 2026
2cd8757
feat: show per-node run history and selecting jobs on catalog node pages
kvlonge Nov 8, 2026
b5f41d4
feat(orchestration): gzip location snapshot publishes
kvlonge Nov 8, 2026
e9a8dde
feat: show run events, statements, SQL and output to people
kvlonge Nov 8, 2026
bff5f8c
feat: require run.prioritise for runs above priority 0
kvlonge Nov 8, 2026
d10bd28
fix(orchestration): report the server's refusal of a snapshot too lar…
kvlonge Nov 8, 2026
19a4005
fix(orchestration): bound decompressed publish bodies at 320 MiB
kvlonge Nov 8, 2026
32abf51
fix: keep run cancellation independent of run.prioritise
kvlonge Nov 8, 2026
58abe6f
test: wait for every trigger to reach the latest due time after sched…
kvlonge Nov 8, 2026
6abe6a0
test: count ticks scheduled after their own evaluation as off schedule
kvlonge Nov 8, 2026
e8df6e4
ci: run the full Verify matrix only on pull requests labelled full-ve…
kvlonge Nov 8, 2026
deed388
Merge feat/gzip-location-publish into the combined delivery
kvlonge Nov 8, 2026
0ba6b92
Merge fix/scheduler-fault-catch-up into the combined delivery
kvlonge Nov 8, 2026
b072d05
Merge test/catalog-statement-count into the combined delivery
kvlonge Nov 8, 2026
6f4ec9c
Merge fix/clickhouse-recall-sqlserver-tvf into the combined delivery
kvlonge Nov 8, 2026
6639e64
Merge chore/sqlquack-parser-train into the combined delivery
kvlonge Nov 8, 2026
7fc70b1
Merge refactor/b101-recorded-spellings into the combined delivery
kvlonge Nov 8, 2026
bfd5cfc
Merge feat/pivot-sqlquack into the combined delivery
kvlonge Nov 8, 2026
30914da
Merge feat/run-prioritise into the combined delivery
kvlonge Nov 8, 2026
ffe352d
Merge feat/ui-warehouse-sync into the combined delivery
kvlonge Nov 8, 2026
24aba79
Merge fix/ui-refresh-burst into the combined delivery
kvlonge Nov 8, 2026
ef60acc
feat: run ad-hoc sqb commands from the web UI
kvlonge Nov 8, 2026
05cd1ab
ci: keep the Verify aggregate on always()
kvlonge Nov 8, 2026
096dbdb
Merge feat/adhoc-commands into the combined delivery
kvlonge Nov 8, 2026
5f82f19
refactor(orchestration): page security audit facts beside the audit w…
kvlonge Nov 8, 2026
aeed1ce
test(ci): prove a fast-core or label-only Verify run cannot satisfy t…
kvlonge Nov 8, 2026
6c4c7f1
feat: publish column lineage and show it on catalog node pages
kvlonge Nov 8, 2026
ac8c917
Merge origin/main into the combined delivery
kvlonge Nov 8, 2026
304af88
Merge feat/ui-run-reads into the combined delivery
kvlonge Nov 8, 2026
1826b8c
fix(lineage): publish snapshot column lineage from native edge records
kvlonge Nov 8, 2026
f757fa2
fix(ui): find an ad-hoc run launched while a runs listing is in flight
kvlonge Nov 8, 2026
13fc786
Merge wip/column-lineage-native into the combined delivery
kvlonge Nov 8, 2026
0aaf702
feat: run test, seed, audit and freshness ad-hoc commands on workers
kvlonge Nov 8, 2026
2d16d24
fix: run every ad-hoc verb from the worker's verified project
kvlonge Nov 9, 2026
5f37d98
fix(ui): show the approved logos and favicon from the website
kvlonge Nov 9, 2026
3828720
Merge remote-tracking branch 'origin/wip/ui-branding' into wip/combin…
kvlonge Nov 9, 2026
e57245a
ci: run the UI checks when the website logos or favicon change
kvlonge Nov 9, 2026
fb73e67
Merge remote-tracking branch 'origin/main' into wip/combined-next-local
kvlonge Nov 9, 2026
334c6b3
Merge remote-tracking branch 'origin/feat/adhoc-verbs' into wip/combi…
kvlonge Nov 9, 2026
333c627
style: move the checks-only outcome note into its doc comment
kvlonge Nov 9, 2026
7d80e87
feat(ui): show the Overview from live runs, checks, models and deploys
kvlonge Nov 9, 2026
0bd8914
Merge remote-tracking branch 'origin/wip/ui-overview' into wip/combin…
kvlonge Nov 9, 2026
69e9de5
feat(ui): tell a partial viewer why the Overview is empty
kvlonge Nov 9, 2026
5d846c1
fix(ui): show Overview errors and drop a stale partial-view message
kvlonge Nov 9, 2026
fafaae1
Merge remote-tracking branch 'origin/wip/ui-overview' into wip/combin…
kvlonge Nov 9, 2026
fbcf13c
feat(ui): show renames, old names and clean-ups on the Maintenance page
kvlonge Nov 9, 2026
728345e
Merge remote-tracking branch 'origin/wip/ui-maintenance' into wip/com…
kvlonge Nov 9, 2026
620cbe6
fix: regenerate the TypeScript control client for ad-hoc run commands
kvlonge Nov 9, 2026
28ba06f
ci: check the generated control client in make check-ci
kvlonge Nov 9, 2026
787a0e6
feat(ui): give and remove roles and disable people from the admin pages
kvlonge Nov 9, 2026
3d5eb48
Merge remote-tracking branch 'origin/wip/ui-admin-actions' into wip/c…
kvlonge Nov 9, 2026
0a69cf7
feat(ui): test a location's warehouse connection from the Locations page
kvlonge Nov 9, 2026
3ed5eed
Merge commit '0a69cf70c44839c47cccd834c443c4d946042d79' into wip/comb…
kvlonge Nov 9, 2026
6739902
Merge remote-tracking branch 'origin/main' into wip/combined-next-local
kvlonge Nov 9, 2026
1d34a8a
feat(ui): location detail and scheduler status from the server's heal…
kvlonge Nov 9, 2026
9b1b6c8
fix(server): count copied grants in the maintenance read instead of r…
kvlonge Nov 9, 2026
d331a85
Merge commit '1d34a8a95699290cd173b568ff066f32715f5320' into wip/comb…
kvlonge Nov 9, 2026
809b672
Merge commit '9b1b6c8facec573083f3af41062a986f4ebb711a' into wip/comb…
kvlonge Nov 9, 2026
9534b3c
feat(ui): backfill a job over a cursor window from its launch form
kvlonge Nov 9, 2026
8c7ca57
fix(ui): show a plain-text server error only when it is plain text
kvlonge Nov 9, 2026
b7c2dfd
Merge commit '8c7ca570e3e666f5c19dcc6d1f14ab86a593e7f2' into wip/comb…
kvlonge Nov 9, 2026
a205d7f
fix(ui): show plain-text server errors only for refused requests, not…
kvlonge Nov 9, 2026
fd8ee5f
feat(ui): upcoming schedule times and calendar coverage on the trigge…
kvlonge Nov 9, 2026
038e8a5
Merge commit 'fd8ee5fa3b9334465a1143f386f2dd59571ce371' into wip/comb…
kvlonge Nov 9, 2026
6727ec1
Merge remote-tracking branch 'origin/main' into wip/combined-next-local
kvlonge Nov 9, 2026
f6409a8
Merge remote-tracking branch 'origin/main' into wip/combined-next-local
kvlonge Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,16 @@
"destination_model": "orders",
"event_type": "view_created",
"created_at": "2026-02-03T04:05:07Z",
"column_aliases": "{\"order_id\":\"legacy_order_id\"}"
"column_aliases": "{\"order_id\":\"legacy_order_id\"}",
"grants_copied": "[\"GRANT SELECT ON main.orders_legacy TO fulfillment_readers\", \"GRANT SELECT ON main.orders_legacy TO support_analysts\"]"
},
{
"event_id": "view-customers-1",
"migration_event_id": "move-customers-1",
"destination_model": "customers",
"event_type": "required",
"created_at": "2026-02-02T04:05:06Z"
"created_at": "2026-02-02T04:05:06Z",
"grants_copied": "GRANT SELECT ON main.customers_legacy TO support_analysts"
},
{
"event_id": "view-orders-old-1",
Expand Down
60 changes: 32 additions & 28 deletions clients/typescript/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1641,7 +1641,38 @@ export interface components {
janitor_events: components["schemas"]["WarehouseJanitorRecord"][];
migrations: components["schemas"]["WarehouseMigrationRecord"][];
/** @description Every step recorded for the listed moves, oldest first; same-time steps are not in step order. */
old_name_views: components["schemas"]["WarehouseOldNameRecord"][];
old_name_views: components["schemas"]["MaintenanceOldNameView"][];
};
/** @description One old-name view step as people read it: copied grants are counted, never returned as statements. */
MaintenanceOldNameView: {
archive_name?: string | null;
column_aliases?: string | null;
/** Format: date-time */
created_at: string;
destination_model: string;
drop_reason?: string | null;
event_id: string;
event_type: string;
/** Format: date-time */
expires_at?: string | null;
/**
* Format: int64
* @description How many grants were copied onto the view; null until it exists or when the stored list is unreadable.
*/
grants_copied?: number | null;
migration_event_id: string;
new_database?: string | null;
new_name: string;
new_schema: string;
old_database?: string | null;
old_name: string;
old_schema?: string | null;
run_id: string;
state_database?: string | null;
state_schema: string;
target_name?: string | null;
view_retention?: string | null;
view_sql?: string | null;
};
/** @description One bounded page of one state table from the registered reader of a published generation. */
MetadataBatch: {
Expand Down Expand Up @@ -2560,33 +2591,6 @@ export interface components {
/** Format: date-time */
observed_at: string;
};
/** @description Preserve nullable compatibility-view facts without projecting live warehouse existence. */
WarehouseOldNameRecord: {
archive_name?: string | null;
column_aliases?: string | null;
/** Format: date-time */
created_at: string;
destination_model: string;
drop_reason?: string | null;
event_id: string;
event_type: string;
/** Format: date-time */
expires_at?: string | null;
grants_copied?: string | null;
migration_event_id: string;
new_database?: string | null;
new_name: string;
new_schema: string;
old_database?: string | null;
old_name: string;
old_schema?: string | null;
run_id: string;
state_database?: string | null;
state_schema: string;
target_name?: string | null;
view_retention?: string | null;
view_sql?: string | null;
};
/** @description Physical namespace of SQLBuild's source-freshness state table, never a source query. */
WarehouseStateLocation: {
database?: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

use serde::Deserialize;
use serde::de::DeserializeOwned;
use sqlbuild_state::events::main::decoded_grants::decoded_grants;
use uuid::Uuid;

use crate::store::errors::ControlError;
use crate::store::main::query_records::query_records;
use crate::store::models::{ControlTransaction, Parameter};
use crate::warehouse_history::models::MaintenanceHistory;
use crate::warehouse_history::models::{MaintenanceHistory, MaintenanceOldNameView};
use crate::warehouse_metadata::models::{
WarehouseJanitorRecord, WarehouseMigrationRecord, WarehouseOldNameRecord,
};
Expand Down Expand Up @@ -55,7 +56,7 @@ pub(crate) async fn maintenance_history(
}
};
let mut transaction = transaction;
let mut old_name_views: Vec<WarehouseOldNameRecord> = Vec::new();
let mut stored_views: Vec<WarehouseOldNameRecord> = Vec::new();
if !migrations.is_empty() {
let mut parameters = scope.clone();
parameters.extend(
Expand All @@ -64,7 +65,7 @@ pub(crate) async fn maintenance_history(
.map(|migration| Parameter::Text(migration.event_id.clone())),
);
let placeholders = Parameter::placeholders(3..=parameters.len());
(transaction, old_name_views) = records(transaction,
(transaction, stored_views) = records(transaction,
&format!("SELECT record_json AS record FROM sqb_warehouse_old_name_views WHERE location_id=$1 AND target=$2 \
AND migration_event_id IN ({placeholders}) ORDER BY observed_at,event_id"),
parameters).await?;
Expand All @@ -80,8 +81,37 @@ pub(crate) async fn maintenance_history(
transaction,
MaintenanceHistory {
migrations,
old_name_views,
old_name_views: stored_views.into_iter().map(old_name_view).collect(),
janitor_events,
},
))
}

/// The stored step with its grant statements reduced to their number, read the way local `sqb` reads them.
fn old_name_view(record: WarehouseOldNameRecord) -> MaintenanceOldNameView {
MaintenanceOldNameView {
grants_copied: decoded_grants(record.grants_copied.as_deref())
.map(|statements| statements.len() as u64),
state_database: record.state_database,
state_schema: record.state_schema,
event_id: record.event_id,
target_name: record.target_name,
event_type: record.event_type,
migration_event_id: record.migration_event_id,
destination_model: record.destination_model,
old_database: record.old_database,
old_schema: record.old_schema,
old_name: record.old_name,
new_database: record.new_database,
new_schema: record.new_schema,
new_name: record.new_name,
view_retention: record.view_retention,
archive_name: record.archive_name,
column_aliases: record.column_aliases,
view_sql: record.view_sql,
expires_at: record.expires_at,
drop_reason: record.drop_reason,
run_id: record.run_id,
created_at: record.created_at,
}
}
32 changes: 30 additions & 2 deletions crates/sqlbuild-orchestration/src/warehouse_history/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::warehouse_metadata::models::{
WarehouseAuditRecord, WarehouseJanitorRecord, WarehouseMigrationRecord, WarehouseOldNameRecord,
WarehouseAuditRecord, WarehouseJanitorRecord, WarehouseMigrationRecord,
};

/// One stored check result. Its SQL, error texts and evidence rows need their own permissions.
Expand Down Expand Up @@ -96,12 +96,40 @@ pub struct CheckEvidence {
pub truncated: Option<String>,
}

/// One old-name view step as people read it: copied grants are counted, never returned as statements.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MaintenanceOldNameView {
pub state_database: Option<String>,
pub state_schema: String,
pub event_id: String,
pub target_name: Option<String>,
pub event_type: String,
pub migration_event_id: String,
pub destination_model: String,
pub old_database: Option<String>,
pub old_schema: Option<String>,
pub old_name: String,
pub new_database: Option<String>,
pub new_schema: String,
pub new_name: String,
pub view_retention: Option<String>,
pub archive_name: Option<String>,
pub column_aliases: Option<String>,
/// How many grants were copied onto the view; null until it exists or when the stored list is unreadable.
pub grants_copied: Option<u64>,
pub view_sql: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub drop_reason: Option<String>,
pub run_id: String,
pub created_at: DateTime<Utc>,
}

/// Recorded moves, their old-name view steps and janitor cleanups, newest moves first.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MaintenanceHistory {
pub migrations: Vec<WarehouseMigrationRecord>,
/// Every step recorded for the listed moves, oldest first; same-time steps are not in step order.
pub old_name_views: Vec<WarehouseOldNameRecord>,
pub old_name_views: Vec<MaintenanceOldNameView>,
/// Empty when the history is for one model; cleanups are not tied to models.
pub janitor_events: Vec<WarehouseJanitorRecord>,
}
Expand Down
26 changes: 26 additions & 0 deletions crates/sqlbuild-orchestration/tests/api/test_maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,32 @@ fn given_moves_views_and_cleanups_when_people_read_maintenance_then_each_model_s
"another target's steps never appear"
);
assert_eq!(ids(&all, "janitor_events"), ["cleanup-2", "cleanup-1"]);
let grants: Vec<(&str, &Value)> = all["old_name_views"]
.as_array()
.expect("views")
.iter()
.map(|view| {
(
view["event_id"].as_str().expect("id"),
&view["grants_copied"],
)
})
.collect();
assert_eq!(
grants,
[
("view-orders-old-1", &Value::Null),
("view-customers-1", &Value::Null),
("view-orders-1", &Value::Null),
("view-orders-2", &json!(2))
],
"copied grants are counted, and unreadable stored text reads as unknown"
);
let body = all.to_string();
assert!(
!body.contains("GRANT") && !body.contains("support_analysts"),
"no grant statement or grantee reaches people: {body}"
);
assert_eq!(all["migrations"][0]["origin_model"], Value::Null);
assert_eq!(all["migrations"][0]["discovery"], "automatic");

Expand Down
114 changes: 114 additions & 0 deletions ui/src/lib/api/http-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,120 @@ describe('overview', () => {
});
});

describe('locations', () => {
const access = (target: string) => ({ location_id: '0191f0aa-orders', name: 'Orders', target, actions: [] });
const report = (session: string, secondsAgo: number) => ({
received_at: new Date(Date.now() - secondsAgo * 1000).toISOString(),
report: { event_id: `e-${session}-${secondsAgo}`, session_id: session, target: 'development', health: { kind: 'heartbeat' } }
});

/** Orders development is readable and orders preview refuses its health read, as for a partial viewer. */
function server() {
const paths: string[] = [];
vi.stubGlobal('document', { cookie: '' });
vi.stubGlobal(
'fetch',
vi.fn(async (request: Request) => {
const path = new URL(request.url).pathname;
paths.push(path);
if (path.endsWith('/bootstrap')) return Response.json({ login_mode: 'password', access: { name: 'ada', principal_id: 'p', actions: [], locations: [access('development'), access('preview')] } });
if (path.endsWith('/platform/problems'))
return Response.json({
scheduler_leader: true,
scheduler_lease_expires_at: '2026-11-09T10:00:30Z',
broken_locations: [{ location_id: '0191f0aa-orders', location_name: 'Orders', target: 'development', last_seen_at: '2026-11-09T09:00:00Z' }],
overdue_ticks: []
});
if (path.includes('/targets/preview/')) return Response.json({ error: 'refused' }, { status: 403 });
if (path.endsWith('/health')) return Response.json([report('s-2', 10), report('s-1', 400), report('s-2', 20)]);
return Response.json([]);
})
);
return { paths, client: createHttpClient({ baseUrl: 'http://ui.test', onUnauthorized: () => {} }) };
}

it('opens a readable location with one process per session', async () => {
const detail = await server().client.getLocation('0191f0aa-orders:development');
expect(detail?.instances.map((i) => [i.id, i.state])).toEqual([
['s-2', 'live'],
['s-1', 'stale']
]);
});

it('fails the page with the refusal when the person may not read the target health', async () => {
await expect(server().client.getLocation('0191f0aa-orders:preview')).rejects.toMatchObject({ status: 403 });
});

it('is null for a location the person is not given, without reading its health', async () => {
const { paths, client } = server();
expect(await client.getLocation('0191f0aa-orders:production')).toBeNull();
expect(paths.some((p) => p.endsWith('/health'))).toBe(false);
});

it('counts open problems per location target', async () => {
const listed = await server().client.listLocations();
expect(listed.map((l) => [l.id, l.activeProblems, l.health])).toEqual([
['0191f0aa-orders:development', 1, 'healthy'],
['0191f0aa-orders:preview', 0, null]
]);
});
});

describe('launchJob', () => {
function server(answer: Response) {
const posts: unknown[] = [];
vi.stubGlobal('document', { cookie: 'sqb-csrf=token-1' });
vi.stubGlobal(
'fetch',
vi.fn(async (request: Request) => {
if (request.method === 'POST') {
posts.push(await request.json());
return answer.clone();
}
return Response.json([]);
})
);
return { posts, client: createHttpClient({ baseUrl: 'http://ui.test', onUnauthorized: () => {} }) };
}

it('sends a backfill window with the job run request', async () => {
const { posts, client } = server(Response.json({ error: 'stop here' }, { status: 409 }));
await expect(client.launchJob(ORDERS, 'daily', { window: { type: 'timestamp', start: '1200', end: '1500' } })).rejects.toThrow();
expect(posts[0]).toMatchObject({ job_key: 'daily', target: 'development', window: { kind: 'integer', start: 1200, end: 1500 } });
});

it('leaves the window out of an ordinary run', async () => {
const { posts, client } = server(Response.json({ error: 'stop here' }, { status: 409 }));
await expect(client.launchJob(ORDERS, 'daily')).rejects.toThrow();
expect(posts[0]).not.toHaveProperty('window');
});

it("shows a plain-text refusal of a body the server couldn't read", async () => {
const text = 'Failed to deserialize the JSON body into the target type: window: input contains invalid characters';
const { client } = server(new Response(text, { status: 422, headers: { 'content-type': 'text/plain; charset=utf-8' } }));
await expect(client.launchJob(ORDERS, 'daily', { window: { type: 'timestamp', start: '2026-10-01', end: '2026-10-31T00:00:00Z' } })).rejects.toMatchObject({ status: 422, message: text });
});

it('shows the status, not an HTML error page from something in front of the server', async () => {
const { client } = server(new Response('<html><body>Bad gateway</body></html>', { status: 502, statusText: 'Bad Gateway', headers: { 'content-type': 'text/html' } }));
await expect(client.launchJob(ORDERS, 'daily')).rejects.toMatchObject({ status: 502, message: '502 Bad Gateway' });
});

it('shows only the status for a plain-text server failure, which can name internal types', async () => {
const text = 'Missing request extension: Extension of type `example::AuditContext` was not found.';
const { client } = server(new Response(text, { status: 500, statusText: 'Internal Server Error', headers: { 'content-type': 'text/plain; charset=utf-8' } }));
await expect(client.launchJob(ORDERS, 'daily')).rejects.toMatchObject({ status: 500, message: '500 Internal Server Error' });
});

it("shows the server's refusal of a window as it comes", async () => {
const { client } = server(Response.json({ error: 'backfill window start must not follow end' }, { status: 400 }));
await expect(client.launchJob(ORDERS, 'daily', { window: { type: 'timestamp', start: '20', end: '10' } })).rejects.toMatchObject({
status: 400,
message: expect.stringContaining('backfill window start must not follow end')
});
});
});

describe('administration writes', () => {
const ada = { principal_id: 'p-ada', name: 'ada', created_at: '2026-11-01T00:00:00Z', disabled_at: null, has_password: true, groups: [], active_sessions: 2, last_signed_in_at: null };

Expand Down
Loading
Loading