From 227445f6f63d3fb7f3a45c293c672f4db9cf5554 Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Fri, 17 Jul 2026 19:50:17 -0600 Subject: [PATCH] =?UTF-8?q?fix(sync):=20emit=20sortAt=20ops=20by=20TARGET?= =?UTF-8?q?=20name=20in=20seconds=20=E2=80=94=20source-name=20ops=20are=20?= =?UTF-8?q?silently=20dropped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The W1-1/W2-1 design emitted the Image trigger's sort value as field 'sortAtUnix' in milliseconds, assuming the data_schema sortAtUnix->sortAt (ms_to_seconds) mapping applied at op ingestion. It does not: data_schema source->target renames apply to DOCUMENT keys (dump/insert path) only. Op Set/Remove fields resolve raw against filter/sort TARGET names (ops_processor process_set_op) and unknown fields are silently ignored — so every steady-state sortAt op was dropped in prod (~5h, 2026-07-17/18) while publishedAt (target-name) ops in the same batches applied fine. Proven by a single-op experiment against prod; every other field always emitted target names. Fix: the shared expression + shared_ops_fields emit field 'sortAt' in SECONDS (no *1000). Function name bitdex_image_sortat_ops is unchanged (re-emitter parity preserved). The dormant sortAtUnix->sortAt doc mapping stays for the dump path. Tests updated to pin target-name + seconds and to reject source-name emission; trigger-sql-review.sql regenerated. Co-Authored-By: Claude Fable 5 --- deploy/configs/sync-config-civitai.yaml | 32 ++++++++----- docs/design/trigger-sql-review.sql | 22 ++++----- src/pg_sync/sync_config.rs | 63 ++++++++++++++----------- src/pg_sync/trigger_gen.rs | 63 ++++++++++++++----------- 4 files changed, 102 insertions(+), 78 deletions(-) diff --git a/deploy/configs/sync-config-civitai.yaml b/deploy/configs/sync-config-civitai.yaml index 26269ee..753ddc5 100644 --- a/deploy/configs/sync-config-civitai.yaml +++ b/deploy/configs/sync-config-civitai.yaml @@ -237,13 +237,19 @@ triggers: slot_field: id sets_alive: true on_delete: true - # [PR-B4] PINNED UNIT STATEMENT (emission + query chains — see also civitai-index.yaml): - # EMISSION: sortAtUnix is emitted in MILLISECONDS (`* 1000`). The engine - # data_schema mapping `sortAtUnix -> sortAt` (civitai-index.yaml, - # ms_to_seconds:true) divides by 1000, so the sortAt SORT LAYERS store - # SECONDS. The dump path instead emits a `sortAt` column already in SECONDS - # (data_schema fallback: sortAt — ms_to_seconds is NOT applied to a - # fallback source). Both wire encodings converge on SECONDS in the layers. + # [PR-B4-v2] PINNED UNIT STATEMENT (emission + query chains — see also civitai-index.yaml): + # EMISSION: ops emit field `sortAt` (the TARGET name) in SECONDS. This was + # CORRECTED 2026-07-18: the original design emitted `sortAtUnix` in ms, + # assuming the data_schema `sortAtUnix -> sortAt` mapping applied to op + # field names — IT DOES NOT. data_schema renames apply to DOCUMENT keys + # (dump/insert path) only; op Set/Remove fields are resolved raw against + # filter/sort TARGET names (ops_processor.rs process_set_op), and unknown + # fields are SILENTLY IGNORED. Every other field always emitted target + # names — sortAtUnix was the one deviation, and its ops were dropped in + # prod for ~5h until caught by a single-op experiment. Target-name seconds + # is now the rule. The dormant sortAtUnix->sortAt doc mapping remains for + # the dump path only. The dump path emits a `sortAt` CSV column in SECONDS + # (doc path — mapping applies). Both paths converge on SECONDS everywhere. # QUERY: the Civitai builder sends `sortAtUnix Gte ` # (image.service.ts:3940). That clause is resolved by the time-bucket # manager (civitai-index.yaml time_buckets.filter_field: sortAtUnix, @@ -256,9 +262,9 @@ triggers: # divided back on ingest. The builder's seconds-Gte is CORRECT both before # this change (sortAt computed) and after (sortAt ingested). # - # sortAtUnix is emitted via the SHARED bitdex_image_sortat_ops() function so - # the W1-3 re-emitter re-asserts the identical value (shape parity). MS units - # (`* 1000`) with a COALESCE(NEW."sortAt", GREATEST(...)) shape — trusting + # sortAt is emitted via the SHARED bitdex_image_sortat_ops() function so + # the W1-3 re-emitter re-asserts the identical value (shape parity). SECONDS + # with a COALESCE(NEW."sortAt", GREATEST(...)) shape — trusting # NEW."sortAt" here is UNCHANGED from before the no-backfill decision and # stays COALESCE-shaped, because it is SAFE for a different reason than the # dump: the model-share `image_sort_at_before` BEFORE INSERT OR UPDATE @@ -272,8 +278,8 @@ triggers: # comment above: ops emission may trust the column ONLY because # image_sort_at_before recomputes it on every write; the dump must NEVER # trust it (92M pre-trigger rows are stale). Expression is byte-identical to - # the W1-1 codegen pin (trigger_gen.rs SORTATUNIX_EXPR). - shared_ops_fields: [sortAtUnix] + # the W1-1 codegen pin (trigger_gen.rs SORTAT_EXPR). + shared_ops_fields: [sortAt] track_fields: - nsfwLevel - { column: type, expression: "{type}::text" } @@ -284,7 +290,7 @@ triggers: - hash - width - height - - 'COALESCE(extract(epoch from {sortAt})::bigint, GREATEST(extract(epoch from (SELECT p."publishedAt" FROM "Post" p WHERE p.id = {postId}))::bigint, extract(epoch from {scannedAt})::bigint, extract(epoch from {createdAt})::bigint)) * 1000 as sortAtUnix' + - 'COALESCE(extract(epoch from {sortAt})::bigint, GREATEST(extract(epoch from (SELECT p."publishedAt" FROM "Post" p WHERE p.id = {postId}))::bigint, extract(epoch from {scannedAt})::bigint, extract(epoch from {createdAt})::bigint)) as sortAt' computed_fields: - target: hasMeta expression: "({flags} >> 13) & 1 = 1 AND ({flags} >> 2) & 1 = 0" diff --git a/docs/design/trigger-sql-review.sql b/docs/design/trigger-sql-review.sql index dabcc68..a1c8e7d 100644 --- a/docs/design/trigger-sql-review.sql +++ b/docs/design/trigger-sql-review.sql @@ -48,17 +48,17 @@ CREATE TRIGGER trg_cleanup_bitdex_ops -- Part 2: Per-table triggers (generated from sync config YAML) -- ----------------------------------------------------------------------- --- [1/8] Table: Image → Trigger: bitdex_image_ee936694 +-- [1/8] Table: Image → Trigger: bitdex_image_dda29cf8 -- Sets alive: yes -- On delete: emit delete op CREATE OR REPLACE FUNCTION bitdex_image_sortat_ops(_i "Image") RETURNS jsonb AS $$ SELECT jsonb_build_array( - jsonb_build_object('op', 'set', 'field', 'sortAtUnix', 'value', to_jsonb(COALESCE(extract(epoch from _i."sortAt")::bigint, GREATEST(extract(epoch from (SELECT p."publishedAt" FROM "Post" p WHERE p.id = _i."postId"))::bigint, extract(epoch from _i."scannedAt")::bigint, extract(epoch from _i."createdAt")::bigint)) * 1000)) + jsonb_build_object('op', 'set', 'field', 'sortAt', 'value', to_jsonb(COALESCE(extract(epoch from _i."sortAt")::bigint, GREATEST(extract(epoch from (SELECT p."publishedAt" FROM "Post" p WHERE p.id = _i."postId"))::bigint, extract(epoch from _i."scannedAt")::bigint, extract(epoch from _i."createdAt")::bigint)))) ); $$ LANGUAGE sql STABLE; -CREATE OR REPLACE FUNCTION bitdex_image_ops_ee936694() RETURNS trigger AS $$ +CREATE OR REPLACE FUNCTION bitdex_image_ops_dda29cf8() RETURNS trigger AS $$ DECLARE _ops jsonb; BEGIN @@ -146,10 +146,10 @@ BEGIN jsonb_build_object('op', 'set', 'field', 'height', 'value', to_jsonb(NEW."height")) ); END IF; - IF (COALESCE(extract(epoch from OLD."sortAt")::bigint, GREATEST(extract(epoch from (SELECT p."publishedAt" FROM "Post" p WHERE p.id = OLD."postId"))::bigint, extract(epoch from OLD."scannedAt")::bigint, extract(epoch from OLD."createdAt")::bigint)) * 1000) IS DISTINCT FROM (COALESCE(extract(epoch from NEW."sortAt")::bigint, GREATEST(extract(epoch from (SELECT p."publishedAt" FROM "Post" p WHERE p.id = NEW."postId"))::bigint, extract(epoch from NEW."scannedAt")::bigint, extract(epoch from NEW."createdAt")::bigint)) * 1000) THEN + IF (COALESCE(extract(epoch from OLD."sortAt")::bigint, GREATEST(extract(epoch from (SELECT p."publishedAt" FROM "Post" p WHERE p.id = OLD."postId"))::bigint, extract(epoch from OLD."scannedAt")::bigint, extract(epoch from OLD."createdAt")::bigint))) IS DISTINCT FROM (COALESCE(extract(epoch from NEW."sortAt")::bigint, GREATEST(extract(epoch from (SELECT p."publishedAt" FROM "Post" p WHERE p.id = NEW."postId"))::bigint, extract(epoch from NEW."scannedAt")::bigint, extract(epoch from NEW."createdAt")::bigint))) THEN _ops := _ops || jsonb_build_array( - jsonb_build_object('op', 'remove', 'field', 'sortAtUnix', 'value', to_jsonb(COALESCE(extract(epoch from OLD."sortAt")::bigint, GREATEST(extract(epoch from (SELECT p."publishedAt" FROM "Post" p WHERE p.id = OLD."postId"))::bigint, extract(epoch from OLD."scannedAt")::bigint, extract(epoch from OLD."createdAt")::bigint)) * 1000)), - jsonb_build_object('op', 'set', 'field', 'sortAtUnix', 'value', to_jsonb(COALESCE(extract(epoch from NEW."sortAt")::bigint, GREATEST(extract(epoch from (SELECT p."publishedAt" FROM "Post" p WHERE p.id = NEW."postId"))::bigint, extract(epoch from NEW."scannedAt")::bigint, extract(epoch from NEW."createdAt")::bigint)) * 1000)) + jsonb_build_object('op', 'remove', 'field', 'sortAt', 'value', to_jsonb(COALESCE(extract(epoch from OLD."sortAt")::bigint, GREATEST(extract(epoch from (SELECT p."publishedAt" FROM "Post" p WHERE p.id = OLD."postId"))::bigint, extract(epoch from OLD."scannedAt")::bigint, extract(epoch from OLD."createdAt")::bigint)))), + jsonb_build_object('op', 'set', 'field', 'sortAt', 'value', to_jsonb(COALESCE(extract(epoch from NEW."sortAt")::bigint, GREATEST(extract(epoch from (SELECT p."publishedAt" FROM "Post" p WHERE p.id = NEW."postId"))::bigint, extract(epoch from NEW."scannedAt")::bigint, extract(epoch from NEW."createdAt")::bigint)))) ); END IF; IF ((OLD."flags" >> 13) & 1 = 1 AND (OLD."flags" >> 2) & 1 = 0) IS DISTINCT FROM ((NEW."flags" >> 13) & 1 = 1 AND (NEW."flags" >> 2) & 1 = 0) THEN @@ -214,10 +214,10 @@ BEGIN END; $$ LANGUAGE plpgsql; -DROP TRIGGER IF EXISTS bitdex_image_ee936694 ON "Image"; -CREATE TRIGGER bitdex_image_ee936694 AFTER INSERT OR UPDATE OR DELETE ON "Image" - FOR EACH ROW EXECUTE FUNCTION bitdex_image_ops_ee936694(); -ALTER TABLE "Image" ENABLE ALWAYS TRIGGER bitdex_image_ee936694; +DROP TRIGGER IF EXISTS bitdex_image_dda29cf8 ON "Image"; +CREATE TRIGGER bitdex_image_dda29cf8 AFTER INSERT OR UPDATE OR DELETE ON "Image" + FOR EACH ROW EXECUTE FUNCTION bitdex_image_ops_dda29cf8(); +ALTER TABLE "Image" ENABLE ALWAYS TRIGGER bitdex_image_dda29cf8; -- [2/8] Table: TagsOnImageNew → Trigger: bitdex_tagsonimagenew_bcbef3c3 @@ -494,7 +494,7 @@ ALTER TABLE "Model" ENABLE ALWAYS TRIGGER bitdex_model_a13d0fe3; -- ----------------------------------------------------------------------- -- Tables created: BitdexOps, bitdex_cursors -- Triggers: 8 --- bitdex_image_ee936694 on "Image" +-- bitdex_image_dda29cf8 on "Image" -- bitdex_tagsonimagenew_bcbef3c3 on "TagsOnImageNew" -- bitdex_imagetool_f87e1fc4 on "ImageTool" -- bitdex_imagetechnique_ee2b2860 on "ImageTechnique" diff --git a/src/pg_sync/sync_config.rs b/src/pg_sync/sync_config.rs index a7b786c..b0373f6 100644 --- a/src/pg_sync/sync_config.rs +++ b/src/pg_sync/sync_config.rs @@ -840,7 +840,7 @@ triggers: [] /// [W1-1 review minor 3 / PR-m2] Disjointness on the REAL deploy config. /// - /// The load-bearing invariant is that the new `sortAtUnix` writer (the Image + /// The load-bearing invariant is that the new `sortAt` writer (the Image /// trigger's shared function) and the Post per-image fan-out never emit the /// SAME field for one image — an overlap would let op_dedup LIFO-resolve the /// two writers nondeterministically. @@ -851,9 +851,9 @@ triggers: [] /// safe: the two writers fire for temporally-separated events and resolve to /// the SAME value (both read Post.publishedAt), so LIFO order is immaterial. /// This test pins that the overlap is EXACTLY that documented set and that - /// `sortAtUnix` (and `model3dId`) are strictly disjoint. + /// `sortAt` (and `model3dId`) are strictly disjoint. #[test] - fn test_post_fanout_disjoint_from_sortatunix() { + fn test_post_fanout_disjoint_from_sortat() { use crate::pg_sync::trigger_gen::generate_trigger_sql; let config = load_deploy_sync_config(); @@ -873,16 +873,20 @@ triggers: [] assert!(!post_fields.is_empty() && !image_fields.is_empty()); // Post fan-out emits exactly the retained payload (PR-M1) — and crucially - // NOT sortAtUnix. + // NOT sortAt. let expected_post: std::collections::HashSet = ["publishedAt", "availability", "postedToId"].iter().map(|s| s.to_string()).collect(); assert_eq!(post_fields, expected_post, "Post fan-out must emit exactly {{publishedAt, availability, postedToId}}, got {post_fields:?}"); - // sortAtUnix is emitted by the Image trigger and is DISJOINT from Post. - assert!(image_fields.contains("sortAtUnix"), "Image trigger must emit sortAtUnix"); - assert!(!post_fields.contains("sortAtUnix"), - "Post fan-out must NOT emit sortAtUnix — it is the Image trigger's field"); + // sortAt is emitted by the Image trigger (TARGET name, seconds — the + // 2026-07-18 correction) and is DISJOINT from Post. + assert!(image_fields.contains("sortAt"), "Image trigger must emit sortAt"); + assert!(!image_fields.contains("sortAtUnix"), + "Image trigger must NOT emit the source name sortAtUnix — op fields \ + resolve against TARGET names only; source-name ops are silently dropped"); + assert!(!post_fields.contains("sortAt"), + "Post fan-out must NOT emit sortAt — it is the Image trigger's field"); // model3dId likewise Image-only (INSERT-read, not carried on the fan-out). assert!(image_fields.contains("model3dId"), "Image trigger must emit model3dId"); assert!(!post_fields.contains("model3dId"), "Post fan-out must NOT emit model3dId"); @@ -893,28 +897,32 @@ triggers: [] assert_eq!(overlap, expected_post, "the only Post∩Image overlap must be the intentional Mode-B set \ {{publishedAt, availability, postedToId}}; any other overlap (esp. \ - sortAtUnix) would be op_dedup-nondeterministic. Got {overlap:?}"); + sortAt) would be op_dedup-nondeterministic. Got {overlap:?}"); } - /// [PR-B4 emission side / AR-4] The Image trigger emits sortAtUnix in - /// MILLISECONDS via the shared function, on the REAL deploy config. Seconds - /// here would shrink every value 1000× once the engine's ms_to_seconds - /// mapping divides by 1000. + /// [PR-B4-v2 emission side] The Image trigger emits the TARGET field name + /// `sortAt` in SECONDS via the shared function, on the REAL deploy config. + /// CORRECTED 2026-07-18: the original sortAtUnix/ms emission was silently + /// dropped by the engine — op field names resolve against filter/sort TARGET + /// names only (data_schema renames are doc-path only), and unknown op fields + /// are ignored without error. #[test] - fn test_image_sortatunix_emitted_ms_on_deploy_config() { + fn test_image_sortat_emitted_target_name_seconds_on_deploy_config() { use crate::pg_sync::trigger_gen::generate_trigger_sql; let config = load_deploy_sync_config(); let image = config.triggers.iter().find(|t| t.table == "Image") .expect("Image trigger must exist"); - assert_eq!(image.shared_ops_fields.as_deref(), Some(&["sortAtUnix".to_string()][..]), - "Image must factor sortAtUnix into the shared re-emitter function"); + assert_eq!(image.shared_ops_fields.as_deref(), Some(&["sortAt".to_string()][..]), + "Image must factor sortAt into the shared re-emitter function"); let sql = generate_trigger_sql(image); - assert!(sql.contains("* 1000"), "sortAtUnix must be milliseconds:\n{sql}"); + assert!(!sql.contains("* 1000"), "sortAt must be SECONDS, not ms:\n{sql}"); assert!(sql.contains("_ops := _ops || bitdex_image_sortat_ops(NEW);"), "INSERT must call the shared sortat ops function (re-emitter parity):\n{sql}"); - assert!(emitted_trigger_fields(&sql).contains("sortAtUnix")); + assert!(emitted_trigger_fields(&sql).contains("sortAt")); + assert!(!emitted_trigger_fields(&sql).contains("sortAtUnix"), + "source-name emission would be silently dropped by the engine"); } /// [PR-B4 mapping side + PR-M5] Index config: sortAt is INGESTED not computed, @@ -1029,22 +1037,23 @@ triggers: [] assert!(images.fields.iter().any(|f| f.target() == "sortAt"), "images phase fields must map sortAt"); - // PINNED PAIRING: the ops trigger's sortAtUnix expression is UNCHANGED - // and stays COALESCE-shaped — trusting the column there is a *different* + // PINNED PAIRING: the ops trigger's sortAt expression stays + // COALESCE-shaped — trusting the column there is a *different* // safety argument (image_sort_at_before recomputes on every write), not - // a relaxation of the dump's no-trust rule. + // a relaxation of the dump's no-trust rule. (2026-07-18: emission field + // renamed sortAtUnix -> sortAt / ms -> seconds; the pairing is unchanged.) let image_trigger = config.triggers.iter().find(|t| t.table == "Image") .expect("Image trigger must exist"); - let sortatunix_track = image_trigger.track_fields.as_deref().unwrap_or(&[]) + let sortat_track = image_trigger.track_fields.as_deref().unwrap_or(&[]) .iter() .map(|tf| tf.to_track_string()) - .find(|s| s.contains("sortAtUnix")) - .expect("Image trigger must have a sortAtUnix track field"); + .find(|s| s.contains("as sortAt")) + .expect("Image trigger must have a sortAt track field"); assert!( - sortatunix_track.to_uppercase().contains("COALESCE"), - "ops emission sortAtUnix expression must remain COALESCE-shaped \ + sortat_track.to_uppercase().contains("COALESCE"), + "ops emission sortAt expression must remain COALESCE-shaped \ (safe via image_sort_at_before recompute-on-write, unlike the \ - dump):\n{sortatunix_track}" + dump):\n{sortat_track}" ); } } diff --git a/src/pg_sync/trigger_gen.rs b/src/pg_sync/trigger_gen.rs index 76df2ed..92986b8 100644 --- a/src/pg_sync/trigger_gen.rs +++ b/src/pg_sync/trigger_gen.rs @@ -1318,14 +1318,20 @@ sync_sources: // W1-1: fan_out_per_row (per-image materialized Post fan-out) + sortAtUnix // ---------------------------------------------------------------------- - /// The exact sortAtUnix track-field expression W2-1 wires onto the Image - /// trigger. Emits **milliseconds** (`* 1000`) with a COALESCE(GREATEST...) - /// belt so rows the PG backfill hasn't reached still emit a correct value. - /// The engine's `sortAtUnix → sortAt` mapping (ms_to_seconds) divides by 1000. - const SORTATUNIX_EXPR: &str = "COALESCE(extract(epoch from {sortAt})::bigint, \ + /// The exact sortAt track-field expression W2-1 wires onto the Image + /// trigger. Emits the TARGET field name `sortAt` in **seconds**, with a + /// COALESCE(GREATEST...) belt so rows the PG backfill hasn't reached still + /// emit a correct value. + /// + /// CORRECTED 2026-07-18 (was `sortAtUnix` in milliseconds): data_schema + /// source→target renames apply to DOCUMENT keys only, never to op field + /// names — ops resolve raw against filter/sort TARGET names and unknown + /// fields are silently ignored (ops_processor.rs process_set_op). Emitting + /// the source name meant every steady-state sortAt op was dropped in prod. + const SORTAT_EXPR: &str = "COALESCE(extract(epoch from {sortAt})::bigint, \ GREATEST(extract(epoch from (SELECT p.\"publishedAt\" FROM \"Post\" p WHERE p.id = {postId}))::bigint, \ extract(epoch from {scannedAt})::bigint, \ -extract(epoch from {createdAt})::bigint)) * 1000 as sortAtUnix"; +extract(epoch from {createdAt})::bigint)) as sortAt"; /// Build the Post per-image fan-out source, retaining the FULL payload /// {publishedAt, availability, postedToId} exactly as the current queryOpSet @@ -1598,21 +1604,22 @@ sync_sources: assert!(!sql.contains("queryOpSet"), "{sql}"); } - /// #3 + [AR-4] units: the Image sortAtUnix track field emits MILLISECONDS - /// (`* 1000`) with a COALESCE(GREATEST...) fallback, and diffs on UPDATE. - /// The engine mapping divides by 1000 — seconds here would shrink every - /// value 1000×. Pins the whole chain at codegen. - /// Build the Image source with the sortAtUnix track field factored into + /// [AR-4-v2] units: the Image sortAt track field emits the TARGET name + /// `sortAt` in SECONDS with a COALESCE(GREATEST...) fallback, and diffs on + /// UPDATE. CORRECTED 2026-07-18: source-name (`sortAtUnix`, ms) emission was + /// silently dropped by the engine — op fields resolve against TARGET names + /// only; data_schema renames are doc-path only. Pins the chain at codegen. + /// Build the Image source with the sortAt track field factored into /// the shared `bitdex_image_sortat_ops` function (production shape: the - /// re-emitter calls this exact function to re-assert sortAtUnix). + /// re-emitter calls this exact function to re-assert sortAt). fn image_source_with_sortat() -> SyncSource { SyncSource { slot_field: Some("id".into()), track_fields: Some(vec![ TrackField::Simple("nsfwLevel".into()), - TrackField::Simple(SORTATUNIX_EXPR.into()), + TrackField::Simple(SORTAT_EXPR.into()), ]), - shared_ops_fields: Some(vec!["sortAtUnix".into()]), + shared_ops_fields: Some(vec!["sortAt".into()]), sets_alive: true, on_delete: Some(OnDeleteValue::String("delete_slot".into())), ..test_source("Image") @@ -1620,14 +1627,16 @@ sync_sources: } #[test] - fn test_image_sortatunix_ms_and_coalesce() { + fn test_image_sortat_target_name_seconds_and_coalesce() { let source = image_source_with_sortat(); let sql = generate_trigger_sql(&source); - // Emitted under the sortAtUnix target (the dormant sortAtUnix → sortAt mapping). - assert!(emitted_fields(&sql).contains("sortAtUnix"), "{sql}"); - // Milliseconds — not seconds. - assert!(sql.contains("* 1000"), "sortAtUnix must be milliseconds:\n{sql}"); + // Emitted under the TARGET field name `sortAt` — op fields resolve + // against target names; data_schema renames are doc-path only. + assert!(emitted_fields(&sql).contains("sortAt"), "{sql}"); + assert!(!emitted_fields(&sql).contains("sortAtUnix"), "must NOT emit the source name:\n{sql}"); + // Seconds — the engine stores sort layers in seconds; no ms conversion. + assert!(!sql.contains("* 1000"), "sortAt must be seconds, not ms:\n{sql}"); // INSERT branch: the shared function is called with NEW rather than the // expression being inlined a second time. assert!( @@ -1660,7 +1669,7 @@ sync_sources: ); } - /// W1-3 parity for sortAtUnix, same pattern as the Post fan-out's parity + /// W1-3 parity for sortAt, same pattern as the Post fan-out's parity /// test: prove the re-emitter's call target is the SAME function the /// trigger installs, not a name/expression it reimplements and could drift /// from. @@ -1671,16 +1680,16 @@ sync_sources: // Stable, unhashed name — same convention as bitdex_post_fanout_ops, // derived from the wire field name with the Unix suffix stripped. assert_eq!( - shared_field_ops_function_name("Image", "sortAtUnix"), + shared_field_ops_function_name("Image", "sortAt"), "bitdex_image_sortat_ops" ); - let fn_sql = generate_shared_field_ops_function_sql(&source, "sortAtUnix") - .expect("sortAtUnix is tracked, must resolve"); + let fn_sql = generate_shared_field_ops_function_sql(&source, "sortAt") + .expect("sortAt is tracked, must resolve"); assert!(fn_sql.contains(r#"CREATE OR REPLACE FUNCTION bitdex_image_sortat_ops(_i "Image") RETURNS jsonb"#), "{fn_sql}"); assert!(fn_sql.contains("LANGUAGE sql STABLE"), "{fn_sql}"); - assert!(fn_sql.contains("'field', 'sortAtUnix'"), "{fn_sql}"); - assert!(fn_sql.contains("* 1000"), "shared function must preserve ms units:\n{fn_sql}"); + assert!(fn_sql.contains("'field', 'sortAt'"), "{fn_sql}"); + assert!(!fn_sql.contains("* 1000"), "shared function must emit seconds:\n{fn_sql}"); // REAL parity: the function embedded in the generated trigger SQL is // byte-identical to the standalone generator's output. @@ -1693,7 +1702,7 @@ sync_sources: assert!( trigger_sql.contains(&format!( "_ops := _ops || {}(NEW);", - shared_field_ops_function_name("Image", "sortAtUnix"), + shared_field_ops_function_name("Image", "sortAt"), )), "trigger INSERT path must call the function by its helper-derived name:\n{trigger_sql}" ); @@ -1730,7 +1739,7 @@ sync_sources: TrackField::Simple("nsfwLevel".into()), TrackField::Simple("{type}::text as type".into()), TrackField::Simple("postId".into()), - TrackField::Simple(SORTATUNIX_EXPR.into()), + TrackField::Simple(SORTAT_EXPR.into()), ]), ..image_source_with_sortat() };