1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
//! Change-log contract: the canonical `duration_ms` computation, the
//! `fraiseql.started_at` session-var convention, and the data-quality marker.
//!
//! These are the single source of truth shared by the session-var resolver
//! (`fraiseql-core`), the adapter's `set_config` application (`fraiseql-db`), and
//! the executor's in-txn outbox write (the Change Spine). See
//! `docs/architecture/change-log-contract.md`.
/// The transaction-local PostgreSQL session variable holding the mutation start
/// timestamp, on the **DB clock** (`clock_timestamp()`).
pub const STARTED_AT_VAR: &str = "fraiseql.started_at";
/// Sentinel session-var value meaning "stamp this variable with the database's
/// `clock_timestamp()` at apply time," rather than binding the string literally.
///
/// The session-var resolver emits this for [`STARTED_AT_VAR`] so the start
/// timestamp is taken on the **same clock** (`clock_timestamp()`) used to close
/// the interval at the outbox write — eliminating app↔DB clock skew. The value
/// uses control characters so it can never collide with a real session value.
pub const CLOCK_TIMESTAMP_DIRECTIVE: &str = "\u{1}fraiseql:clock_timestamp\u{1}";
/// The transaction-local PostgreSQL session variable that marks a write as
/// **FraiseQL-mediated** (#366).
///
/// The mutation executor sets this to [`CDC_MEDIATED_ON`] at the start of every
/// mutation transaction (PostgreSQL only). The shipped fallback-capture trigger
/// `core.fn_entity_change_log_capture()` reads it with
/// `current_setting('fraiseql.cdc_mediated', true)` and **suppresses** its own
/// change-log row when it equals [`CDC_MEDIATED_ON`] — so an app-path mutation,
/// already logged by the in-transaction outbox, is never double-captured. A raw
/// external write (psql / a migration / a third-party tool) leaves the GUC unset,
/// so the trigger fires and captures the change. Dotted custom GUC: no
/// `postgresql.conf` declaration is required, exactly like [`STARTED_AT_VAR`].
pub const CDC_MEDIATED_VAR: &str = "fraiseql.cdc_mediated";
/// The value [`CDC_MEDIATED_VAR`] carries when a write is FraiseQL-mediated.
///
/// The fallback-capture trigger suppresses its row only on an exact match, so the
/// unset state (raw external writes → `current_setting(..., true)` is NULL) never
/// suppresses capture.
pub const CDC_MEDIATED_ON: &str = "on";
/// Data-quality marker for the `duration_ms` computation.
///
/// Stamped into a framework-written change-log row's
/// `extra_metadata->>'duration_calc_version'` and bumped when the computation
/// changes, so consumers (#392) can refuse to mix incomparable rows. `2` = the
/// wall-clock-correct, single-DB-clock computation ([`duration_ms_sql`]); legacy
/// app-written rows carry no marker (or `1`).
pub const DURATION_CALC_VERSION: i64 = 2;
/// The canonical SQL expression computing `duration_ms` as **full wall-clock
/// milliseconds** from `started_at` to now, on the DB clock.
///
/// Uses `EXTRACT(EPOCH FROM interval)` (total seconds) — **never**
/// `EXTRACT(MILLISECONDS FROM interval)`, which returns only the
/// seconds-within-the-minute × 1000 and so under-reports any interval ≥ 1
/// minute (`00:01:30.250` → `30250`, not `90250`).
///
/// `started_at_var` is a trusted GUC name (e.g. [`STARTED_AT_VAR`]); the result
/// reads it back with `current_setting(...)::timestamptz` and closes the
/// interval against `clock_timestamp()` — the same clock that set it.
///
/// # Example
///
/// ```
/// let sql = fraiseql_db::changelog::duration_ms_sql(fraiseql_db::changelog::STARTED_AT_VAR);
/// assert!(sql.contains("EXTRACT(EPOCH"));
/// assert!(!sql.contains("MILLISECONDS"));
/// ```
/// The columns a **portable** (non-PostgreSQL) outbox INSERT writes.
///
/// The changed-entity identity + the Change Spine envelope subset that any
/// dialect — and any cooperative external producer — can supply by value.
///
/// PostgreSQL writes a richer set via its in-txn `MATERIALIZED` CTE (it also
/// stamps `started_at`/`duration_ms` from the request-scoped GUC, computed in
/// SQL). Those two columns are PostgreSQL-request-scoped and are **legitimately
/// omitted (NULL)** on the portable path — exactly the rows #392's `null-rate`
/// subcommand expects from non-FraiseQL producers. `seq` is supplied by the
/// table's sequence/identity default, never by the INSERT.
pub const CHANGELOG_PORTABLE_INSERT_COLUMNS: & = &;
/// Build a portable, fully-parameterized outbox INSERT for a non-PostgreSQL dialect.
///
/// The multi-DB counterpart of PostgreSQL's in-txn CTE: the row values are bound
/// from the parsed `app.mutation_response` row in Rust, since MySQL / SQL Server
/// cannot reference a `CALL`/`EXEC` result set in a following `INSERT ... SELECT`.
///
/// Placeholders are dialect-specific: PostgreSQL `$1, $2, …`, SQL Server
/// `@P1, @P2, …`, MySQL / SQLite `?`. The column list is
/// [`CHANGELOG_PORTABLE_INSERT_COLUMNS`], so every dialect writes the same
/// contract shape.
///
/// Column identifiers are quoted per dialect (PostgreSQL/SQLite `"col"`, MySQL
/// `` `col` ``, SQL Server `[col]`) because `cascade` is a reserved keyword in
/// MySQL and SQL Server — an unquoted `cascade` is a syntax error there.
///
/// # Example
///
/// ```
/// use fraiseql_db::{changelog::build_changelog_insert_sql, DatabaseType};
/// let sql = build_changelog_insert_sql("core.tb_entity_change_log", DatabaseType::MySQL);
/// assert!(sql.starts_with("INSERT INTO core.tb_entity_change_log ("));
/// assert!(sql.contains("`cascade`"), "reserved word quoted for MySQL");
/// assert!(sql.contains("VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"));
/// ```
/// Permissive truthiness for a `mutation_response` boolean column, shared by the
/// portable (MySQL / SQL Server) outbox paths. Dialects surface `succeeded` /
/// `state_changed` differently — MySQL's `TRUE`/`FALSE` literals come back as
/// integers (binary protocol), SQL Server's `BIT` as a bool — so `true`, a
/// non-zero number, and the string forms all read as the same flag.
pub
/// Serialise a JSON-bearing `mutation_response` column (`object_data`,
/// `updated_fields`, `cascade`) to text for binding to the portable outbox
/// INSERT's JSON / text column. A JSON `null` (or an absent column) binds as SQL
/// NULL; a non-string JSON value is re-serialised; a plain string passes through.
pub