feat(apply): server-side materialisation of desktop sync ops (phase 1.g.0) - #26
Conversation
….g.0) Until now `sync_op` was append-only: the desktop pushed ops, the server stored them, but no consumer materialised them into the entity tables — so a desktop-created playlist was invisible to playlist sharing, library APIs, web views, and any other surface that reads from `playlist`/`library`/etc. This PR closes that gap with a synchronous apply pipeline that runs in the same transaction as the durable insert. ## Migration `20260604000000_apply_pipeline.sql` adds: - `profile.canonical_id`, `library.canonical_id`, `playlist.canonical_id` — partial-unique-indexed TEXT columns scoped to the parent tenant. Legacy server-created rows keep working (canonical_id NULL). - `track.file_hash` — non-unique index. Server has no track sync yet; the column is there for the future join with rating / liked tables. - `sync_op.profile_canonical_id` — routing key for apply. Legacy ops without it stay in the durable log but skip apply. - `user_liked_track(user_id, file_hash, liked_at)` — file-hash keyed so liked ops can land even before a corresponding track row exists. - `user_track_rating(user_id, file_hash, rating, updated_at)` — same shape as `track.rating` (raw POPM byte) so a future track sync can backfill via INNER JOIN on file_hash. Explicitly NOT included: `playlist_track` materialisation. Desktop emits these ops with local BIGINT track ids that have no meaning on the server. Until desktop emits file_hash refs + track sync ships, the apply path logs them as Skipped. ## Apply pipeline (`src/apply.rs`) `apply_op(conn, user_id, op, now) -> Result<ApplyOutcome, ApplyError>` is called from `api::sync::push_ops` right after each freshly-inserted log row, inside the same transaction. A failure rolls the log row back too — better to refuse a push than to leave an op the server can't honour. Routing: - `playlist` / `library` ops — resolve `profile_canonical_id` to a server `profile.id` via `find_or_provision` (read-first, UPSERT on miss, race-safe), then dispatch to the per-entity module. - `liked_track` / `track_rating` — keyed on `(user_id, file_hash)`; no profile lookup needed. - Anything else — `Skipped` (recognised + unsupported) or `Unknown` (forward-compat). Durable log always retains the row. ## Tests (`tests/apply.rs`) 11 integration tests covering the contract: - playlist / library insert materialises a row with the right defaults and canonical_id mapping - replay of the same operation_id is idempotent (one playlist row) - `set name` updates the field - `delete` removes the row - rating set persists; rating out-of-range short-circuits with rollback; rating delete clears - liked insert / delete round-trip - missing profile_canonical_id keeps the durable row but skips apply - two distinct profile_canonical_ids land in two distinct server profiles - tenant isolation: user A's apply doesn't bleed into user B's profile / playlist counts Signed-off-by: InstaZDLL <github.105mh@8shield.net>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughCe PR ajoute un pipeline server-side apply : migration DB pour canonical/file_hash, extension du protocole ( ChangesPipeline Apply Phase 1.g
Sequence DiagramssequenceDiagram
participant Client
participant PushOps as push_ops handler
participant DB as Postgres
participant Apply as apply_op
Client->>PushOps: POST /api/v1/sync/ops (SyncOpIn)
PushOps->>DB: INSERT INTO sync_op RETURNING *
DB-->>PushOps: row (incl. profile_canonical_id)
PushOps->>PushOps: row_to_op -> SyncOp
PushOps->>Apply: apply_op(user_id, SyncOp)
Apply->>DB: SELECT profile WHERE canonical_id=...
DB-->>Apply: profile.id (or none)
Apply->>DB: INSERT/UPDATE/DELETE entity tables (playlist/library/user_liked_track/user_track_rating)
alt Apply success
DB-->>Apply: OK
Apply-->>PushOps: ApplyOutcome::Applied
PushOps->>DB: COMMIT
PushOps-->>Client: 200 Accepted
else Apply failure
Apply-->>PushOps: ApplyError
PushOps->>DB: ROLLBACK
PushOps-->>Client: 500 Internal Error
end
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly Related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Signed-off-by: InstaZDLL <github.105mh@8shield.net>
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/apply.rs`:
- Around line 212-217: La fonction payload_optional_string silencieusement
convertit un type invalide en None; changez sa signature et son comportement
pour renvoyer Result<Option<String>, InvalidPayload> (ou le type d'erreur
existant, ex. ApplyError::InvalidPayload) et validez explicitement la
présence/valeur: si payload.get(key) est None => Ok(None); si c'est Value::Null
=> Ok(None) (ou l'équivalent pour un null explicite selon le contexte d'appel);
si c'est une string => Ok(Some(string.to_owned())); sinon => Err(InvalidPayload{
key, expected: "string | null" }); modifiez les appels existants de
payload_optional_string pour propager l'erreur (utiliser ?), en faisant
référence à la fonction payload_optional_string et au type d'erreur
InvalidPayload/ApplyError dans votre code.
In `@tests/apply.rs`:
- Around line 122-128: Replace the unnecessary clone when passing a single-item
slice to push: change the call that currently builds a slice with
&[body.clone()] to use std::slice::from_ref(&body) so you pass a &[BodyType]
without cloning; update both push(&auth.base, &auth.token, &[body.clone()]) and
the later duplicate to push(&auth.base, &auth.token,
std::slice::from_ref(&body)) (referencing the push function and the local body
variable to locate the sites).
- Around line 22-47: La fonction de test op dépasse la limite de paramètres de
Clippy (8 args) ; corrige en choisissant soit créer un petit builder struct
(p.ex. OpBuilder avec méthodes chainables build() pour produire la Value) et
remplacer les appels à op par OpBuilder::new(...)..., soit simplement ajouter
#[allow(clippy::too_many_arguments)] directement au-dessus de la définition de
la fonction op pour désactiver l'avertissement de Clippy pour cet helper de test
; faites la modification sur la fonction nommée op et/ou introduisez OpBuilder
si vous préférez refactoriser.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: e5e58ebb-fc4e-49bb-abeb-58ebd37d5d98
📒 Files selected for processing (7)
migrations/20260604000000_apply_pipeline.sqlsrc/api/sync.rssrc/apply.rssrc/db.rssrc/lib.rssrc/sync.rstests/apply.rs
Three findings from CI + CodeRabbit on PR #26: payload_optional_string was silently coercing a non-string, non-null value (e.g. a number) to None — turning a desktop bug into a "clear this field" UPDATE on the server. Changed the signature to return Result<Option<String>, ApplyError> with an explicit InvalidPayload error for the typed-mismatch case, and propagated `?` to every call site (playlist + library insert / set_field). Null and absent stay valid "clear" signals. Clippy too_many_arguments on the test op() helper — folding 8 args into a builder would obscure call sites that read like a wire-format dump. #[allow] locally with a comment explaining the trade-off. Clippy cloned_ref_to_slice_refs on &[body.clone()] in the replay test — swapped to std::slice::from_ref(&body) on both calls. Plus a new test pinning the typed-payload error path so a future regression on payload_optional_string surfaces as a failed test instead of as a silently-cleared description column. Signed-off-by: InstaZDLL <github.105mh@8shield.net>
|
Round 1 CR addressed in efd8e3a. 1. Clippy 2. Clippy 3. @coderabbitai
Propagated Local validation: |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check the state of apply.rs after the fixes, focusing on payload_optional_string
gh pr view 26 --repo InstaZDLL/waveflow-server --json headRefName,headRefOid
git show efd8e3a --stat 2>/dev/null || echo "commit not found directly, checking branch"
# Show the current payload_optional_string implementation
rg -n "payload_optional_string" --type rust -A 10Length of output: 7043 🏁 Script executed: #!/bin/bash
# Check the new test and the fixed implementation
rg -n "playlist_insert_with_wrong_type" --type rust -A 20
rg -n "fn payload_optional_string" --type rust -A 20Length of output: 2309 🏁 Script executed: #!/bin/bash
# Check the rollback assertion in the new test
rg -n "playlist_insert_with_wrong_type" --type rust -A 35Length of output: 2149
LGTM. (╯°□°)╯ 🐇 ✓ 🧠 Learnings used |
|
@coderabbitai review |
✅ Action performedReview finished.
|
Summary
Until now `sync_op` was append-only: the desktop pushed ops, the server stored them, but no consumer materialised them into the entity tables. This PR closes that gap with a synchronous apply pipeline that runs in the same transaction as the durable insert.
Why now: Phase 1.g.2 (web share preview) is merged, but desktop-created playlists are invisible to the share endpoint because they live only in `sync_op` log. This PR unblocks 1.g.3 desktop sharing.
What lands
Migration `20260604000000_apply_pipeline.sql`
NOT included: `playlist_track` materialisation. Desktop emits local BIGINT track ids that have no meaning server-side. Pending desktop emitting file_hash refs.
Apply pipeline (`src/apply.rs`)
Tests (`tests/apply.rs`)
11 integration tests covering the contract:
Wire shape change
`SyncOpIn` and `SyncOp` gain a `profile_canonical_id: Option` field. Legacy clients (no field) still push successfully — durable log stores NULL, apply skips. Forward-only protocol bump.
Test plan
Follow-ups (deferred to keep this PR reviewable)
Summary by CodeRabbit
Nouvelles fonctionnalités
Corrections
Tests