feat(caldav): mig 101 — multi-calendar binding schema + backfill (t-paliad-212 Slice 1)
Schema-only landing for Slice 1 of the CalDAV multi-calendar design
(docs/design-caldav-multi-calendar-2026-05-19.md). Sync engine NOT
touched — Slice 2 wires the per-binding fan-out. After this migration:
- paliad.user_calendar_bindings — N bindings per user with scope_kind
∈ {all_visible, personal_only, project, client, litigation, patent,
case}. Hierarchy scopes anchor scope_id at paliad.projects(id).
Partial unique indexes enforce one binding per (user, scope_kind,
scope_id) for hierarchical scopes and one per (user, scope_kind)
for the scope-less roots. RLS mirrors user_caldav_config.
- paliad.appointment_caldav_targets — per-(appointment, binding) join
carrying caldav_uid + caldav_etag. UID stays canonical per
appointment so the same event in N cals shares one UID.
- Backfill — one all_visible binding per existing user_caldav_config
row, one target row per appointment already pushed. Maps target to
the creator's binding, matching today's Phase F semantics where the
creator's goroutine owns the etag.
Legacy paliad.appointments.caldav_uid / caldav_etag columns are
untouched (kept as denormalised pointers through Slice 1+2; dropped
in Slice 4 after telemetry).
Dry-run verified against live Supabase (PG 15.8): synthetic config +
appointment backfill creates exactly 1 binding + 1 target; re-run is a
no-op; all CHECK + unique-index constraints enforce as designed; final
assertions pass with 0 missing rows.
Prod impact at landing: 0 rows in user_caldav_config and 0 appointments
with caldav_uid — backfill is a true no-op. Slice 1 ships invisible.
This commit is contained in:
13
internal/db/migrations/101_caldav_multi_calendar.down.sql
Normal file
13
internal/db/migrations/101_caldav_multi_calendar.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Reverse of 101_caldav_multi_calendar.up.sql.
|
||||
--
|
||||
-- Drop the new join + binding tables. CASCADE on the FK references
|
||||
-- isn't needed because we drop targets before bindings, and Postgres
|
||||
-- handles RLS policies / indexes automatically on DROP TABLE.
|
||||
--
|
||||
-- The legacy paliad.appointments.caldav_uid / caldav_etag columns are
|
||||
-- untouched by the up migration, so they're untouched here too —
|
||||
-- rollback returns the system to the pre-Slice-1 state where those
|
||||
-- scalars are the single source of CalDAV truth.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.appointment_caldav_targets;
|
||||
DROP TABLE IF EXISTS paliad.user_calendar_bindings;
|
||||
350
internal/db/migrations/101_caldav_multi_calendar.up.sql
Normal file
350
internal/db/migrations/101_caldav_multi_calendar.up.sql
Normal file
@@ -0,0 +1,350 @@
|
||||
-- t-paliad-212 — Slice 1 of the CalDAV multi-calendar design (see
|
||||
-- docs/design-caldav-multi-calendar-2026-05-19.md). Pure schema +
|
||||
-- backfill; the sync engine is NOT touched in this migration. Slice 2
|
||||
-- wires the per-binding fan-out.
|
||||
--
|
||||
-- What we add:
|
||||
-- 1. paliad.user_calendar_bindings — N bindings per user, each with
|
||||
-- a scope_kind enum (all_visible / personal_only / project /
|
||||
-- client / litigation / patent / case) and an optional scope_id
|
||||
-- pointing at a paliad.projects row when the scope is hierarchy-
|
||||
-- anchored. The same Appointment can be PUT into multiple of
|
||||
-- these bindings (e.g. master cal + per-project cal).
|
||||
-- 2. paliad.appointment_caldav_targets — (appointment_id, binding_id)
|
||||
-- join carrying the per-target caldav_uid + caldav_etag. The
|
||||
-- canonical UID is still per-appointment (paliad-appointment-
|
||||
-- <uuid>@paliad.de) so the same event in N cals shares one UID.
|
||||
-- 3. Backfill: one all_visible binding per existing
|
||||
-- user_caldav_config row, plus one target row per Appointment
|
||||
-- already pushed (caldav_uid IS NOT NULL). Backfill maps the
|
||||
-- target's binding_id to the appointment creator's binding —
|
||||
-- that matches today's Phase F semantics, where the creator's
|
||||
-- sync goroutine owns the etag.
|
||||
--
|
||||
-- The scalar columns paliad.appointments.caldav_uid / caldav_etag
|
||||
-- STAY in place through Slice 1 and Slice 2. Slice 1 keeps them as
|
||||
-- read-once denormalised pointers to the default binding's target
|
||||
-- row; Slice 4 drops them after telemetry confirms no path still
|
||||
-- reads them.
|
||||
--
|
||||
-- Idempotent: every CREATE uses IF NOT EXISTS, both backfills are
|
||||
-- guarded by NOT EXISTS. Safe to re-run.
|
||||
--
|
||||
-- audit_reason set_config required at the top because m's recent
|
||||
-- migration friction had several mig failures from missing reasons.
|
||||
-- The trigger raising 'audit reason required' is on
|
||||
-- paliad.deadline_rules only — this migration doesn't touch that
|
||||
-- table — but we set the reason for symmetry per paliadin's 2026-05-19
|
||||
-- coder-shift brief.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 101: CalDAV multi-calendar schema + backfill (Slice 1 of t-paliad-212; design doc docs/design-caldav-multi-calendar-2026-05-19.md). No row mutations on existing trigger-guarded tables; this is a defensive symmetry set_config.',
|
||||
true);
|
||||
|
||||
-- =========================================================================
|
||||
-- 1. paliad.user_calendar_bindings
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.user_calendar_bindings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
|
||||
-- Full URL or path under user_caldav_config.url. The CalDAV client
|
||||
-- resolves it against the user's server URL the same way it
|
||||
-- resolves the legacy user_caldav_config.calendar_path today.
|
||||
calendar_path text NOT NULL,
|
||||
|
||||
-- What the picker UI shows for this binding. Discovered via
|
||||
-- PROPFIND <displayname/> at add-time and cached here so we don't
|
||||
-- re-fetch every render. Default '' (Slice 1 backfill leaves it
|
||||
-- empty; Slice 2 fills it during the picker flow).
|
||||
display_name text NOT NULL DEFAULT '',
|
||||
|
||||
-- Which appointments push into this calendar. Slice 1 only really
|
||||
-- needs 'all_visible' (that's all the backfill creates) but we
|
||||
-- ship the full enum now so the schema is final and Slice 2/3
|
||||
-- don't have to ALTER it.
|
||||
scope_kind text NOT NULL,
|
||||
scope_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
|
||||
-- Only meaningful when scope_kind is hierarchy-anchored
|
||||
-- (project / client / litigation / patent / case). When true,
|
||||
-- the binding ALSO receives the user's personal (project_id IS
|
||||
-- NULL AND created_by = user_id) appointments. Ignored for
|
||||
-- 'all_visible' (already includes them) and 'personal_only'.
|
||||
include_personal boolean NOT NULL DEFAULT false,
|
||||
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
last_sync_at timestamptz,
|
||||
last_sync_error text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT user_calendar_bindings_scope_kind_chk CHECK (
|
||||
scope_kind IN ('all_visible','personal_only','project','client','litigation','patent','case')
|
||||
),
|
||||
CONSTRAINT user_calendar_bindings_scope_id_chk CHECK (
|
||||
(scope_kind IN ('all_visible','personal_only') AND scope_id IS NULL)
|
||||
OR
|
||||
(scope_kind NOT IN ('all_visible','personal_only') AND scope_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- One binding per (user, calendar) — can't bind the same external
|
||||
-- calendar twice for the same user.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_user_path_uniq
|
||||
ON paliad.user_calendar_bindings (user_id, calendar_path);
|
||||
|
||||
-- One hierarchy binding per (user, scope_kind, scope_id) — a user
|
||||
-- can't have two bindings for the same project, but CAN have a
|
||||
-- 'project' binding for project X alongside an 'all_visible'
|
||||
-- master binding (different scope_kind ⇒ different row).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_hier_uniq
|
||||
ON paliad.user_calendar_bindings (user_id, scope_kind, scope_id)
|
||||
WHERE scope_id IS NOT NULL;
|
||||
|
||||
-- One scope-less binding per (user, scope_kind) — at most one
|
||||
-- 'all_visible' and one 'personal_only' per user.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_root_uniq
|
||||
ON paliad.user_calendar_bindings (user_id, scope_kind)
|
||||
WHERE scope_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_calendar_bindings_user_idx
|
||||
ON paliad.user_calendar_bindings (user_id)
|
||||
WHERE enabled;
|
||||
|
||||
-- No updated_at trigger — paliad.user_caldav_config also doesn't have
|
||||
-- one. The Go service layer sets updated_at = NOW() explicitly on
|
||||
-- every write (see SaveConfig in caldav_service.go); we follow the
|
||||
-- same convention here so all CalDAV-related tables are consistent.
|
||||
|
||||
ALTER TABLE paliad.user_calendar_bindings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Same shape as user_caldav_config policies: a user sees + mutates
|
||||
-- only their own rows. auth.uid() returns the authenticated user's
|
||||
-- id (mirrors auth.uid()).
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_select ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_select ON paliad.user_calendar_bindings
|
||||
FOR SELECT TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_insert ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_insert ON paliad.user_calendar_bindings
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_update ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_update ON paliad.user_calendar_bindings
|
||||
FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
DROP POLICY IF EXISTS user_calendar_bindings_self_delete ON paliad.user_calendar_bindings;
|
||||
CREATE POLICY user_calendar_bindings_self_delete ON paliad.user_calendar_bindings
|
||||
FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 2. paliad.appointment_caldav_targets
|
||||
-- =========================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.appointment_caldav_targets (
|
||||
appointment_id uuid NOT NULL REFERENCES paliad.appointments(id) ON DELETE CASCADE,
|
||||
binding_id uuid NOT NULL REFERENCES paliad.user_calendar_bindings(id) ON DELETE CASCADE,
|
||||
|
||||
-- 'paliad-appointment-<uuid>@paliad.de' — derived from
|
||||
-- appointment_id, identical across all bindings of one appointment.
|
||||
caldav_uid text NOT NULL,
|
||||
|
||||
-- ETag returned by the CalDAV server on the last successful PUT.
|
||||
-- NULLABLE to match the legacy paliad.appointments.caldav_etag
|
||||
-- column: some servers don't return ETag on PUT and we
|
||||
-- re-PROPFIND lazily on next tick.
|
||||
caldav_etag text,
|
||||
|
||||
last_pushed_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
PRIMARY KEY (appointment_id, binding_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS appointment_caldav_targets_binding_idx
|
||||
ON paliad.appointment_caldav_targets (binding_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS appointment_caldav_targets_uid_idx
|
||||
ON paliad.appointment_caldav_targets (caldav_uid);
|
||||
|
||||
ALTER TABLE paliad.appointment_caldav_targets ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- A target row is visible/mutable to the user who owns the binding.
|
||||
-- Appointment-side visibility is enforced separately by AppointmentService;
|
||||
-- the target is a sync-state row, scoped per-user.
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets
|
||||
FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets
|
||||
FOR UPDATE TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
))
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets;
|
||||
CREATE POLICY appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets
|
||||
FOR DELETE TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.id = appointment_caldav_targets.binding_id
|
||||
AND b.user_id = auth.uid()
|
||||
));
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 3. Backfill — one all_visible binding per existing CalDAV-configured user
|
||||
-- =========================================================================
|
||||
|
||||
-- For every paliad.user_caldav_config row, insert an 'all_visible'
|
||||
-- binding that mirrors today's single-target Phase F push. The new
|
||||
-- binding inherits the legacy `calendar_path` (or, when that's empty,
|
||||
-- the server URL itself — same fallback the client uses today). The
|
||||
-- enabled flag carries over.
|
||||
--
|
||||
-- Idempotent: skipped when this user already has an all_visible binding
|
||||
-- (re-running the migration is a no-op).
|
||||
INSERT INTO paliad.user_calendar_bindings
|
||||
(user_id, calendar_path, display_name, scope_kind, scope_id, include_personal, enabled)
|
||||
SELECT
|
||||
c.user_id,
|
||||
COALESCE(NULLIF(c.calendar_path, ''), c.url),
|
||||
'',
|
||||
'all_visible',
|
||||
NULL,
|
||||
false,
|
||||
c.enabled
|
||||
FROM paliad.user_caldav_config c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.user_id = c.user_id
|
||||
AND b.scope_kind = 'all_visible'
|
||||
);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 4. Backfill — one target row per already-pushed appointment
|
||||
-- =========================================================================
|
||||
|
||||
-- For every appointment with a non-null caldav_uid, insert one target
|
||||
-- row pointing at the appointment creator's new all_visible binding.
|
||||
-- That preserves the (appointment, calendar) sync state exactly as it
|
||||
-- existed before this migration.
|
||||
--
|
||||
-- Why created_by, not "every visible user": today's Phase F
|
||||
-- caldav_uid/caldav_etag scalars on appointments are populated by
|
||||
-- whoever happened to push last; in practice the etag almost always
|
||||
-- belongs to the creator's calendar because pull-side updates only
|
||||
-- run when CreatedBy = userID (caldav_service.go:449). Mapping the
|
||||
-- backfill target to the creator's binding keeps the etag pointing
|
||||
-- where it actually came from. Other users' goroutines will create
|
||||
-- their own target rows on their next sync tick after Slice 2 ships.
|
||||
--
|
||||
-- Idempotent: skipped when (appointment_id, binding_id) target already
|
||||
-- exists.
|
||||
INSERT INTO paliad.appointment_caldav_targets
|
||||
(appointment_id, binding_id, caldav_uid, caldav_etag, last_pushed_at)
|
||||
SELECT
|
||||
a.id,
|
||||
b.id,
|
||||
a.caldav_uid,
|
||||
a.caldav_etag,
|
||||
a.updated_at
|
||||
FROM paliad.appointments a
|
||||
JOIN paliad.user_calendar_bindings b
|
||||
ON b.user_id = a.created_by
|
||||
AND b.scope_kind = 'all_visible'
|
||||
WHERE a.caldav_uid IS NOT NULL
|
||||
AND a.created_by IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM paliad.appointment_caldav_targets t
|
||||
WHERE t.appointment_id = a.id
|
||||
AND t.binding_id = b.id
|
||||
);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- 5. Assertions — hard fail if the backfill didn't catch every row
|
||||
-- =========================================================================
|
||||
|
||||
-- Every paliad.user_caldav_config row must have at least one
|
||||
-- all_visible binding after this migration. If it doesn't, either a
|
||||
-- row was inserted between the backfill and the assertion (race —
|
||||
-- run is wrapped in a transaction by golang-migrate, so this can't
|
||||
-- happen) or the backfill is buggy. Hard fail either way.
|
||||
DO $$
|
||||
DECLARE
|
||||
missing_users int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO missing_users
|
||||
FROM paliad.user_caldav_config c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM paliad.user_calendar_bindings b
|
||||
WHERE b.user_id = c.user_id
|
||||
AND b.scope_kind = 'all_visible'
|
||||
);
|
||||
IF missing_users > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 101 assertion failed: % paliad.user_caldav_config row(s) without an all_visible binding',
|
||||
missing_users;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Every appointment with a non-null caldav_uid AND a non-null
|
||||
-- created_by must have a target row pointing at its creator's
|
||||
-- all_visible binding. created_by can be NULL on legacy rows
|
||||
-- (e.g. seed data) so we exclude those from the assertion.
|
||||
DO $$
|
||||
DECLARE
|
||||
missing_targets int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO missing_targets
|
||||
FROM paliad.appointments a
|
||||
WHERE a.caldav_uid IS NOT NULL
|
||||
AND a.created_by IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.appointment_caldav_targets t
|
||||
JOIN paliad.user_calendar_bindings b
|
||||
ON b.id = t.binding_id
|
||||
WHERE t.appointment_id = a.id
|
||||
AND b.user_id = a.created_by
|
||||
AND b.scope_kind = 'all_visible'
|
||||
);
|
||||
IF missing_targets > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 101 assertion failed: % appointment(s) with caldav_uid but no all_visible target row',
|
||||
missing_targets;
|
||||
END IF;
|
||||
END $$;
|
||||
Reference in New Issue
Block a user