fix(server,flutter): sync wire format + systemVariant column (#357)

Two fixes in one commit because they're entangled — the systemVariant
work would have been theater otherwise.

## The wire-format bug

/api/library/sync was emitting PascalCase JSON for artist / album /
track / playlist upserts (raw json.Marshal of sqlc-generated structs
with no JSON tags — sqlc.yaml: emit_json_tags=false). Flutter's
sync_controller _*FromJson reads snake_case keys, so all metadata
sync rows landed in drift with empty strings / zero ints.

The like_track / like_album / like_artist / playlist_track entities
work because they're hand-built `map[string]string` payloads with
snake_case keys — they sidestepped the bug. The 4 raw-marshal
entities did not.

Existing sync test caught zero of this — it asserts on len(upserts)
not field shape.

Fix: server-side view structs in library_sync_views.go with proper
JSON tags + pgtype-flattening (UUID → 8-4-4-4-12 hex string,
Date → "2006-01-02"). Mirrors the playlistRowView pattern from
/api/playlists. New library_sync_views_test.go pins the wire keys
so future field-name drift breaks loud.

## systemVariant column (closes #357 plan C v1 limitation)

playlistSyncView now carries `system_variant` server → wire.

Flutter drift schema bumped from 1 → 2 with onUpgrade adding the
`systemVariant TEXT NULL` column to cached_playlists. Cursor reset
to 0 in the migration so existing rows refresh with the new field
on the next sync.

playlistsListProvider now filters locally by systemVariant:
- kind='user'   → systemVariant IS NULL  (the add-to-playlist sheet's intent)
- kind='system' → systemVariant IS NOT NULL
- kind='all'    → no filter

Closes the documented v1 limitation where the add-to-playlist sheet
showed system playlists alongside user-created ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 18:47:30 -04:00
parent 0db7054518
commit 27f123f7d9
7 changed files with 268 additions and 14 deletions
@@ -17,12 +17,15 @@ final playlistsApiProvider = FutureProvider<PlaylistsApi>((ref) async {
return PlaylistsApi(await ref.watch(dioProvider.future));
});
/// Drift-first per #357 plan C. Returns all cached playlists for the
/// current user (owned) + public playlists from others. The `kind`
/// family arg only affects the REST cold-cache fetch shape — drift
/// can't distinguish 'user' from 'system' kind because systemVariant
/// isn't persisted (TODO: add column + schema bump in a follow-up).
/// For v1 this means add-to-playlist sheet shows system playlists too.
/// Drift-first per #357 plan C. Returns cached playlists filtered to
/// match the `kind` family arg:
/// - 'user' → only user-created (systemVariant null), owned by current user
/// - 'system' → only system-generated (systemVariant non-null), owned
/// - 'all' → everything: own user playlists + own system + others' public
///
/// systemVariant column added in drift schema v2 (#357 follow-up); previous
/// behavior leaked system playlists into add-to-playlist sheet because we
/// couldn't filter locally.
final playlistsListProvider =
StreamProvider.family<PlaylistsList, String>((ref, kind) {
final db = ref.watch(appDbProvider);
@@ -42,11 +45,16 @@ final playlistsListProvider =
},
toResult: (rows) {
if (user == null) return PlaylistsList.empty();
final owned = rows
final filtered = rows.where((r) {
if (kind == 'user') return r.systemVariant == null;
if (kind == 'system') return r.systemVariant != null;
return true; // 'all'
});
final owned = filtered
.where((r) => r.userId == user.id)
.map((r) => r.toRef())
.toList();
final pub = rows
final pub = filtered
.where((r) => r.userId != user.id && r.isPublic)
.map((r) => r.toRef())
.toList();