diff --git a/changes/vbp6-datetime-custom-field.md b/changes/vbp6-datetime-custom-field.md new file mode 100644 index 0000000..ea4c579 --- /dev/null +++ b/changes/vbp6-datetime-custom-field.md @@ -0,0 +1,8 @@ +--- +type: added +facade: breaking +--- +[en] +A plugin can now declare a custom field of type `datetime` (`[fields.] type = "datetime"`) for a moment such as a meeting's start. A value must be an RFC 3339 timestamp with a time zone (`Z` or an offset such as `+02:00`); a plain date or a time without a zone is refused. The value is stored as the same moment in UTC at whole seconds: `--set occurred_on=2026-10-12T10:30:00+02:00` stores `2026-10-12T08:30:00Z`, and fractional seconds are dropped. Because every value has that one shape, `datetime` values sort in time order wherever a field is read, such as a ranking key's `of = { field = "..." }`, locally and on a hosted board alike. The offset that was typed is not kept. `nxf schema --json` and MCP `flow_schema` report the field with `"kind": "datetime"`; `create`/`update --set` and MCP `flow_create`/`flow_update` validate it. Breaking for embedders that match on `plugin::FieldType`: it gains the variant `Datetime` and is now `#[non_exhaustive]`, so a match needs a wildcard arm; later field types will then be additive. +[de] +Ein Plugin kann jetzt ein eigenes Feld vom Typ `datetime` deklarieren (`[fields.] type = "datetime"`), für einen Zeitpunkt wie den Beginn eines Meetings. Ein Wert muss ein RFC-3339-Zeitstempel mit Zeitzone sein (`Z` oder ein Offset wie `+02:00`); ein reines Datum oder eine Uhrzeit ohne Zone wird abgelehnt. Gespeichert wird derselbe Zeitpunkt in UTC auf ganze Sekunden: `--set occurred_on=2026-10-12T10:30:00+02:00` speichert `2026-10-12T08:30:00Z`, Sekundenbruchteile entfallen. Weil jeder Wert diese eine Form hat, sortieren `datetime`-Werte überall zeitlich, wo ein Feld gelesen wird, etwa im `of = { field = "..." }` eines Ranking-Schlüssels, lokal wie auf einem gehosteten Brett. Der eingegebene Offset bleibt nicht erhalten. `nxf schema --json` und MCP `flow_schema` melden das Feld mit `"kind": "datetime"`; `create`/`update --set` und MCP `flow_create`/`flow_update` prüfen es. Brechend für Einbettende, die auf `plugin::FieldType` matchen: Es bekommt die Variante `Datetime` und ist jetzt `#[non_exhaustive]`, ein Match braucht also einen Wildcard-Arm; spätere Feldtypen sind dann additiv. diff --git a/crates/cli/docs/guide/de/plugins.md b/crates/cli/docs/guide/de/plugins.md index 82e3c17..46d8188 100644 --- a/crates/cli/docs/guide/de/plugins.md +++ b/crates/cli/docs/guide/de/plugins.md @@ -151,6 +151,36 @@ das Plugin ist, wie ein bestimmtes Publikum sie liest und rankt. Ein Plugin bei ist daher eine echte Entscheidung; führen Sie `nxf init --help` aus, um die Optionen zu sehen, bevor Sie sich festlegen. +## Eigene Felder deklarieren + +Ein Plugin kann seinen Typen unter `[fields.]` eigene Felder geben. `nxf schema --json` +listet sie mit `"custom": true` und ihrer `kind`; `create` und `update` setzen sie mit +`--set =`; `show --json` und die Listen geben die gesetzten unter `custom` zurück. Ein +leerer Wert löscht ein Feld. + +```toml +[fields.stage] +type = "enum" +values = ["backlog", "active", "done"] +on = ["project"] # die Typen, für die es gilt; weglassen heißt: für jeden Typ + +[fields.occurred_on] +type = "datetime" +label = "Occurred on" +``` + +Der `type` entscheidet, was ein Wert sein darf: + +- `text` und `longtext`: beliebiger Text. +- `date`: ein Datum (`2026-10-12`) oder ein RFC-3339-Zeitstempel, gespeichert wie angegeben. +- `datetime`: ein Zeitpunkt, als RFC-3339-Zeitstempel **mit Zeitzone** — `Z` oder ein Offset wie + `+02:00`. Gespeichert wird derselbe Zeitpunkt in UTC auf ganze Sekunden: + `--set occurred_on=2026-10-12T10:30:00+02:00` speichert `2026-10-12T08:30:00Z`. Ein reines Datum + oder eine Uhrzeit ohne Zone wird abgelehnt. Weil jeder Wert dieselbe Form hat, sortieren + `datetime`-Werte überall zeitlich, wo ein Feld gelesen wird, etwa im `of = { field = "..." }` + eines Ranking-Schlüssels. Der eingegebene Offset bleibt nicht erhalten. +- `enum`: einer der deklarierten `values`. + ## Ein Ranking deklarieren Ein Plugin deklariert unter `[ranking.next]`, wie `next` rankt, und unter `[ranking.list]`, wie diff --git a/crates/cli/docs/guide/en/plugins.md b/crates/cli/docs/guide/en/plugins.md index c9609b4..67bd963 100644 --- a/crates/cli/docs/guide/en/plugins.md +++ b/crates/cli/docs/guide/en/plugins.md @@ -147,6 +147,36 @@ That doubling is not redundancy — it *is* the explanation. The `--json` is the plugin is how a particular audience reads and ranks it. Picking a plugin at `init` is therefore a real choice; run `nxf init --help` to see the options before you commit. +## Declaring custom fields + +A plugin can give its types fields of their own under `[fields.]`. `nxf schema --json` lists +them with `"custom": true` and their `kind`; `create` and `update` set them with +`--set =`; `show --json` and the lists return the set ones under `custom`. An empty +value clears a field. + +```toml +[fields.stage] +type = "enum" +values = ["backlog", "active", "done"] +on = ["project"] # the types it applies to; omit it for every type + +[fields.occurred_on] +type = "datetime" +label = "Occurred on" +``` + +The `type` decides what a value may be: + +- `text` and `longtext`: any text. +- `date`: a date (`2026-10-12`) or an RFC 3339 timestamp, stored as given. +- `datetime`: a moment, as an RFC 3339 timestamp **with a time zone** — `Z` or an offset such as + `+02:00`. It is stored as the same moment in UTC at whole seconds: + `--set occurred_on=2026-10-12T10:30:00+02:00` stores `2026-10-12T08:30:00Z`. A plain date or a + time without a zone is refused. Because every value has the same shape, `datetime` values sort + in time order wherever a field is read, such as a ranking key's `of = { field = "..." }`. The + offset you typed is not kept. +- `enum`: one of the declared `values`. + ## Declaring a ranking A plugin declares how `next` ranks under `[ranking.next]` and how `list --sort rank` ranks under diff --git a/crates/facade/src/plugin/mod.rs b/crates/facade/src/plugin/mod.rs index 38947a9..ddee86d 100644 --- a/crates/facade/src/plugin/mod.rs +++ b/crates/facade/src/plugin/mod.rs @@ -72,14 +72,19 @@ pub enum MergeStrategy { /// The declared data type of a plugin custom field (plugin-custom-fields §3, TB-CF-3). Governs /// value validation and presentation, never storage (a value is always a `TEXT` LWW cell). The v1 /// set: `text`/`longtext` are free UTF-8; `date` is ISO-8601 (the same check `due`/`defer` use); -/// `enum` is one of a declared `values` list. Richer types (`int`/`bool`/format-validated -/// uri/email/color) are additive follow-ups (§9). +/// `datetime` is an RFC 3339 instant with a time zone, stored in UTC at whole seconds (nxf +/// 6j6v.vbp6, [`validate::datetime`](crate::validate::datetime)); `enum` is one of a declared +/// `values` list. Richer types (`int`/`bool`/format-validated uri/email/color) are additive +/// follow-ups (§9). `#[non_exhaustive]` since 6j6v.vbp6, so the next type is an additive change for +/// a consumer that matches on it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum FieldType { Text, Longtext, Date, + Datetime, Enum, } @@ -91,6 +96,7 @@ impl FieldType { FieldType::Text => "text", FieldType::Longtext => "longtext", FieldType::Date => "date", + FieldType::Datetime => "datetime", FieldType::Enum => "enum", } } @@ -1350,6 +1356,41 @@ mod tests { assert_eq!(stage.merge, MergeStrategy::CrdtText); } + #[test] + fn a_datetime_field_parses_validates_and_reports_its_kind() { + // vbp6: `type = "datetime"` is a declarable type, spelled `datetime` on the wire, and like + // every non-enum it refuses `values`. + let cfg = cfg_with_fields( + r#" + [fields.occurred_on] + type = "datetime" + on = ["project"] + label = "Occurred on" + "#, + ); + assert!(cfg.validate_fields().is_ok()); + let decl = &cfg.fields["occurred_on"]; + assert_eq!(decl.field_type, FieldType::Datetime); + assert_eq!(decl.field_type.as_str(), "datetime"); + assert!( + cfg_with_fields("[fields.at]\ntype = \"datetime\"\nvalues = [\"a\"]") + .validate_fields() + .is_err(), + "a datetime field carries no enum values" + ); + // The spelling is exact: `date-time`/`dateTime` are not types. + for bad in ["date-time", "dateTime", "timestamp"] { + let toml = format!("[fields.at]\ntype = \"{bad}\""); + let parsed = toml::from_str::(&toml) + .unwrap() + .get("fields") + .cloned() + .unwrap() + .try_into::>(); + assert!(parsed.is_err(), "'{bad}' is not a field type"); + } + } + #[test] fn validate_fields_rejects_a_canonical_name_collision() { // §3 / TB-CF-6: a custom field must not shadow a canonical field or alias — a loud load diff --git a/crates/facade/src/read.rs b/crates/facade/src/read.rs index 0c5da3b..05a0f57 100644 --- a/crates/facade/src/read.rs +++ b/crates/facade/src/read.rs @@ -224,6 +224,8 @@ fn titleise(name: &str) -> String { pub struct SchemaField { pub field: String, pub label: String, + /// How the value is interpreted: a canonical field's kind, or a custom field's declared type + /// (`text | longtext | date | datetime | enum`, [`FieldType::as_str`](crate::plugin::FieldType::as_str)). pub kind: String, pub required: bool, pub settable: bool, diff --git a/crates/facade/src/validate.rs b/crates/facade/src/validate.rs index 4f21028..d555b00 100644 --- a/crates/facade/src/validate.rs +++ b/crates/facade/src/validate.rs @@ -35,6 +35,36 @@ pub fn iso_date(value: &str) -> Result { } } +/// Validate a `datetime` custom-field value (nxf 6j6v.vbp6) and return its stored form. The input +/// must be an RFC 3339 timestamp WITH a time zone (`Z` or `±hh:mm`); a plain date or a local time +/// without an offset is refused. The stored form is the same instant in UTC at whole seconds, +/// `YYYY-MM-DDTHH:MM:SSZ`: fractional seconds are dropped and the given offset is applied, then +/// discarded. One fixed shape means the text order of stored values IS their chronological order, +/// which is what every comparison downstream relies on (the SQL sort, the hosted fold, the ranking +/// comparator's text arm). A UTC year outside `0000..=9999` is refused for the same reason. +pub fn datetime(value: &str) -> Result { + let refuse = || { + NxfError::validation(format!( + "'{value}' is not an RFC 3339 date-time with a time zone \ + (e.g. 2026-10-12T08:30:00Z or 2026-10-12T10:30:00+02:00)" + )) + }; + let utc = OffsetDateTime::parse(value, &Rfc3339) + .map_err(|_| refuse())? + .checked_to_offset(time::UtcOffset::UTC) + .filter(|utc| (0..=9999).contains(&utc.year())) + .ok_or_else(refuse)?; + Ok(format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + utc.year(), + u8::from(utc.month()), + utc.day(), + utc.hour(), + utc.minute(), + utc.second() + )) +} + /// Canonicalize + validate a user label (h89s): trim surrounding whitespace, reject an empty /// result, and reject the U+001F composite separator (it would break the OR-set's `item␟label` /// target_id). Returns the stored form. The ONE definition every label path shares — the facade @@ -166,4 +196,69 @@ mod tests { assert!(iso_date("2026-13-01").is_err()); assert!(iso_date("2026/06/09").is_err()); } + + #[test] + fn datetime_normalizes_any_offset_to_utc_seconds() { + // The offset is applied, not dropped: 10:30 at +02:00 is 08:30 UTC. + assert_eq!( + datetime("2026-10-12T10:30:00+02:00").unwrap(), + "2026-10-12T08:30:00Z" + ); + assert_eq!( + datetime("2026-10-12T08:30:00Z").unwrap(), + "2026-10-12T08:30:00Z" + ); + // A negative offset can cross midnight into the next day. + assert_eq!( + datetime("2026-12-31T23:00:00-05:00").unwrap(), + "2027-01-01T04:00:00Z" + ); + // Lowercase separators are RFC 3339 too. + assert_eq!( + datetime("2026-10-12t08:30:00z").unwrap(), + "2026-10-12T08:30:00Z" + ); + } + + #[test] + fn datetime_drops_fractional_seconds() { + // Fixed precision keeps text order chronological: `…00.5Z` would sort BEFORE `…00Z`. + assert_eq!( + datetime("2026-10-12T08:30:00.000Z").unwrap(), + "2026-10-12T08:30:00Z" + ); + assert_eq!( + datetime("2026-10-12T08:30:59.999999+00:00").unwrap(), + "2026-10-12T08:30:59Z" + ); + } + + #[test] + fn datetime_refuses_a_value_without_a_time_zone() { + for v in [ + "2026-10-12", + "2026-10-12T08:30:00", + "2026-10-12T08:30", + "2026-10-12 08:30:00", + "not-a-date", + "2026-13-01T00:00:00Z", + "", + ] { + let err = datetime(v).expect_err(v); + assert_eq!(err.kind, crate::error::ErrorKind::Validation, "{v}"); + assert!(err.msg.contains("RFC 3339"), "{v}: {}", err.msg); + } + } + + #[test] + fn datetime_refuses_a_utc_year_outside_four_digits() { + // In range as given, out of range once moved to UTC: refused, not stored with a 5-digit or + // negative year that would break the text order. + assert!(datetime("9999-12-31T23:30:00-01:00").is_err()); + assert!(datetime("0000-01-01T00:30:00+01:00").is_err()); + assert_eq!( + datetime("0000-01-01T00:30:00Z").unwrap(), + "0000-01-01T00:30:00Z" + ); + } } diff --git a/crates/facade/src/write.rs b/crates/facade/src/write.rs index 38ecafa..d39f4f0 100644 --- a/crates/facade/src/write.rs +++ b/crates/facade/src/write.rs @@ -528,11 +528,14 @@ fn prepare_custom_set( ))); } // Per-type value validation — skipped for an empty value, which is the clear sentinel (§4.2). - if !value.is_empty() { + // A `datetime` value is the one type stored in a canonical form rather than as given (vbp6). + let canonical = if value.is_empty() { + String::new() + } else { match decl.field_type { - FieldType::Date => { - validate::iso_date(value)?; - } + FieldType::Date => validate::iso_date(value)?, + FieldType::Datetime => validate::datetime(value) + .map_err(|e| NxfError::validation(format!("custom field '{name}': {}", e.msg)))?, FieldType::Enum => { if !decl.values.iter().any(|v| v == value) { return Err(NxfError::validation(format!( @@ -540,11 +543,12 @@ fn prepare_custom_set( decl.values ))); } + value.to_string() } - FieldType::Text | FieldType::Longtext => {} + FieldType::Text | FieldType::Longtext => value.to_string(), } - } - Ok((value.to_string(), custom_merge_strategy(decl.merge))) + }; + Ok((canonical, custom_merge_strategy(decl.merge))) } /// Parse + validate the create/update custom-field `--set name=value` pairs (plugin-custom-fields diff --git a/crates/facade/tests/derived_ranking.rs b/crates/facade/tests/derived_ranking.rs index d5ad251..8c59941 100644 --- a/crates/facade/tests/derived_ranking.rs +++ b/crates/facade/tests/derived_ranking.rs @@ -778,6 +778,8 @@ fn kinds_cfg(order: &str) -> PluginConfig { columns = ["id"] [fields.stage] type = "text" + [fields.met] + type = "datetime" "# )) .unwrap() @@ -875,6 +877,38 @@ fn related_honours_its_type_filter_a_precedence_and_a_custom_field() { ); } +#[test] +fn related_reads_a_datetime_custom_field_in_chronological_order() { + // vbp6: a `datetime` value is stored as the UTC instant, so `pick` and `dir` compare instants, + // not the text as typed. As typed, b1's "09:00Z" sorts before n1's "10:00+02:00"; as instants + // n1 (08:00Z) is earlier. + let mut s = kinds_board(); + let cfg = kinds_cfg(r#"[ { related = "mentions", of = { field = "met" } } ]"#); + let set = |s: &mut Store, id: &str, v: &str| { + nexus_flow_facade::write::update(s, &cfg, NOW, "t", id, &[format!("met={v}")]).unwrap(); + }; + set(&mut s, "zz12.n1", "2026-10-12T10:00:00+02:00"); + set(&mut s, "zz12.b1", "2026-10-12T09:00:00Z"); + set(&mut s, "zz12.b2", "2026-10-12T07:59:59.900-00:00"); + edge(&mut s, "zz12.i1", "zz12.b1", EdgeKind::Mentions); // 09:00Z + edge(&mut s, "zz12.i2", "zz12.n1", EdgeKind::Mentions); // 08:00Z + edge(&mut s, "zz12.i3", "zz12.b1", EdgeKind::Mentions); // 09:00Z and + edge(&mut s, "zz12.i3", "zz12.b2", EdgeKind::Mentions); // 07:59:59Z + assert_eq!( + ranked_items(&cfg, &s), + ["zz12.i3", "zz12.i2", "zz12.i1", "zz12.i4"], + "min, ascending: the earliest instant first; no neighbour → null, last" + ); + let latest = kinds_cfg( + r#"[ { related = "mentions", of = { field = "met" }, pick = "max", dir = "desc" } ]"#, + ); + assert_eq!( + ranked_items(&latest, &s), + ["zz12.i1", "zz12.i3", "zz12.i2", "zz12.i4"], + "max, descending: i1 and i3 both read 09:00Z and fall to the id" + ); +} + #[test] fn related_reads_a_neighbours_rank_in_its_own_order() { let mut s = kinds_board(); diff --git a/crates/facade/tests/engine.rs b/crates/facade/tests/engine.rs index a543a1d..61552a3 100644 --- a/crates/facade/tests/engine.rs +++ b/crates/facade/tests/engine.rs @@ -768,6 +768,8 @@ columns = ["id"] [fields.uri] type = "text" label = "URI" +[fields.occurred_on] +type = "datetime" "#, order: 101, }); @@ -1168,3 +1170,54 @@ fn a_chunk_op_of_exactly_the_limit_is_taken_and_folded_and_one_byte_more_is_refu ErrorKind::Validation ); } + +// ---- the `datetime` custom field type at the Engine seam (6j6v.vbp6) --------------------------- + +#[test] +fn engine_update_stores_a_datetime_custom_value_in_utc_and_reads_it_back() { + // The embedding seam app-foundations drives: `Engine::update --set` with an offset timestamp, + // read back through `show_value` and the bulk join as the normalized UTC instant. + let tmp = seeded_fields_workspace(); + let engine = Engine::open(None, tmp.path()).unwrap(); + engine + .update( + NOW, + "tester", + "cf12.0002", + &["occurred_on=2026-10-12T10:30:00.123+02:00".to_string()], + ) + .unwrap(); + let v = engine.show_value("cf12.0002").unwrap(); + assert_eq!( + v["custom"]["occurred_on"], + serde_json::json!("2026-10-12T08:30:00Z"), + "show_value reads the stored UTC instant: {v}" + ); + assert_eq!( + engine.custom_fields_bulk(&["cf12.0002"]).unwrap()["cf12.0002"]["occurred_on"], + serde_json::json!("2026-10-12T08:30:00Z") + ); + // A value without a zone is refused at the seam and changes nothing. + let err = engine + .update( + NOW, + "tester", + "cf12.0002", + &["occurred_on=2026-10-12T09:00:00".to_string()], + ) + .unwrap_err(); + assert_eq!(err.kind, ErrorKind::Validation); + assert_eq!( + engine.show_value("cf12.0002").unwrap()["custom"]["occurred_on"], + serde_json::json!("2026-10-12T08:30:00Z") + ); + // The schema the seam reports names the type. + let schema = read::schema(&engine.plugin_config()); + let field = schema + .fields + .iter() + .find(|f| f.field == "occurred_on") + .expect("schema lists the datetime field"); + assert_eq!(field.kind, "datetime"); + assert!(field.custom && field.settable); +} diff --git a/crates/facade/tests/write.rs b/crates/facade/tests/write.rs index 9f61a40..05dc3bc 100644 --- a/crates/facade/tests/write.rs +++ b/crates/facade/tests/write.rs @@ -1322,7 +1322,7 @@ fn label_add_rejects_a_separator_in_the_label() { /// A PluginConfig declaring custom fields (plugin-custom-fields §3), the fixture the T3 write-path /// tests vary. `uri` (required text on `file`), `body` (crdt-text longtext on `file`), `stage` -/// (enum on `project`), `deadline` (date on `project`). +/// (enum on `project`), `deadline` (date on `project`), `met_at` (datetime on `project`). fn cfg_custom() -> PluginConfig { toml::from_str( r#" @@ -1361,6 +1361,10 @@ fn cfg_custom() -> PluginConfig { [fields.deadline] type = "date" on = ["project"] + + [fields.met_at] + type = "datetime" + on = ["project"] "#, ) .unwrap() @@ -1621,3 +1625,85 @@ fn a_date_custom_field_rejects_a_non_iso_value() { "a valid ISO date is accepted: {ops:?}" ); } + +// ---- the `datetime` custom field type (6j6v.vbp6) ---------------------------------------------- + +/// A `project` created with the given custom `--set` assignments. +fn create_project( + s: &mut Store, + cfg: &PluginConfig, + custom: &[String], +) -> nexus_flow_facade::error::Result { + let mut item = minimal("why", "P1"); + item.custom = custom; + write::create(s, cfg, "ab12", NOW, ACTOR, "project", "P", item).map(|i| i.id) +} + +#[test] +fn a_datetime_custom_field_is_stored_in_utc_at_whole_seconds_on_create_and_update() { + let mut s = store(); + let cfg = cfg_custom(); + // Create: the +02:00 offset is applied and the stored value is the UTC instant. + let id = create_project( + &mut s, + &cfg, + &["met_at=2026-10-12T10:30:00+02:00".to_string()], + ) + .unwrap(); + assert_eq!( + custom_ops(&s, &id).last(), + Some(&( + "met_at".to_string(), + "set".to_string(), + "2026-10-12T08:30:00Z".to_string() + )), + "create stores the normalized instant" + ); + // Update: fractional seconds are dropped, `Z` stays `Z`. + write::update( + &mut s, + &cfg, + NOW, + ACTOR, + &id, + &["met_at=2026-10-13T07:00:00.250Z".to_string()], + ) + .unwrap(); + assert_eq!( + s.custom_fields_of(&id) + .unwrap() + .get("met_at") + .map(String::as_str), + Some("2026-10-13T07:00:00Z"), + "update stores the normalized instant in the folded view" + ); + // An empty value is the clear sentinel and bypasses the type check, as for `date`. + write::update(&mut s, &cfg, NOW, ACTOR, &id, &["met_at=".to_string()]).unwrap(); + assert_eq!( + custom_ops(&s, &id).last(), + Some(&("met_at".to_string(), "set".to_string(), String::new())), + "an empty value clears the field" + ); +} + +#[test] +fn a_datetime_custom_field_refuses_a_value_without_a_time_zone() { + let mut s = store(); + let cfg = cfg_custom(); + let id = create_project(&mut s, &cfg, &[]).unwrap(); + let before = custom_ops(&s, &id).len(); + for bad in ["2026-10-12", "2026-10-12T08:30:00", "yesterday"] { + let err = write::update(&mut s, &cfg, NOW, ACTOR, &id, &[format!("met_at={bad}")]) + .expect_err(bad); + assert_eq!(err.kind, ErrorKind::Validation, "{bad}"); + assert!( + err.msg.contains("met_at") && err.msg.contains("time zone"), + "the error names the field and the missing zone: {}", + err.msg + ); + } + // A refused create writes nothing either. + let err = create_project(&mut s, &cfg, &["met_at=2026-10-12".to_string()]).unwrap_err(); + assert_eq!(err.kind, ErrorKind::Validation); + assert_eq!(custom_ops(&s, &id).len(), before, "nothing was written"); +} diff --git a/crates/fold-ddb/tests/records_differential.rs b/crates/fold-ddb/tests/records_differential.rs index b300b93..89a1f03 100644 --- a/crates/fold-ddb/tests/records_differential.rs +++ b/crates/fold-ddb/tests/records_differential.rs @@ -406,6 +406,39 @@ fn a_ticket_reads_only_its_own_labels_custom_values_and_links() { assert_eq!(served.tickets, read::active_board(&s).unwrap().tickets); } +#[test] +fn a_datetime_custom_value_reaches_the_server_as_the_utc_instant_the_replica_holds() { + // vbp6: the write seam stores a `datetime` value in its canonical UTC form, so the op log carries + // that form and a hosted fold holds the same instant the replica does — nothing type-aware on + // the server side. + let mut cfg = cfg_with_fields(); + cfg.fields.insert( + "occurred_on".into(), + toml::from_str("type = \"datetime\"").expect("a datetime declaration parses"), + ); + let mut s = nexus_flow_core::store::Store::open_in_memory(1); + s.create_item("ab12.0001", "task", "T", "u"); + nexus_flow_facade::write::update( + &mut s, + &cfg, + NOW, + "u", + "ab12.0001", + &["occurred_on=2026-10-12T10:30:00.5+02:00".to_string()], + ) + .unwrap(); + let folder = Folder::platform().unwrap(); + let served = served_board(&serve(&s, 3, &folder)); + assert_eq!( + served.tickets["ab12.0001"] + .custom + .get("occurred_on") + .map(String::as_str), + Some("2026-10-12T08:30:00Z") + ); + assert_eq!(served.tickets, read::active_board(&s).unwrap().tickets); +} + #[test] fn a_stream_folded_before_the_entries_is_refused_then_refolded_and_reads_its_labels() { // A stream an older release folded: the same rows, no entries under its tickets, and a diff --git a/crates/nxs/src/mcp/flow.rs b/crates/nxs/src/mcp/flow.rs index 0aaa4bd..9cb974a 100644 --- a/crates/nxs/src/mcp/flow.rs +++ b/crates/nxs/src/mcp/flow.rs @@ -708,4 +708,65 @@ mod custom_field_tests { "a cleared custom field drops the sparse map" ); } + + #[test] + fn flow_create_and_update_store_a_datetime_custom_field_as_the_utc_instant() { + // vbp6: the MCP write path hands a `datetime` value to the shared facade, which stores the + // UTC instant; a value without a time zone is refused. + let tmp = file_workspace(); + let start = tmp.path(); + let id = create( + None, + start, + "tester", + &create_params( + vec![ + "uri=.nxs/files/a.md".into(), + "produced_at=2026-10-12T10:30:00+02:00".into(), + ], + "2026-10-12T09:00:00Z", + ), + ) + .expect("flow_create with a zoned datetime succeeds")["id"] + .as_str() + .unwrap() + .to_string(); + let shown = |id: &str| { + show( + None, + start, + &FlowShowParams { + id: id.to_string(), + workspace: None, + }, + ) + .unwrap() + }; + assert_eq!( + shown(&id)["custom"]["produced_at"], + serde_json::json!("2026-10-12T08:30:00Z") + ); + let err = update( + None, + start, + "tester", + &FlowUpdateParams { + id: id.clone(), + set: vec!["produced_at=2026-10-12".into()], + now: Some("2026-10-12T09:01:00Z".into()), + actor: None, + workspace: None, + }, + ) + .expect_err("a plain date is not a datetime"); + assert!( + err.to_string().contains("time zone"), + "the refusal says what is missing: {err}" + ); + assert_eq!( + shown(&id)["custom"]["produced_at"], + serde_json::json!("2026-10-12T08:30:00Z"), + "the refused update changed nothing" + ); + } } diff --git a/crates/nxs/src/mcp/params.rs b/crates/nxs/src/mcp/params.rs index 8dcae3b..b877ad4 100644 --- a/crates/nxs/src/mcp/params.rs +++ b/crates/nxs/src/mcp/params.rs @@ -61,6 +61,8 @@ pub struct FlowCreateParams { /// Plugin custom-field assignments as `name=value` strings (plugin-custom-fields §5), mirroring /// `nxf create --set name=value`. Canonical fields use the dedicated params above; `set` carries /// the active plugin's declared custom fields, validated against the resolved type at write time. + /// `flow_schema` names each custom field's `kind`; a `datetime` value needs a time zone + /// (`2026-10-12T10:30:00+02:00`) and is stored as the UTC instant (`2026-10-12T08:30:00Z`). #[serde(default)] pub set: Vec, /// Reference time (ISO-8601 / RFC3339) stamped on the emitted ops. @@ -81,7 +83,8 @@ pub struct FlowCreateParams { pub struct FlowUpdateParams { /// Item id (a bare local suffix or a full `.`). pub id: String, - /// Field assignments as `field=value` strings (e.g. `status=in_progress`, `priority=P0`). + /// Field assignments as `field=value` strings (e.g. `status=in_progress`, `priority=P0`). A + /// custom `datetime` field needs a time zone and is stored as the UTC instant. pub set: Vec, /// Reference time (ISO-8601 / RFC3339) stamped on the emitted ops. #[serde(default)] diff --git a/crates/plugin-probe/src/file-fixture.toml b/crates/plugin-probe/src/file-fixture.toml index 60c291a..0ceba18 100644 --- a/crates/plugin-probe/src/file-fixture.toml +++ b/crates/plugin-probe/src/file-fixture.toml @@ -47,3 +47,11 @@ type = "text" on = ["file"] required = true label = "File URI" + +# When the linked file was produced (6j6v.vbp6): an optional `datetime` field, so the consumer +# path also proves the one type stored in a canonical form — an RFC 3339 instant with a time zone, +# stored in UTC at whole seconds. +[fields.produced_at] +type = "datetime" +on = ["file"] +label = "Produced at" diff --git a/crates/plugin-probe/tests/ky26_file_consumer.rs b/crates/plugin-probe/tests/ky26_file_consumer.rs index 12dd8dc..46ea752 100644 --- a/crates/plugin-probe/tests/ky26_file_consumer.rs +++ b/crates/plugin-probe/tests/ky26_file_consumer.rs @@ -153,6 +153,36 @@ fn schema_marks_uri_as_a_required_file_scoped_custom_text_field() { assert_eq!(uri_json["kind"], "text"); } +#[test] +fn schema_reports_the_datetime_field_kind_and_create_stores_the_utc_instant() { + // vbp6: `nxf schema --json` (the same `read::schema(..).to_value()`) names the `datetime` kind, + // and a create through the consumer plugin stores the zoned value as the UTC instant. + let cfg = cfg(); + let v = read::schema(&cfg).to_value(); + let produced = v["fields"] + .as_array() + .unwrap() + .iter() + .find(|f| f["field"] == "produced_at") + .expect("produced_at appears in schema --json"); + assert_eq!(produced["kind"], "datetime"); + assert_eq!(produced["custom"], serde_json::json!(true)); + assert_eq!(produced["settable"], serde_json::json!(true)); + + let mut s = Store::open_in_memory(1); + let sets = vec![ + format!("uri={URI}"), + "produced_at=2026-10-12T23:15:00-07:00".to_string(), + ]; + let item = create_file(&mut s, &cfg, "report.md", &sets).unwrap(); + let sv = read::show_value_with_custom(&cfg, &s, &item.id).unwrap(); + assert_eq!( + sv["custom"]["produced_at"], + serde_json::json!("2026-10-13T06:15:00Z"), + "the -07:00 evening is the next UTC morning" + ); +} + #[test] fn read_surfaces_the_sparse_custom_uri_map_on_show_and_lane() { // read: `show --json` and a lane read (`list`/`next --json`) surface the sparse `custom` map diff --git a/docs/specs/plugin-custom-fields.md b/docs/specs/plugin-custom-fields.md index 406ef8c..d3e3849 100644 --- a/docs/specs/plugin-custom-fields.md +++ b/docs/specs/plugin-custom-fields.md @@ -49,7 +49,8 @@ one domain uses). **Explicit non-goals (§9):** slice *implementation* (post-approval); **set-valued** (`or-set`) custom fields (scalar-only v1 — no v1 consumer, and they need their own view+fold like `labels`); -richer scalar types beyond text/longtext/date/enum (int/bool/format-validated uri/email/color); +richer scalar types beyond text/longtext/date/enum (int/bool/format-validated uri/email/color; +`datetime` has since been added — §5.1, 6j6v.vbp6); the `r16h`/`file` consumer plugin itself (`ky26`) and blob sync; a per-field permission model. ## 2. Data model — the 16 canonical, unchanged, plus a folded `custom_fields` view @@ -101,7 +102,7 @@ A plugin declares custom fields in a new top-level TOML table, parsed + validate ```toml [fields.uri] -type = "text" # text | longtext | date | enum (v1 set — TB-CF-3) +type = "text" # text | longtext | date | datetime | enum (TB-CF-3; datetime §5.1) on = ["file"] # types this field applies to; omit ⇒ all types (TB-CF-2) required = true # must be set at create for an applicable type (default false) label = "File URI" # display label; default = titleised name @@ -186,8 +187,9 @@ scalar register); the row remains with an empty `value` and reads as unset in th 1. If `name` is canonical/aliased → unchanged path (bare `set_field` LWW). 2. Else if `name` is a **declared custom field** of the active plugin → **new path**: validate the item's `type` is in the field's `on` set; validate `value` against the declared type (`date` → - ISO-8601, reusing the `due`/`defer` check; `enum` → member of `values`; `text`/`longtext` → any - UTF-8); emit a merge-tagged `field set` op via `set_field_merge`. + ISO-8601, reusing the `due`/`defer` check; `datetime` → RFC 3339 with a time zone, stored as + the UTC instant (§5.1); `enum` → member of `values`; `text`/`longtext` → any UTF-8); emit a + merge-tagged `field set` op via `set_field_merge`. 3. Else (undeclared) → the existing **loud `validation` error**, now listing canonical + declared custom names (`write.rs:418`). @@ -208,6 +210,43 @@ no v1 consumer, and `file.uri` is satisfied by the create guard. T3 (`6j6v.p7b2` create guard only; a presentation layer that wants "this file is missing its uri" derives it from the sparse `custom` map, not from a write-time reject. +### 5.1 The `datetime` type (6j6v.vbp6) + +`date` stores what it is given: a plain `YYYY-MM-DD` or any RFC 3339 timestamp, as typed. That is +enough for a deadline, not for a moment such as a meeting's start. `type = "datetime"` is the type +for a moment: + +```toml +[fields.occurred_on] +type = "datetime" +on = ["note"] +label = "Occurred on" +``` + +- **Accepted:** an RFC 3339 date-time **with a time zone** — `Z` or a numeric offset + (`2026-10-12T10:30:00+02:00`, `2026-10-12T08:30:00Z`, `…T08:30:00.250Z`). Lowercase `t`/`z` are + RFC 3339 too. +- **Refused (`validation`, naming the field):** a plain date (`2026-10-12` — a plugin wanting a + date declares `date`), a local time without an offset (`2026-10-12T08:30:00`), anything else + that is not RFC 3339, and an instant whose UTC year falls outside `0000..=9999`. +- **Stored:** the same instant in UTC at whole seconds, `YYYY-MM-DDTHH:MM:SSZ`. The offset is + applied and then discarded; fractional seconds are dropped (truncated, not rounded). + `2026-10-12T10:30:00.9+02:00` is stored as `2026-10-12T08:30:00Z`. +- **Cleared** by an empty value, like every custom field (§4.2). + +**Why one fixed shape.** Every comparison downstream compares the stored text: the derived ranking +comparator (numbers before text, then text order), a server's fold, any SQL sort. With one +offset and one precision, text order **is** chronological order — no reader needs to know the +type. Keeping the given offset would order `09:00Z` before `10:00+02:00` (08:00Z); keeping a +variable precision would order `…00.5Z` before `…00Z` (`.` sorts before `Z`). So a `datetime` +field ranks chronologically wherever a field is read, today in a derived key's +`of = { field = "…" }` (custom fields are not direct `[ranking]` field keys). The normalization +happens once, at the write seam (`validate::datetime`, called from `prepare_custom_set`), so the op +log, every replica and a hosted fold carry the canonical value — nothing type-aware downstream. + +**What is lost.** The writer's offset is not kept: a reader shows the instant in its own time zone. +A consumer that must show "10:30 in the attendee's zone" stores that zone separately. + ## 6. Read & schema surface **`nxf schema --json` / `flow_schema`** (`crates/facade/src/read.rs:322`, one source for CLI + MCP): @@ -287,7 +326,7 @@ per-field change — the generic seam the epic is designed to feed. rather than half-built (TB-CF-4). - **Richer scalar types.** `int`, `bool`, and format-validated `uri`/`email`/`color` are out; v1 is `text`/`longtext`/`date`/`enum` (a hex color or uri is `text`, a fixed palette is `enum`) — - TB-CF-3. Adding a type later is additive. + TB-CF-3. Adding a type later is additive; `datetime` was the first such addition (§5.1). - **The `r16h` / `file` consumer plugin itself** (`ky26`) and **blob sync** — this epic ships the *mechanism*; `file`+`uri` is its first *user*, tracked separately. - **A per-field permission / audit model** — custom fields obey the same actor model as canonical @@ -301,7 +340,7 @@ All eight decisions were approved as proposed. |---|---|---| | **TB-CF-1** | Where do custom values live — dynamic `items` columns or a separate view? | **Separate folded `custom_fields` EAV view** (§2). Keeps `items`/`ItemRow`/`CanonicalItem` byte-identical (oracle + goldens green, SemVer-minor), sparse, and mirrors `labels`/edges. Dynamic columns would make the core table plugin-dependent. | | **TB-CF-2** | Are fields global or type-scoped? | **Type-scoped** via `on = […]` (default: all types). Matches every consumer (`person.email`, `project.status`, `file.uri`); `--set` on a non-applicable type errors. | -| **TB-CF-3** | Which data types in v1? | **`text` \| `longtext` \| `date` \| `enum`.** Covers all named consumers (uri/email/color = `text`; a fixed palette/status = `enum`; deadline = `date`). `int`/`bool`/format-validated types are additive follow-ups. | +| **TB-CF-3** | Which data types in v1? | **`text` \| `longtext` \| `date` \| `enum`.** Covers all named consumers (uri/email/color = `text`; a fixed palette/status = `enum`; deadline = `date`). `int`/`bool`/format-validated types are additive follow-ups. **Extended 2026-10-12 (6j6v.vbp6):** `datetime` — an RFC 3339 instant with a time zone, stored in UTC at whole seconds (§5.1). | | **TB-CF-4** | Multi-value (list) custom fields in v1? | **No — scalar only.** `or-set` custom fields need their own view+fold and have no v1 consumer; deferred (§9). | | **TB-CF-5** | Merge policy & the `w213` seam? | **All v1 custom fields fold keep-if-beats LWW**, with the op carrying the `w213` strategy marker (`set`/`set:crdt-text`) so a real char-level merge is a future drop-in. This epic is the **first** writer to activate the dormant `w213` `set_field_merge` path; canonical fields are untouched. | | **TB-CF-6** | Write namespace — flat `--set uri=` or a `custom.`/`x-` prefix? | **Flat `--set =` with a load-time collision guard** vs the canonical set; values read back under a sparse `custom{}` map (so `CanonicalItem`'s 16 keys stay parity-stable). Best agent-ergonomics; a future canonical-field collision is caught loudly at plugin load. |