tenseleyflow/shithub / ad194f3

Browse files

S26: migrations for stars, watches, domain_events

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ad194f3280e6adf5378f7a2e65203e4e7025513b
Parents
abc1df8
Tree
0abedb2

3 changed files

StatusFile+-
A internal/migrationsfs/migrations/0026_stars.sql 70 0
A internal/migrationsfs/migrations/0027_watches.sql 79 0
A internal/migrationsfs/migrations/0028_domain_events.sql 71 0
internal/migrationsfs/migrations/0026_stars.sqladded
@@ -0,0 +1,70 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- Stars + the cached repos.star_count column + the trigger that
4
+-- maintains it.
5
+--
6
+-- The S11 status block claimed `repos.star_count` was already in
7
+-- place; it was not. We add the column here so this sprint stands
8
+-- alone (noted in the S26 status block).
9
+--
10
+-- Deletion semantics: a star row is the user's choice. When the user
11
+-- is hard-deleted (or the repo is hard-deleted) the row goes with
12
+-- them via ON DELETE CASCADE; the AFTER DELETE trigger keeps
13
+-- star_count consistent.
14
+
15
+-- +goose Up
16
+ALTER TABLE repos
17
+    ADD COLUMN star_count    bigint NOT NULL DEFAULT 0,
18
+    ADD COLUMN watcher_count bigint NOT NULL DEFAULT 0;
19
+
20
+CREATE TABLE stars (
21
+    user_id    bigint      NOT NULL REFERENCES users(id) ON DELETE CASCADE,
22
+    repo_id    bigint      NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
23
+    starred_at timestamptz NOT NULL DEFAULT now(),
24
+    PRIMARY KEY (user_id, repo_id)
25
+);
26
+
27
+-- Stars-of-a-user, recency-sorted: drives `/{user}?tab=stars`.
28
+CREATE INDEX stars_user_starred_at_idx
29
+    ON stars (user_id, starred_at DESC);
30
+
31
+-- Stargazers-of-a-repo, recency-sorted: drives `/{owner}/{repo}/stargazers`.
32
+CREATE INDEX stars_repo_starred_at_idx
33
+    ON stars (repo_id, starred_at DESC);
34
+
35
+-- +goose StatementBegin
36
+CREATE OR REPLACE FUNCTION tg_stars_count_inc() RETURNS trigger
37
+    LANGUAGE plpgsql AS $$
38
+BEGIN
39
+    UPDATE repos SET star_count = star_count + 1 WHERE id = NEW.repo_id;
40
+    RETURN NEW;
41
+END;
42
+$$;
43
+-- +goose StatementEnd
44
+
45
+-- +goose StatementBegin
46
+CREATE OR REPLACE FUNCTION tg_stars_count_dec() RETURNS trigger
47
+    LANGUAGE plpgsql AS $$
48
+BEGIN
49
+    UPDATE repos SET star_count = GREATEST(star_count - 1, 0)
50
+        WHERE id = OLD.repo_id;
51
+    RETURN OLD;
52
+END;
53
+$$;
54
+-- +goose StatementEnd
55
+
56
+CREATE TRIGGER stars_count_inc AFTER INSERT ON stars
57
+    FOR EACH ROW EXECUTE FUNCTION tg_stars_count_inc();
58
+
59
+CREATE TRIGGER stars_count_dec AFTER DELETE ON stars
60
+    FOR EACH ROW EXECUTE FUNCTION tg_stars_count_dec();
61
+
62
+-- +goose Down
63
+DROP TRIGGER IF EXISTS stars_count_dec ON stars;
64
+DROP TRIGGER IF EXISTS stars_count_inc ON stars;
65
+DROP FUNCTION IF EXISTS tg_stars_count_dec();
66
+DROP FUNCTION IF EXISTS tg_stars_count_inc();
67
+DROP TABLE IF EXISTS stars;
68
+ALTER TABLE repos
69
+    DROP COLUMN IF EXISTS watcher_count,
70
+    DROP COLUMN IF EXISTS star_count;
internal/migrationsfs/migrations/0027_watches.sqladded
@@ -0,0 +1,79 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- Per-user, per-repo watch level. Absence of a row is the implicit
4
+-- "participating" default — matches GitHub's semantics. We only
5
+-- materialize a row when the user explicitly sets a level OR when an
6
+-- auto-watch trigger fires (collaborator add → 'all'; first comment /
7
+-- mention / assignment → 'participating').
8
+--
9
+-- watcher_count is the count of rows where level <> 'ignore'. The
10
+-- spec's day-1 lean ("all non-ignore") matches GitHub. We do NOT add
11
+-- collaborators with no row; the auto-watch path inserts a row for
12
+-- them on collab add, so once that fires every collab is in the count.
13
+
14
+-- +goose Up
15
+CREATE TYPE watch_level AS ENUM ('all', 'participating', 'ignore');
16
+
17
+CREATE TABLE watches (
18
+    user_id    bigint      NOT NULL REFERENCES users(id) ON DELETE CASCADE,
19
+    repo_id    bigint      NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
20
+    level      watch_level NOT NULL,
21
+    updated_at timestamptz NOT NULL DEFAULT now(),
22
+    PRIMARY KEY (user_id, repo_id)
23
+);
24
+
25
+-- Watchers-of-a-repo, drives `/{owner}/{repo}/watchers`.
26
+CREATE INDEX watches_repo_idx
27
+    ON watches (repo_id, updated_at DESC)
28
+    WHERE level <> 'ignore';
29
+
30
+-- Watches-by-user (e.g. notification fan-out picks recipients).
31
+CREATE INDEX watches_user_idx ON watches (user_id);
32
+
33
+-- +goose StatementBegin
34
+CREATE OR REPLACE FUNCTION tg_watches_count() RETURNS trigger
35
+    LANGUAGE plpgsql AS $$
36
+DECLARE
37
+    delta int := 0;
38
+    target_repo_id bigint;
39
+BEGIN
40
+    IF TG_OP = 'INSERT' THEN
41
+        target_repo_id := NEW.repo_id;
42
+        IF NEW.level <> 'ignore' THEN
43
+            delta := 1;
44
+        END IF;
45
+    ELSIF TG_OP = 'UPDATE' THEN
46
+        target_repo_id := NEW.repo_id;
47
+        IF OLD.level = 'ignore' AND NEW.level <> 'ignore' THEN
48
+            delta := 1;
49
+        ELSIF OLD.level <> 'ignore' AND NEW.level = 'ignore' THEN
50
+            delta := -1;
51
+        END IF;
52
+    ELSIF TG_OP = 'DELETE' THEN
53
+        target_repo_id := OLD.repo_id;
54
+        IF OLD.level <> 'ignore' THEN
55
+            delta := -1;
56
+        END IF;
57
+    END IF;
58
+    IF delta <> 0 THEN
59
+        UPDATE repos
60
+            SET watcher_count = GREATEST(watcher_count + delta, 0)
61
+            WHERE id = target_repo_id;
62
+    END IF;
63
+    RETURN COALESCE(NEW, OLD);
64
+END;
65
+$$;
66
+-- +goose StatementEnd
67
+
68
+CREATE TRIGGER watches_count AFTER INSERT OR UPDATE OR DELETE ON watches
69
+    FOR EACH ROW EXECUTE FUNCTION tg_watches_count();
70
+
71
+CREATE TRIGGER set_updated_at BEFORE UPDATE ON watches
72
+    FOR EACH ROW EXECUTE FUNCTION tg_set_updated_at();
73
+
74
+-- +goose Down
75
+DROP TRIGGER IF EXISTS set_updated_at ON watches;
76
+DROP TRIGGER IF EXISTS watches_count ON watches;
77
+DROP FUNCTION IF EXISTS tg_watches_count();
78
+DROP TABLE IF EXISTS watches;
79
+DROP TYPE IF EXISTS watch_level;
internal/migrationsfs/migrations/0028_domain_events.sqladded
@@ -0,0 +1,71 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- Canonical event log. The S26 spec called for a generic `events`
4
+-- table; the S00-S25 audit's forward-plan finding flagged that S29
5
+-- (notifications) and S33 (webhooks) would also want their own log
6
+-- table. Per the audit's recommendation we land the unified shape
7
+-- here from day 1 — `domain_events` — so S29 and S33 don't have to
8
+-- migrate the schema later.
9
+--
10
+-- Schema columns:
11
+--   actor_user_id — who did it (NULL for system events)
12
+--   kind          — short string identifying the event type
13
+--                   ("star", "unstar", "issue_comment_created", …)
14
+--   repo_id       — the repo the event is scoped to (NULL for
15
+--                   user-scoped events; reserved for org-scoped
16
+--                   events when S30 lands)
17
+--   source_kind   — what kind of object is the source/target
18
+--                   ("repo", "issue", "pull", "user", …)
19
+--   source_id     — the source object's id
20
+--   public        — whether this event is visible in public feeds.
21
+--                   Public-repo events default true; private-repo
22
+--                   events default false; user-scoped events follow
23
+--                   the user's profile-visibility setting.
24
+--   payload       — event-specific JSON. Keep small (<4 KiB);
25
+--                   bigger payloads belong in the source object
26
+--                   (referenced via source_kind/id).
27
+--
28
+-- Read patterns:
29
+--   * notifications fan-out (S29) consumes by polling on
30
+--     created_at >= last_processed.
31
+--   * webhooks (S33) the same.
32
+--   * activity feeds (post-MVP) read public events sliced by repo
33
+--     or by actor.
34
+
35
+-- +goose Up
36
+CREATE TABLE domain_events (
37
+    id              bigserial   PRIMARY KEY,
38
+    actor_user_id   bigint      REFERENCES users(id) ON DELETE SET NULL,
39
+    kind            text        NOT NULL,
40
+    repo_id         bigint      REFERENCES repos(id) ON DELETE CASCADE,
41
+    source_kind     text        NOT NULL,
42
+    source_id       bigint      NOT NULL,
43
+    public          boolean     NOT NULL DEFAULT false,
44
+    payload         jsonb       NOT NULL DEFAULT '{}'::jsonb,
45
+    created_at      timestamptz NOT NULL DEFAULT now(),
46
+
47
+    CONSTRAINT domain_events_kind_length CHECK (char_length(kind) BETWEEN 1 AND 64),
48
+    CONSTRAINT domain_events_source_kind_length CHECK (char_length(source_kind) BETWEEN 1 AND 32)
49
+);
50
+
51
+-- Notifications/webhooks consumers poll by created_at; partial-on
52
+-- created_at would gain little since both consumers process every row.
53
+CREATE INDEX domain_events_created_at_idx ON domain_events (created_at);
54
+
55
+-- Activity feed: events for a repo, recency-sorted.
56
+CREATE INDEX domain_events_repo_created_idx
57
+    ON domain_events (repo_id, created_at DESC)
58
+    WHERE repo_id IS NOT NULL;
59
+
60
+-- Activity feed: events by an actor, recency-sorted.
61
+CREATE INDEX domain_events_actor_created_idx
62
+    ON domain_events (actor_user_id, created_at DESC)
63
+    WHERE actor_user_id IS NOT NULL;
64
+
65
+-- Public-feed slice: only public rows, recency-sorted.
66
+CREATE INDEX domain_events_public_created_idx
67
+    ON domain_events (created_at DESC)
68
+    WHERE public = true;
69
+
70
+-- +goose Down
71
+DROP TABLE IF EXISTS domain_events;