Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
492c2f9
feat(habit): add recurring habit domain kernel
seonghobae Aug 3, 2026
29b205d
test(habit): recover recurrence and immutable history behavior
seonghobae Aug 3, 2026
6121031
feat(habit): add append-only recurring core schema
seonghobae Aug 3, 2026
a1cef78
docs(habit): document recurrence persistence guarantees
seonghobae Aug 3, 2026
8c9b482
docs(habit): plan recurring domain slice
seonghobae Aug 3, 2026
3d8fbb0
chore(format): gate recurring habit slice
seonghobae Aug 3, 2026
12836b5
ci: render recurring habit formatting diagnostics
seonghobae Aug 3, 2026
b2bb920
fix(ci): preserve formatting gate integrity
seonghobae Aug 3, 2026
28ec325
fix(habit): harden recurrence and tenant invariants
seonghobae Aug 3, 2026
24d57cd
test(habit): cover week anchoring and strict persistence input
seonghobae Aug 3, 2026
1e58dd8
ci: render habit test formatting diagnostics
seonghobae Aug 3, 2026
ed12462
style(habit): apply Prettier formatting
seonghobae Aug 3, 2026
d514665
ci: remove temporary habit diagnostics
seonghobae Aug 3, 2026
5e9bd9b
chore(ci): diagnose habit test formatting
seonghobae Aug 3, 2026
288ba08
ci: restore production validation workflow
seonghobae Aug 3, 2026
81f62cf
fix(habit): harden composite keys and idempotency replay
seonghobae Aug 3, 2026
783ec5c
test(habit): cover composite collisions and replay conflicts
seonghobae Aug 3, 2026
047996e
fix(habit): reject truncation of completion history
seonghobae Aug 3, 2026
bea9047
docs(habit): document append-only database privileges
seonghobae Aug 3, 2026
248da1d
perf(habit): index completion history ordering
seonghobae Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions apps/habit-service/migrations/0001_recurring_habit_core.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
BEGIN;

CREATE SCHEMA IF NOT EXISTS habit;

CREATE TABLE habit.habit_definitions (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL,
title text NOT NULL,
timezone_name text NOT NULL,
recurrence_kind text NOT NULL,
recurrence_interval smallint NOT NULL,
weekday_mask smallint NOT NULL DEFAULT 0,
starts_on date NOT NULL,
created_at timestamptz NOT NULL,
CONSTRAINT habit_definitions_id_workspace_unique
UNIQUE (id, workspace_id),
CONSTRAINT habit_definitions_id_uuid_v4 CHECK (
id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
),
CONSTRAINT habit_definitions_workspace_id_uuid_v4 CHECK (
workspace_id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
),
CONSTRAINT habit_definitions_title_nonblank CHECK (
length(btrim(title)) > 0
),
CONSTRAINT habit_definitions_timezone_nonblank CHECK (
length(btrim(timezone_name)) > 0
),
CONSTRAINT habit_definitions_recurrence_kind_valid CHECK (
recurrence_kind IN ('daily', 'weekly')
),
CONSTRAINT habit_definitions_recurrence_interval_valid CHECK (
recurrence_interval BETWEEN 1 AND 365
),
CONSTRAINT habit_definitions_weekday_mask_valid CHECK (
(recurrence_kind = 'daily' AND weekday_mask = 0)
OR
(recurrence_kind = 'weekly' AND weekday_mask BETWEEN 1 AND 127)
)
);

CREATE TABLE habit.completion_events (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL,
habit_id uuid NOT NULL,
scheduled_local_date date NOT NULL,
completed_at timestamptz NOT NULL,
idempotency_key uuid NOT NULL,
recorded_at timestamptz NOT NULL,
CONSTRAINT completion_events_id_workspace_unique
UNIQUE (id, workspace_id),
CONSTRAINT completion_events_idempotency_unique
UNIQUE (workspace_id, habit_id, idempotency_key),
CONSTRAINT completion_events_habit_workspace_foreign
FOREIGN KEY (habit_id, workspace_id)
REFERENCES habit.habit_definitions (id, workspace_id),
CONSTRAINT completion_events_id_uuid_v4 CHECK (
id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
),
CONSTRAINT completion_events_workspace_id_uuid_v4 CHECK (
workspace_id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
),
CONSTRAINT completion_events_habit_id_uuid_v4 CHECK (
habit_id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
),
CONSTRAINT completion_events_idempotency_key_uuid_v4 CHECK (
idempotency_key::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
)
);

CREATE INDEX habit_definitions_workspace_creation_idx
ON habit.habit_definitions (workspace_id, created_at ASC, id ASC);

CREATE INDEX completion_events_workspace_habit_schedule_idx
ON habit.completion_events (
workspace_id,
habit_id,
scheduled_local_date ASC,
recorded_at ASC,
id ASC
);

CREATE INDEX completion_events_workspace_habit_recorded_idx
ON habit.completion_events (
workspace_id,
habit_id,
recorded_at ASC,
id ASC
);

CREATE FUNCTION habit.reject_completion_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION USING
ERRCODE = '55000',
MESSAGE = 'Habit completion history is append-only';
END;
$$;

CREATE TRIGGER completion_events_append_only
BEFORE UPDATE OR DELETE ON habit.completion_events
FOR EACH ROW
EXECUTE FUNCTION habit.reject_completion_mutation();
Comment thread
seonghobae marked this conversation as resolved.

CREATE TRIGGER completion_events_reject_truncate
BEFORE TRUNCATE ON habit.completion_events
FOR EACH STATEMENT
EXECUTE FUNCTION habit.reject_completion_mutation();

COMMIT;
15 changes: 15 additions & 0 deletions apps/habit-service/migrations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Habit migrations

Apply Habit SQL files in lexical order to the PostgreSQL database owned by the Habit service before starting the corresponding application version.

- `0001_recurring_habit_core.sql` creates tenant-safe habit definitions and append-only completion events. Weekly recurrence days are stored as a seven-bit ISO-weekday mask, while the service domain exposes normalized weekday numbers from Monday (`1`) through Sunday (`7`).

## Integrity guarantees

Every persisted entity, workspace, habit reference, event, and idempotency key is constrained to UUIDv4. Composite foreign keys carry `workspace_id` through the ownership path, and duplicate completion commands are identified by `(workspace_id, habit_id, idempotency_key)`.

Database triggers reject `UPDATE`, `DELETE`, and `TRUNCATE` operations on completion events. The service runtime role must not receive `UPDATE`, `DELETE`, or `TRUNCATE` privileges on `habit.completion_events`. A future data-rights migration must provide a separately authorized erasure path before production account deletion is enabled; application code must not bypass append-only history through direct SQL.

## Rollback

This migration is forward-only in automated environments. An operator-approved rollback must first export tenant data, then drop `habit.completion_events`, `habit.habit_definitions`, `habit.reject_completion_mutation()`, and the `habit` schema. Do not roll back after serving completion writes unless the exported history has been verified and the data-retention decision is documented.
Loading
Loading