feat(seed): SeedResult outcome, capped preview, advisory, count cap (ADR-0048 P1.3c)

A dedicated SeedResult replaces the borrowed insert outcome (X5):
- CommandOutcome::Seed + DslSeedSucceeded event + handle_dsl_seed_success
  render: the echo, "N row(s) seeded into T", a capped preview table
  (D18, first 20 rows; full count always reported), and a Hint-styled
  advisory naming enum-ish / un-derivable-CHECK columns filled with
  generic text (D12/D13, Phase-1 wording).
- SeedResult carries requested vs produced, so a junction cap is now
  reported to the user, not only logged.
- Count cap (D6): a seed over 10000 rows is refused with a friendly error.
- Catalog keys ok.rows_seeded / seed.capped / seed.advisory_generic.

4 new tests (advisory flag, IN-check not flagged, preview cap, excess
count). 2346 pass / 0 fail / 0 skip, clippy clean.
This commit is contained in:
claude@clouddev1
2026-06-11 19:11:18 +00:00
parent 9c135010ba
commit 0b3ab3cc13
7 changed files with 191 additions and 32 deletions
+32
View File
@@ -771,6 +771,10 @@ impl App {
self.handle_dsl_insert_success(&command, &result);
Vec::new()
}
AppEvent::DslSeedSucceeded { command, result } => {
self.handle_dsl_seed_success(&command, &result);
Vec::new()
}
AppEvent::DslUpdateSucceeded {
command,
result,
@@ -2072,6 +2076,34 @@ impl App {
}
}
/// Render a successful `seed` (ADR-0048): the ✓ echo, the seeded-row
/// count (with a cap note when the unique-value space ran out), the
/// capped preview table (D18), and a Hint-styled advisory naming
/// columns filled with generic text that look like fixed value sets
/// (D12/D13).
fn handle_dsl_seed_success(&mut self, command: &Command, result: &crate::db::SeedResult) {
self.note_ok_summary(command);
let mut summary = crate::t!(
"ok.rows_seeded",
count = result.produced,
table = result.table
);
if result.produced < result.requested {
summary.push(' ');
summary.push_str(&crate::t!("seed.capped", requested = result.requested));
}
self.note_system(summary);
for line in crate::output_render::render_data_table(&result.data) {
self.note_system(line);
}
if !result.advisory_columns.is_empty() {
self.push_category_three_prose(crate::t!(
"seed.advisory_generic",
columns = result.advisory_columns.join(", ")
));
}
}
fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) {
self.note_ok_summary(command);
self.note_system(crate::t!("ok.rows_updated", count = result.rows_affected));
+68 -20
View File
@@ -287,6 +287,23 @@ pub struct InsertResult {
pub data: DataResult,
}
/// Outcome of a successful `seed` (ADR-0048).
///
/// `produced` is below `requested` when the unique-value space ran out
/// (D14 cap). `data` is a **capped preview** of the seeded rows (D18,
/// not the whole batch). `advisory_columns` names columns that were
/// filled with generic text but look like fixed value sets — enum-ish
/// names or un-derivable CHECKs (D12/D13) — so the render can nudge the
/// user toward choosing those values deliberately.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeedResult {
pub table: String,
pub requested: u64,
pub produced: u64,
pub data: DataResult,
pub advisory_columns: Vec<String>,
}
/// Outcome of a successful `add column …`.
///
/// Carries the post-add structure (used for the auto-show that
@@ -709,7 +726,7 @@ enum Request {
count: Option<u64>,
rng_seed: Option<u64>,
source: Option<String>,
reply: oneshot::Sender<Result<InsertResult, DbError>>,
reply: oneshot::Sender<Result<SeedResult, DbError>>,
},
Update {
table: String,
@@ -1507,7 +1524,7 @@ impl Database {
count: Option<u64>,
rng_seed: Option<u64>,
source: Option<String>,
) -> Result<InsertResult, DbError> {
) -> Result<SeedResult, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::Seed {
table,
@@ -8686,6 +8703,14 @@ fn count_rows(conn: &Connection, table: &str) -> Result<i64, DbError> {
/// Default row count when `seed <T>` omits the count (ADR-0048 D6).
const DEFAULT_SEED_COUNT: u64 = 20;
/// Upper bound on a single `seed` (ADR-0048 D6) — a typo like
/// `seed t 1000000` is refused rather than left to hang the app.
const MAX_SEED_COUNT: u64 = 10_000;
/// Cap on rows shown in the post-seed auto-show preview (ADR-0048 D18).
/// The full count is always reported; only the rendered table is capped.
const SEED_PREVIEW_CAP: usize = 20;
/// How a single column's value is produced for each seeded row.
enum SeedColPlan {
/// Generated from the seed library (the generator is chosen once;
@@ -8802,7 +8827,7 @@ fn do_seed(
table: &str,
count: Option<u64>,
rng_seed: Option<u64>,
) -> Result<InsertResult, DbError> {
) -> Result<SeedResult, DbError> {
use crate::seed;
use rand::RngExt;
@@ -8810,6 +8835,12 @@ fn do_seed(
let table = canonical_table.as_str();
let n = count.unwrap_or(DEFAULT_SEED_COUNT);
debug!(table = %table, count = n, "seed");
if n > MAX_SEED_COUNT {
return Err(DbError::Unsupported(format!(
"cannot seed {n} rows at once: the maximum is {MAX_SEED_COUNT}. \
Seed in smaller batches."
)));
}
let schema = read_schema(conn, table)?;
@@ -8839,9 +8870,11 @@ fn do_seed(
}
// Build the per-column generation plan, skipping autogen and
// un-generatable columns.
// un-generatable columns. `advisory_columns` collects columns
// filled with generic text that look like fixed value sets (D12/D13).
let mut col_names: Vec<String> = Vec::new();
let mut plans: Vec<SeedColPlan> = Vec::new();
let mut advisory_columns: Vec<String> = Vec::new();
for c in &schema.columns {
let ty = c.user_type.unwrap_or(Type::Text);
// serial/shortid auto-fill in `do_insert`; omit them.
@@ -8879,6 +8912,15 @@ fn do_seed(
check_in_values,
};
let generator = seed::choose_generator(table, &spec);
// Flag columns that fell through to generic text but look
// like a fixed value set (enum-ish name, or a CHECK we
// could not derive values from) — D12/D13.
if matches!(generator, crate::seed::Generator::Generic)
&& (seed::is_enum_ish(&c.name)
|| (c.check.is_some() && spec.check_in_values.is_none()))
{
advisory_columns.push(c.name.clone());
}
plans.push(SeedColPlan::Generated { generator, ty });
}
}
@@ -8957,8 +8999,12 @@ fn do_seed(
const MAX_ATTEMPTS: u32 = 200;
let mut rng = seed::make_rng(rng_seed);
let mut rows_affected = 0usize;
let mut last_data: Option<DataResult> = None;
let mut preview = DataResult {
table_name: table.to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
};
let mut accepted: u64 = 0;
let mut capped = false;
@@ -9024,8 +9070,15 @@ fn do_seed(
};
match inserted {
Some(result) => {
rows_affected += result.rows_affected;
last_data = Some(result.data);
// Accumulate the capped preview (D18).
if preview.columns.is_empty() {
preview.columns = result.data.columns;
preview.column_types = result.data.column_types;
}
if preview.rows.len() < SEED_PREVIEW_CAP {
preview.rows.extend(result.data.rows);
preview.rows.truncate(SEED_PREVIEW_CAP);
}
accepted += 1;
}
None => break,
@@ -9037,21 +9090,16 @@ fn do_seed(
table = %table,
requested = n,
produced = accepted,
"seed capped: ran out of distinct unique-value combinations before the \
requested count (user-facing note arrives with the advisory in P1.3c)"
"seed capped: ran out of distinct unique-value combinations before the requested count"
);
}
Ok(InsertResult {
rows_affected,
// `None` only when count was 0 — an empty result for the
// auto-show (the zero-no-op refinement lands in a later phase).
data: last_data.unwrap_or_else(|| DataResult {
table_name: table.to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
}),
Ok(SeedResult {
table: table.to_string(),
requested: n,
produced: accepted,
data: preview,
advisory_columns,
})
}
+4
View File
@@ -87,6 +87,10 @@ pub enum AppEvent {
command: Command,
result: InsertResult,
},
DslSeedSucceeded {
command: Command,
result: crate::db::SeedResult,
},
DslUpdateSucceeded {
command: Command,
result: UpdateResult,
+3
View File
@@ -550,7 +550,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("ok.index_dropped_with_column", &["index"]),
("ok.rows_deleted", &["count"]),
("ok.rows_inserted", &["count"]),
("ok.rows_seeded", &["count", "table"]),
("ok.rows_updated", &["count"]),
("seed.capped", &["requested"]),
("seed.advisory_generic", &["columns"]),
// ---- Client-side success notes (ADR-0017 §6, ADR-0018 §9) ----
("client_side.auto_fill_add_serial", &["count"]),
("client_side.auto_fill_add_shortid", &["count"]),
+8
View File
@@ -983,6 +983,13 @@ db:
# template couldn't provide. Re-introduce a key here if a non-English
# locale lands.)
# Seed-command notes (ADR-0048): the cap note when the unique-value
# space is exhausted, and the advisory that flags columns filled with
# generic text that look like fixed value sets.
seed:
capped: "(of {requested} requested — ran out of distinct value combinations)"
advisory_generic: "{columns} filled with generic text — they look like fixed value sets."
ok:
# ADR-0040: the generic `[ok] <verb> <subject>` summary line was
# retired — a successful command's echo line now carries a ✓
@@ -990,6 +997,7 @@ ok:
# per-operation row-count footers below still convey real payload
# and are unchanged.
rows_inserted: " {count} row(s) inserted"
rows_seeded: " {count} row(s) seeded into {table}"
rows_updated: " {count} row(s) updated"
rows_deleted: " {count} row(s) deleted"
# Shown beneath a `drop column --cascade` summary, once per
+7 -4
View File
@@ -1492,6 +1492,10 @@ fn spawn_dsl_dispatch(
command: command.clone(),
result,
},
Ok(CommandOutcome::Seed(result)) => AppEvent::DslSeedSucceeded {
command: command.clone(),
result,
},
Ok(CommandOutcome::Update(result)) => AppEvent::DslUpdateSucceeded {
command: command.clone(),
result,
@@ -2364,6 +2368,7 @@ enum CommandOutcome {
ShowRelationship(Option<Box<crate::db::RelationshipDiagramData>>),
QueryPlan(QueryPlan),
Insert(InsertResult),
Seed(crate::db::SeedResult),
Update(UpdateResult),
Delete(DeleteResult),
ChangeColumn(ChangeColumnTypeResult),
@@ -2911,9 +2916,7 @@ async fn execute_command_typed(
.insert(table, columns, values, src)
.await
.map(CommandOutcome::Insert),
// ADR-0048 (SD1). Phase 1 reuses the insert outcome for the
// auto-show; a dedicated `SeedResult` (capped preview +
// enum/CHECK advisory) replaces this in a later phase.
// ADR-0048 (SD1).
Command::Seed {
table,
count,
@@ -2921,7 +2924,7 @@ async fn execute_command_typed(
} => database
.seed(table, count, rng_seed, src)
.await
.map(CommandOutcome::Insert),
.map(CommandOutcome::Seed),
Command::Update {
table,
assignments,