feedkeeper 0.0.0-stage → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/package.json +57 -4
- package/server/dist/api/archive.js +19 -0
- package/server/dist/api/auth.js +167 -0
- package/server/dist/api/feeds.js +207 -0
- package/server/dist/api/filters.js +32 -0
- package/server/dist/api/folders.js +53 -0
- package/server/dist/api/index.js +37 -0
- package/server/dist/api/items.js +129 -0
- package/server/dist/api/oauth.js +14 -0
- package/server/dist/api/onboarding.js +30 -0
- package/server/dist/api/push.js +51 -0
- package/server/dist/api/system.js +42 -0
- package/server/dist/api/tokens.js +28 -0
- package/server/dist/api/v1/index.js +178 -0
- package/server/dist/api/v1/resources.js +653 -0
- package/server/dist/api/v1/serialize.js +48 -0
- package/server/dist/auth/avatar.js +13 -0
- package/server/dist/auth/capabilities.js +53 -0
- package/server/dist/auth/middleware.js +77 -0
- package/server/dist/auth/pairing.js +36 -0
- package/server/dist/auth/password.js +21 -0
- package/server/dist/auth/security.js +126 -0
- package/server/dist/auth/securityRoutes.js +249 -0
- package/server/dist/auth/tokens.js +64 -0
- package/server/dist/auth/users.js +54 -0
- package/server/dist/backup-cli.js +101 -0
- package/server/dist/cli.js +135 -0
- package/server/dist/clientAddress.js +26 -0
- package/server/dist/config.js +51 -0
- package/server/dist/db/index.js +40 -0
- package/server/dist/db/migrations/0001_init.sql +70 -0
- package/server/dist/db/migrations/0002_feed_health.sql +5 -0
- package/server/dist/db/migrations/0003_retention.sql +13 -0
- package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
- package/server/dist/db/migrations/0005_folders.sql +15 -0
- package/server/dist/db/migrations/0006_feed_position.sql +5 -0
- package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
- package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
- package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
- package/server/dist/db/migrations/0010_token_scope.sql +3 -0
- package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
- package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
- package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
- package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
- package/server/dist/db/migrations/0015_push.sql +13 -0
- package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
- package/server/dist/db/migrations/0017_devices.sql +13 -0
- package/server/dist/db/migrations/0018_change_log.sql +166 -0
- package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
- package/server/dist/db/migrations/0020_fts.sql +32 -0
- package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
- package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
- package/server/dist/db/migrations/0023_server_editions.sql +81 -0
- package/server/dist/db/migrations/0024_inbox.sql +5 -0
- package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
- package/server/dist/db/migrations/0026_article_content.sql +43 -0
- package/server/dist/db/migrations/0027_oauth.sql +38 -0
- package/server/dist/db/migrations/0028_user_intro.sql +2 -0
- package/server/dist/db/migrations/0029_account_security.sql +35 -0
- package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
- package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
- package/server/dist/db/migrations/0032_normalize_published_at.sql +12 -0
- package/server/dist/feedkeeper.js +38 -0
- package/server/dist/feeds/archive.js +334 -0
- package/server/dist/feeds/articleContent.js +26 -0
- package/server/dist/feeds/cleanup.js +179 -0
- package/server/dist/feeds/dates.js +21 -0
- package/server/dist/feeds/discovery.js +153 -0
- package/server/dist/feeds/extractor.js +208 -0
- package/server/dist/feeds/feedIcon.js +171 -0
- package/server/dist/feeds/fetcher.js +217 -0
- package/server/dist/feeds/fullText.js +91 -0
- package/server/dist/feeds/fullTextQueue.js +159 -0
- package/server/dist/feeds/icon.js +97 -0
- package/server/dist/feeds/imageCache.js +266 -0
- package/server/dist/feeds/imageCacheSettings.js +19 -0
- package/server/dist/feeds/imageTypes.js +16 -0
- package/server/dist/feeds/inbox.js +128 -0
- package/server/dist/feeds/opml.js +190 -0
- package/server/dist/feeds/pollQueue.js +301 -0
- package/server/dist/feeds/pollSettings.js +23 -0
- package/server/dist/feeds/poller.js +223 -0
- package/server/dist/feeds/repository.js +687 -0
- package/server/dist/feeds/requestGate.js +99 -0
- package/server/dist/feeds/service.js +162 -0
- package/server/dist/feeds/ssrfGuard.js +98 -0
- package/server/dist/feeds/text.js +35 -0
- package/server/dist/mcp/digest.js +73 -0
- package/server/dist/mcp/http.js +46 -0
- package/server/dist/mcp/items.js +73 -0
- package/server/dist/mcp/promptsAndResources.js +43 -0
- package/server/dist/mcp/server.js +547 -0
- package/server/dist/native/editionSelection.js +127 -0
- package/server/dist/native/editionText.js +15 -0
- package/server/dist/native/editions.js +218 -0
- package/server/dist/native/preferences.js +66 -0
- package/server/dist/native/requests.js +35 -0
- package/server/dist/oauth/constants.js +6 -0
- package/server/dist/oauth/host.js +8 -0
- package/server/dist/oauth/pages.js +148 -0
- package/server/dist/oauth/routes.js +370 -0
- package/server/dist/oauth/store.js +104 -0
- package/server/dist/push.js +147 -0
- package/server/dist/pushOutbox.js +164 -0
- package/server/dist/security-recover-cli.js +29 -0
- package/server/dist/server.js +108 -0
- package/server/dist/setup-cli.js +97 -0
- package/server/dist/sync/changeLog.js +170 -0
- package/server/dist/sync/mutations.js +144 -0
- package/server/dist/trustProxy.js +14 -0
- package/server/dist/version.js +6 -0
- package/server/package.json +6 -0
- package/web/dist/app-icon-180.png +0 -0
- package/web/dist/app-icon-192.png +0 -0
- package/web/dist/app-icon-512.png +0 -0
- package/web/dist/assets/index-B4e2oqbo.css +2 -0
- package/web/dist/assets/index-xx6TyOwB.js +32 -0
- package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
- package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
- package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
- package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
- package/web/dist/favicon-32.png +0 -0
- package/web/dist/index.html +40 -0
- package/web/dist/logo.svg +50 -0
- package/web/dist/manifest.webmanifest +64 -0
- package/web/dist/robots.txt +3 -0
- package/web/dist/sw.js +198 -0
- package/web/dist/theme-init.js +10 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Saved (bookmarked) articles are archived: full text is kept on the item and
|
|
2
|
+
-- images are stored as files next to the database; this table maps them.
|
|
3
|
+
ALTER TABLE items ADD COLUMN archived_at TEXT;
|
|
4
|
+
|
|
5
|
+
CREATE TABLE archived_images (
|
|
6
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
7
|
+
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
8
|
+
source_url TEXT NOT NULL,
|
|
9
|
+
file_name TEXT NOT NULL UNIQUE,
|
|
10
|
+
mime_type TEXT NOT NULL,
|
|
11
|
+
byte_size INTEGER NOT NULL,
|
|
12
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
13
|
+
UNIQUE (item_id, source_url)
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE INDEX idx_bookmarks_item ON item_bookmarks(item_id);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- Web Push: devices that receive notifications, and per-subscription opt-in for new articles.
|
|
2
|
+
ALTER TABLE subscriptions ADD COLUMN notify INTEGER NOT NULL DEFAULT 0;
|
|
3
|
+
|
|
4
|
+
CREATE TABLE IF NOT EXISTS push_devices (
|
|
5
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
6
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
7
|
+
endpoint TEXT NOT NULL UNIQUE,
|
|
8
|
+
p256dh TEXT NOT NULL,
|
|
9
|
+
auth TEXT NOT NULL,
|
|
10
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE INDEX IF NOT EXISTS idx_push_devices_user ON push_devices(user_id);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- Native apps pair with a one-time code and get their own revocable token.
|
|
2
|
+
ALTER TABLE personal_access_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'api' CHECK (kind IN ('api', 'device'));
|
|
3
|
+
ALTER TABLE personal_access_tokens ADD COLUMN platform TEXT;
|
|
4
|
+
ALTER TABLE personal_access_tokens ADD COLUMN app_version TEXT;
|
|
5
|
+
|
|
6
|
+
-- At most one pairing code per user; creating a new one replaces the old one.
|
|
7
|
+
CREATE TABLE IF NOT EXISTS pairing_codes (
|
|
8
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
9
|
+
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
|
10
|
+
code_hash TEXT NOT NULL UNIQUE,
|
|
11
|
+
expires_at TEXT NOT NULL,
|
|
12
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
13
|
+
);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
-- Change log for native clients (see docs/design/native-api.md).
|
|
2
|
+
--
|
|
3
|
+
-- One row per object a user owns (compacted): whenever the object changes the row's seq moves
|
|
4
|
+
-- forward, so the log grows with the number of objects, not with the number of changes.
|
|
5
|
+
-- Triggers fill it, so every code path (web, MCP, native API, poller, cleanup) is covered.
|
|
6
|
+
-- No foreign key on user_id: triggers write here while a user's data is being deleted.
|
|
7
|
+
CREATE TABLE changes (
|
|
8
|
+
user_id INTEGER NOT NULL,
|
|
9
|
+
entity TEXT NOT NULL,
|
|
10
|
+
entity_id INTEGER NOT NULL,
|
|
11
|
+
op TEXT NOT NULL CHECK (op IN ('upsert', 'delete')),
|
|
12
|
+
seq INTEGER NOT NULL,
|
|
13
|
+
changed_at TEXT NOT NULL,
|
|
14
|
+
-- Per-field times of an article's state, for last-writer-wins between devices.
|
|
15
|
+
read_at TEXT,
|
|
16
|
+
saved_at TEXT,
|
|
17
|
+
progress_at TEXT,
|
|
18
|
+
PRIMARY KEY (user_id, entity, entity_id)
|
|
19
|
+
) WITHOUT ROWID;
|
|
20
|
+
|
|
21
|
+
CREATE INDEX idx_changes_seq ON changes(user_id, seq);
|
|
22
|
+
|
|
23
|
+
-- A row exists once a user's existing data was added to the log. `pruned_through` is the highest
|
|
24
|
+
-- seq of a forgotten deletion, so clients that missed it know they must start over.
|
|
25
|
+
CREATE TABLE sync_state (
|
|
26
|
+
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
27
|
+
pruned_through INTEGER NOT NULL DEFAULT 0
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE TABLE applied_mutations (
|
|
31
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
32
|
+
mutation_id TEXT NOT NULL,
|
|
33
|
+
outcome TEXT NOT NULL,
|
|
34
|
+
error TEXT,
|
|
35
|
+
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
36
|
+
PRIMARY KEY (user_id, mutation_id)
|
|
37
|
+
) WITHOUT ROWID;
|
|
38
|
+
|
|
39
|
+
-- Reading position of any article; positions near the start or the end are not stored.
|
|
40
|
+
CREATE TABLE item_progress (
|
|
41
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
42
|
+
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
43
|
+
position REAL NOT NULL,
|
|
44
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
45
|
+
PRIMARY KEY (user_id, item_id)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TRIGGER trg_changes_read_insert AFTER INSERT ON item_reads
|
|
49
|
+
BEGIN
|
|
50
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at, read_at)
|
|
51
|
+
VALUES (NEW.user_id, 'item_state', NEW.item_id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
52
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at, read_at = excluded.read_at;
|
|
53
|
+
END;
|
|
54
|
+
|
|
55
|
+
CREATE TRIGGER trg_changes_read_delete AFTER DELETE ON item_reads
|
|
56
|
+
WHEN EXISTS (SELECT 1 FROM items WHERE id = OLD.item_id)
|
|
57
|
+
BEGIN
|
|
58
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at, read_at)
|
|
59
|
+
VALUES (OLD.user_id, 'item_state', OLD.item_id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = OLD.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
60
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at, read_at = excluded.read_at;
|
|
61
|
+
END;
|
|
62
|
+
|
|
63
|
+
CREATE TRIGGER trg_changes_saved_insert AFTER INSERT ON item_bookmarks
|
|
64
|
+
BEGIN
|
|
65
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at, saved_at)
|
|
66
|
+
VALUES (NEW.user_id, 'item_state', NEW.item_id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
67
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at, saved_at = excluded.saved_at;
|
|
68
|
+
END;
|
|
69
|
+
|
|
70
|
+
CREATE TRIGGER trg_changes_saved_delete AFTER DELETE ON item_bookmarks
|
|
71
|
+
WHEN EXISTS (SELECT 1 FROM items WHERE id = OLD.item_id)
|
|
72
|
+
BEGIN
|
|
73
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at, saved_at)
|
|
74
|
+
VALUES (OLD.user_id, 'item_state', OLD.item_id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = OLD.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
75
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at, saved_at = excluded.saved_at;
|
|
76
|
+
END;
|
|
77
|
+
|
|
78
|
+
CREATE TRIGGER trg_changes_progress_insert AFTER INSERT ON item_progress
|
|
79
|
+
BEGIN
|
|
80
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at, progress_at)
|
|
81
|
+
VALUES (NEW.user_id, 'item_state', NEW.item_id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
82
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at, progress_at = excluded.progress_at;
|
|
83
|
+
END;
|
|
84
|
+
|
|
85
|
+
CREATE TRIGGER trg_changes_progress_update AFTER UPDATE ON item_progress
|
|
86
|
+
BEGIN
|
|
87
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at, progress_at)
|
|
88
|
+
VALUES (NEW.user_id, 'item_state', NEW.item_id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
89
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at, progress_at = excluded.progress_at;
|
|
90
|
+
END;
|
|
91
|
+
|
|
92
|
+
CREATE TRIGGER trg_changes_progress_delete AFTER DELETE ON item_progress
|
|
93
|
+
WHEN EXISTS (SELECT 1 FROM items WHERE id = OLD.item_id)
|
|
94
|
+
BEGIN
|
|
95
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at, progress_at)
|
|
96
|
+
VALUES (OLD.user_id, 'item_state', OLD.item_id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = OLD.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
97
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at, progress_at = excluded.progress_at;
|
|
98
|
+
END;
|
|
99
|
+
|
|
100
|
+
CREATE TRIGGER trg_changes_subscription_insert AFTER INSERT ON subscriptions
|
|
101
|
+
BEGIN
|
|
102
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
103
|
+
VALUES (NEW.user_id, 'subscription', NEW.feed_id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
104
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
105
|
+
END;
|
|
106
|
+
|
|
107
|
+
CREATE TRIGGER trg_changes_subscription_update AFTER UPDATE ON subscriptions
|
|
108
|
+
BEGIN
|
|
109
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
110
|
+
VALUES (NEW.user_id, 'subscription', NEW.feed_id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
111
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
112
|
+
END;
|
|
113
|
+
|
|
114
|
+
CREATE TRIGGER trg_changes_subscription_delete AFTER DELETE ON subscriptions
|
|
115
|
+
BEGIN
|
|
116
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
117
|
+
VALUES (OLD.user_id, 'subscription', OLD.feed_id, 'delete', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = OLD.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
118
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
119
|
+
END;
|
|
120
|
+
|
|
121
|
+
CREATE TRIGGER trg_changes_folder_insert AFTER INSERT ON folders
|
|
122
|
+
BEGIN
|
|
123
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
124
|
+
VALUES (NEW.user_id, 'folder', NEW.id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
125
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
126
|
+
END;
|
|
127
|
+
|
|
128
|
+
CREATE TRIGGER trg_changes_folder_update AFTER UPDATE ON folders
|
|
129
|
+
BEGIN
|
|
130
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
131
|
+
VALUES (NEW.user_id, 'folder', NEW.id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
132
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
133
|
+
END;
|
|
134
|
+
|
|
135
|
+
CREATE TRIGGER trg_changes_folder_delete AFTER DELETE ON folders
|
|
136
|
+
BEGIN
|
|
137
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
138
|
+
VALUES (OLD.user_id, 'folder', OLD.id, 'delete', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = OLD.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
139
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
140
|
+
END;
|
|
141
|
+
|
|
142
|
+
CREATE TRIGGER trg_changes_muted_keyword_insert AFTER INSERT ON user_muted_keywords
|
|
143
|
+
BEGIN
|
|
144
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
145
|
+
VALUES (NEW.user_id, 'muted_keyword', NEW.id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
146
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
147
|
+
END;
|
|
148
|
+
|
|
149
|
+
CREATE TRIGGER trg_changes_muted_keyword_update AFTER UPDATE ON user_muted_keywords
|
|
150
|
+
BEGIN
|
|
151
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
152
|
+
VALUES (NEW.user_id, 'muted_keyword', NEW.id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
153
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
154
|
+
END;
|
|
155
|
+
|
|
156
|
+
CREATE TRIGGER trg_changes_muted_keyword_delete AFTER DELETE ON user_muted_keywords
|
|
157
|
+
BEGIN
|
|
158
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
159
|
+
VALUES (OLD.user_id, 'muted_keyword', OLD.id, 'delete', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = OLD.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
160
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
161
|
+
END;
|
|
162
|
+
|
|
163
|
+
CREATE TRIGGER trg_changes_user_delete AFTER DELETE ON users
|
|
164
|
+
BEGIN
|
|
165
|
+
DELETE FROM changes WHERE user_id = OLD.id;
|
|
166
|
+
END;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
-- Migration 0019: Feed icons, folder symbols, synchronized article notes, and curated editions
|
|
2
|
+
|
|
3
|
+
-- 1. Persistent feed icons
|
|
4
|
+
CREATE TABLE IF NOT EXISTS feed_icons (
|
|
5
|
+
feed_id INTEGER PRIMARY KEY REFERENCES feeds(id) ON DELETE CASCADE,
|
|
6
|
+
data BLOB NOT NULL,
|
|
7
|
+
mime TEXT NOT NULL,
|
|
8
|
+
hash TEXT NOT NULL,
|
|
9
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
CREATE TRIGGER IF NOT EXISTS trg_changes_feed_icon_insert AFTER INSERT ON feed_icons
|
|
13
|
+
BEGIN
|
|
14
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
15
|
+
SELECT s.user_id, 'subscription', s.feed_id, 'upsert',
|
|
16
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = s.user_id),
|
|
17
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
18
|
+
FROM subscriptions s WHERE s.feed_id = NEW.feed_id
|
|
19
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
20
|
+
END;
|
|
21
|
+
|
|
22
|
+
CREATE TRIGGER IF NOT EXISTS trg_changes_feed_icon_update AFTER UPDATE ON feed_icons
|
|
23
|
+
BEGIN
|
|
24
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
25
|
+
SELECT s.user_id, 'subscription', s.feed_id, 'upsert',
|
|
26
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = s.user_id),
|
|
27
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
28
|
+
FROM subscriptions s WHERE s.feed_id = NEW.feed_id
|
|
29
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
30
|
+
END;
|
|
31
|
+
|
|
32
|
+
-- 2. SF Symbol for folders / categories
|
|
33
|
+
ALTER TABLE folders ADD COLUMN icon_symbol TEXT;
|
|
34
|
+
|
|
35
|
+
-- 3. Synchronized article notes
|
|
36
|
+
CREATE TABLE IF NOT EXISTS item_notes (
|
|
37
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
38
|
+
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
39
|
+
content TEXT NOT NULL,
|
|
40
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
41
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
42
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
43
|
+
PRIMARY KEY (user_id, item_id)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_item_notes_item ON item_notes(item_id);
|
|
47
|
+
|
|
48
|
+
CREATE TRIGGER IF NOT EXISTS trg_changes_note_insert AFTER INSERT ON item_notes
|
|
49
|
+
BEGIN
|
|
50
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
51
|
+
VALUES (NEW.user_id, 'note', NEW.item_id, 'upsert',
|
|
52
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id),
|
|
53
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
54
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
55
|
+
END;
|
|
56
|
+
|
|
57
|
+
CREATE TRIGGER IF NOT EXISTS trg_changes_note_update AFTER UPDATE ON item_notes
|
|
58
|
+
BEGIN
|
|
59
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
60
|
+
VALUES (NEW.user_id, 'note', NEW.item_id, 'upsert',
|
|
61
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id),
|
|
62
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
63
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
64
|
+
END;
|
|
65
|
+
|
|
66
|
+
CREATE TRIGGER IF NOT EXISTS trg_changes_note_delete AFTER DELETE ON item_notes
|
|
67
|
+
WHEN EXISTS (SELECT 1 FROM items WHERE id = OLD.item_id)
|
|
68
|
+
BEGIN
|
|
69
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
70
|
+
VALUES (OLD.user_id, 'note', OLD.item_id, 'delete',
|
|
71
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = OLD.user_id),
|
|
72
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
73
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
74
|
+
END;
|
|
75
|
+
|
|
76
|
+
-- 4. Curated edition ("Deine Zeitung")
|
|
77
|
+
CREATE TABLE IF NOT EXISTS editions (
|
|
78
|
+
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
79
|
+
item_ids TEXT NOT NULL,
|
|
80
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
81
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
82
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
83
|
+
expires_at TEXT
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
CREATE TRIGGER IF NOT EXISTS trg_changes_edition_insert AFTER INSERT ON editions
|
|
87
|
+
BEGIN
|
|
88
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
89
|
+
VALUES (NEW.user_id, 'edition', 1, 'upsert',
|
|
90
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id),
|
|
91
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
92
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
93
|
+
END;
|
|
94
|
+
|
|
95
|
+
CREATE TRIGGER IF NOT EXISTS trg_changes_edition_update AFTER UPDATE ON editions
|
|
96
|
+
BEGIN
|
|
97
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
98
|
+
VALUES (NEW.user_id, 'edition', 1, 'upsert',
|
|
99
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id),
|
|
100
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
101
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
102
|
+
END;
|
|
103
|
+
|
|
104
|
+
CREATE TRIGGER IF NOT EXISTS trg_changes_edition_delete AFTER DELETE ON editions
|
|
105
|
+
BEGIN
|
|
106
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
107
|
+
VALUES (OLD.user_id, 'edition', 1, 'delete',
|
|
108
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = OLD.user_id),
|
|
109
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
110
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
111
|
+
END;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- SQLite FTS5 Full-Text Search for items (titles, summaries, and full article text)
|
|
2
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
|
|
3
|
+
title,
|
|
4
|
+
content_snippet,
|
|
5
|
+
content_html,
|
|
6
|
+
full_content_html,
|
|
7
|
+
content='items',
|
|
8
|
+
content_rowid='id',
|
|
9
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
-- Backfill existing items into FTS index
|
|
13
|
+
INSERT INTO items_fts(rowid, title, content_snippet, content_html, full_content_html)
|
|
14
|
+
SELECT id, title, content_snippet, content_html, full_content_html FROM items;
|
|
15
|
+
|
|
16
|
+
-- Triggers to keep FTS index in step with items
|
|
17
|
+
CREATE TRIGGER IF NOT EXISTS trg_items_fts_insert AFTER INSERT ON items BEGIN
|
|
18
|
+
INSERT INTO items_fts(rowid, title, content_snippet, content_html, full_content_html)
|
|
19
|
+
VALUES (new.id, new.title, new.content_snippet, new.content_html, new.full_content_html);
|
|
20
|
+
END;
|
|
21
|
+
|
|
22
|
+
CREATE TRIGGER IF NOT EXISTS trg_items_fts_delete AFTER DELETE ON items BEGIN
|
|
23
|
+
INSERT INTO items_fts(items_fts, rowid, title, content_snippet, content_html, full_content_html)
|
|
24
|
+
VALUES ('delete', old.id, old.title, old.content_snippet, old.content_html, old.full_content_html);
|
|
25
|
+
END;
|
|
26
|
+
|
|
27
|
+
CREATE TRIGGER IF NOT EXISTS trg_items_fts_update AFTER UPDATE ON items BEGIN
|
|
28
|
+
INSERT INTO items_fts(items_fts, rowid, title, content_snippet, content_html, full_content_html)
|
|
29
|
+
VALUES ('delete', old.id, old.title, old.content_snippet, old.content_html, old.full_content_html);
|
|
30
|
+
INSERT INTO items_fts(rowid, title, content_snippet, content_html, full_content_html)
|
|
31
|
+
VALUES (new.id, new.title, new.content_snippet, new.content_html, new.full_content_html);
|
|
32
|
+
END;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- Keep note revisions after deletion so a stale device cannot overwrite a recreated note.
|
|
2
|
+
CREATE TABLE item_note_revisions (
|
|
3
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
4
|
+
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
5
|
+
revision INTEGER NOT NULL,
|
|
6
|
+
updated_at TEXT NOT NULL,
|
|
7
|
+
PRIMARY KEY (user_id, item_id)
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
INSERT INTO item_note_revisions (user_id, item_id, revision, updated_at)
|
|
11
|
+
SELECT user_id, item_id, revision, updated_at FROM item_notes;
|
|
12
|
+
|
|
13
|
+
CREATE TRIGGER trg_note_revision_insert AFTER INSERT ON item_notes
|
|
14
|
+
BEGIN
|
|
15
|
+
INSERT INTO item_note_revisions (user_id, item_id, revision, updated_at)
|
|
16
|
+
VALUES (NEW.user_id, NEW.item_id, NEW.revision, NEW.updated_at)
|
|
17
|
+
ON CONFLICT (user_id, item_id) DO UPDATE SET revision = excluded.revision, updated_at = excluded.updated_at;
|
|
18
|
+
END;
|
|
19
|
+
|
|
20
|
+
CREATE TRIGGER trg_note_revision_update AFTER UPDATE ON item_notes
|
|
21
|
+
BEGIN
|
|
22
|
+
INSERT INTO item_note_revisions (user_id, item_id, revision, updated_at)
|
|
23
|
+
VALUES (NEW.user_id, NEW.item_id, NEW.revision, NEW.updated_at)
|
|
24
|
+
ON CONFLICT (user_id, item_id) DO UPDATE SET revision = excluded.revision, updated_at = excluded.updated_at;
|
|
25
|
+
END;
|
|
26
|
+
|
|
27
|
+
CREATE TRIGGER trg_note_revision_delete AFTER DELETE ON item_notes
|
|
28
|
+
WHEN EXISTS (SELECT 1 FROM items WHERE id = OLD.item_id)
|
|
29
|
+
BEGIN
|
|
30
|
+
UPDATE item_note_revisions SET revision = OLD.revision + 1,
|
|
31
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
32
|
+
WHERE user_id = OLD.user_id AND item_id = OLD.item_id;
|
|
33
|
+
END;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
-- Native presentation preferences never change the web folder order or stream.
|
|
2
|
+
CREATE TABLE native_preferences (
|
|
3
|
+
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
4
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
5
|
+
settings TEXT NOT NULL CHECK (json_valid(settings)),
|
|
6
|
+
updated_at TEXT NOT NULL
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
CREATE TABLE native_requests (
|
|
10
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
11
|
+
request_id TEXT NOT NULL,
|
|
12
|
+
fingerprint TEXT NOT NULL,
|
|
13
|
+
response TEXT NOT NULL,
|
|
14
|
+
created_at TEXT NOT NULL,
|
|
15
|
+
PRIMARY KEY (user_id, request_id)
|
|
16
|
+
) WITHOUT ROWID;
|
|
17
|
+
|
|
18
|
+
ALTER TABLE editions ADD COLUMN edition_id TEXT;
|
|
19
|
+
ALTER TABLE editions ADD COLUMN source TEXT NOT NULL DEFAULT 'curated';
|
|
20
|
+
ALTER TABLE editions ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
|
|
21
|
+
ALTER TABLE editions ADD COLUMN period TEXT;
|
|
22
|
+
ALTER TABLE editions ADD COLUMN title TEXT;
|
|
23
|
+
ALTER TABLE editions ADD COLUMN summary TEXT;
|
|
24
|
+
ALTER TABLE editions ADD COLUMN preferences_revision INTEGER NOT NULL DEFAULT 0;
|
|
25
|
+
UPDATE editions SET edition_id = lower(hex(randomblob(16))),
|
|
26
|
+
expires_at = COALESCE(expires_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+24 hours'));
|
|
27
|
+
|
|
28
|
+
-- A retained slot keeps revisions monotonic, including expiry and dismissal.
|
|
29
|
+
CREATE TABLE edition_history (
|
|
30
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
31
|
+
edition_id TEXT NOT NULL,
|
|
32
|
+
item_ids TEXT NOT NULL CHECK (json_valid(item_ids)),
|
|
33
|
+
revision INTEGER NOT NULL,
|
|
34
|
+
created_at TEXT NOT NULL,
|
|
35
|
+
PRIMARY KEY (user_id, edition_id)
|
|
36
|
+
) WITHOUT ROWID;
|
|
37
|
+
INSERT INTO edition_history SELECT user_id, edition_id, item_ids, revision, created_at FROM editions;
|
|
38
|
+
|
|
39
|
+
DROP TRIGGER trg_changes_edition_update;
|
|
40
|
+
CREATE TRIGGER trg_changes_edition_update AFTER UPDATE ON editions
|
|
41
|
+
BEGIN
|
|
42
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
43
|
+
VALUES (NEW.user_id, 'edition', 1, CASE WHEN NEW.status = 'active' THEN 'upsert' ELSE 'delete' END,
|
|
44
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), NEW.updated_at)
|
|
45
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
46
|
+
END;
|
|
47
|
+
|
|
48
|
+
CREATE TRIGGER trg_changes_native_preferences_insert AFTER INSERT ON native_preferences
|
|
49
|
+
BEGIN
|
|
50
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
51
|
+
VALUES (NEW.user_id, 'native_preferences', 1, 'upsert',
|
|
52
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), NEW.updated_at)
|
|
53
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
54
|
+
END;
|
|
55
|
+
|
|
56
|
+
CREATE TRIGGER trg_changes_native_preferences_update AFTER UPDATE ON native_preferences
|
|
57
|
+
BEGIN
|
|
58
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
59
|
+
VALUES (NEW.user_id, 'native_preferences', 1, 'upsert',
|
|
60
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = NEW.user_id), NEW.updated_at)
|
|
61
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
62
|
+
END;
|
|
63
|
+
|
|
64
|
+
-- Remove stale folder IDs atomically when any API deletes a folder. A new folder
|
|
65
|
+
-- with the same name has a new identity and must not inherit old preferences.
|
|
66
|
+
CREATE TRIGGER trg_native_preferences_folder_delete AFTER DELETE ON folders
|
|
67
|
+
WHEN EXISTS (SELECT 1 FROM native_preferences WHERE user_id = OLD.user_id)
|
|
68
|
+
BEGIN
|
|
69
|
+
UPDATE native_preferences SET
|
|
70
|
+
settings = json_set(settings,
|
|
71
|
+
'$.folderOrder', json(COALESCE((SELECT json_group_array(value) FROM json_each(settings, '$.folderOrder') WHERE value IS NULL OR value != OLD.id), '[]')),
|
|
72
|
+
'$.hiddenFolderIds', json(COALESCE((SELECT json_group_array(value) FROM json_each(settings, '$.hiddenFolderIds') WHERE value != OLD.id), '[]')),
|
|
73
|
+
'$.preferredFolderIds', json(COALESCE((SELECT json_group_array(value) FROM json_each(settings, '$.preferredFolderIds') WHERE value != OLD.id), '[]'))),
|
|
74
|
+
revision = revision + 1,
|
|
75
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
76
|
+
WHERE user_id = OLD.user_id AND (
|
|
77
|
+
EXISTS (SELECT 1 FROM json_each(settings, '$.folderOrder') WHERE value = OLD.id) OR
|
|
78
|
+
EXISTS (SELECT 1 FROM json_each(settings, '$.hiddenFolderIds') WHERE value = OLD.id) OR
|
|
79
|
+
EXISTS (SELECT 1 FROM json_each(settings, '$.preferredFolderIds') WHERE value = OLD.id)
|
|
80
|
+
);
|
|
81
|
+
END;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- Migration 0024: Universal Inbox ("Save to FeedKeeper")
|
|
2
|
+
-- Creates a dedicated system feed for each user's saved web articles and inbound clippings.
|
|
3
|
+
|
|
4
|
+
-- Mark feeds that are internal system inboxes rather than polled RSS sources.
|
|
5
|
+
ALTER TABLE feeds ADD COLUMN is_system_inbox INTEGER NOT NULL DEFAULT 0;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- Shared reader content lifecycle; no client- or AI-specific state.
|
|
2
|
+
ALTER TABLE items ADD COLUMN content_revision INTEGER NOT NULL DEFAULT 1;
|
|
3
|
+
ALTER TABLE items ADD COLUMN extraction_status TEXT NOT NULL DEFAULT 'pending';
|
|
4
|
+
ALTER TABLE items ADD COLUMN extraction_attempted_at TEXT;
|
|
5
|
+
ALTER TABLE items ADD COLUMN extraction_retry_at TEXT;
|
|
6
|
+
UPDATE items SET extraction_status = 'ready' WHERE full_content_html IS NOT NULL;
|
|
7
|
+
CREATE TABLE full_text_jobs (
|
|
8
|
+
item_id INTEGER PRIMARY KEY REFERENCES items(id) ON DELETE CASCADE,
|
|
9
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
10
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
11
|
+
available_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
12
|
+
);
|
|
13
|
+
CREATE INDEX idx_full_text_jobs_due ON full_text_jobs(available_at, priority);
|
|
14
|
+
CREATE TRIGGER trg_content_revision AFTER UPDATE OF content_html, full_content_html, content_snippet, image_url, title, link ON items
|
|
15
|
+
WHEN OLD.content_html IS NOT NEW.content_html OR OLD.full_content_html IS NOT NEW.full_content_html
|
|
16
|
+
OR OLD.content_snippet IS NOT NEW.content_snippet OR OLD.image_url IS NOT NEW.image_url OR OLD.title IS NOT NEW.title OR OLD.link IS NOT NEW.link
|
|
17
|
+
BEGIN
|
|
18
|
+
UPDATE items SET content_revision = OLD.content_revision + 1,
|
|
19
|
+
extraction_status = CASE WHEN NEW.full_content_html IS NOT NULL THEN 'ready' WHEN OLD.extraction_status = 'feed_only' THEN 'pending' ELSE NEW.extraction_status END
|
|
20
|
+
WHERE id = NEW.id;
|
|
21
|
+
END;
|
|
22
|
+
CREATE TRIGGER trg_content_changes AFTER UPDATE OF content_revision, extraction_status, extraction_retry_at ON items
|
|
23
|
+
WHEN OLD.content_revision IS NOT NEW.content_revision OR OLD.extraction_status IS NOT NEW.extraction_status OR OLD.extraction_retry_at IS NOT NEW.extraction_retry_at
|
|
24
|
+
BEGIN
|
|
25
|
+
INSERT INTO changes(user_id, entity, entity_id, op, seq, changed_at)
|
|
26
|
+
SELECT owners.user_id, 'item_content', NEW.id, 'upsert',
|
|
27
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = owners.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
28
|
+
FROM (SELECT user_id FROM subscriptions WHERE feed_id = NEW.feed_id
|
|
29
|
+
UNION SELECT user_id FROM item_bookmarks WHERE item_id = NEW.id
|
|
30
|
+
UNION SELECT user_id FROM item_notes WHERE item_id = NEW.id
|
|
31
|
+
UNION SELECT e.user_id FROM editions e, json_each(e.item_ids) selected
|
|
32
|
+
WHERE selected.value = NEW.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now')) owners WHERE 1
|
|
33
|
+
ON CONFLICT(user_id, entity, entity_id) DO UPDATE SET seq = excluded.seq, changed_at = excluded.changed_at, op = excluded.op;
|
|
34
|
+
END;
|
|
35
|
+
|
|
36
|
+
-- Metadata-only nested updates must not issue a second FTS delete for the new body.
|
|
37
|
+
DROP TRIGGER trg_items_fts_update;
|
|
38
|
+
CREATE TRIGGER trg_items_fts_update AFTER UPDATE OF title, content_snippet, content_html, full_content_html ON items BEGIN
|
|
39
|
+
INSERT INTO items_fts(items_fts, rowid, title, content_snippet, content_html, full_content_html)
|
|
40
|
+
VALUES ('delete', old.id, old.title, old.content_snippet, old.content_html, old.full_content_html);
|
|
41
|
+
INSERT INTO items_fts(rowid, title, content_snippet, content_html, full_content_html)
|
|
42
|
+
VALUES (new.id, new.title, new.content_snippet, new.content_html, new.full_content_html);
|
|
43
|
+
END;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- Migration 0027: OAuth 2.1 for the remote MCP endpoint (ChatGPT, Claude and other connectors).
|
|
2
|
+
-- OAuth grants live apart from personal_access_tokens: they expire, rotate and only work on /mcp.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE oauth_clients (
|
|
5
|
+
client_id TEXT PRIMARY KEY,
|
|
6
|
+
client_name TEXT NOT NULL,
|
|
7
|
+
redirect_uris TEXT NOT NULL,
|
|
8
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
-- Single-use authorization codes (60 seconds), stored as hashes.
|
|
12
|
+
CREATE TABLE oauth_codes (
|
|
13
|
+
code_hash TEXT PRIMARY KEY,
|
|
14
|
+
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
|
15
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
16
|
+
redirect_uri TEXT NOT NULL,
|
|
17
|
+
code_challenge TEXT NOT NULL,
|
|
18
|
+
scope TEXT NOT NULL CHECK (scope IN ('read', 'write')),
|
|
19
|
+
expires_at TEXT NOT NULL
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
-- One row per connected app: the current access token and its rotating refresh token.
|
|
23
|
+
CREATE TABLE oauth_grants (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
26
|
+
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
|
|
27
|
+
scope TEXT NOT NULL CHECK (scope IN ('read', 'write')),
|
|
28
|
+
access_hash TEXT NOT NULL UNIQUE,
|
|
29
|
+
access_expires_at TEXT NOT NULL,
|
|
30
|
+
refresh_hash TEXT NOT NULL UNIQUE,
|
|
31
|
+
-- The refresh token this one replaced: presenting it again means it leaked, so the grant is revoked.
|
|
32
|
+
previous_refresh_hash TEXT,
|
|
33
|
+
refresh_expires_at TEXT NOT NULL,
|
|
34
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
35
|
+
last_used_at TEXT
|
|
36
|
+
);
|
|
37
|
+
CREATE INDEX idx_oauth_grants_user ON oauth_grants(user_id);
|
|
38
|
+
CREATE INDEX idx_oauth_grants_client ON oauth_grants(client_id);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- Account security is independent of the client used to sign in.
|
|
2
|
+
CREATE TABLE account_security (
|
|
3
|
+
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
4
|
+
user_handle TEXT NOT NULL UNIQUE,
|
|
5
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
6
|
+
totp_secret TEXT,
|
|
7
|
+
totp_last_step INTEGER NOT NULL DEFAULT -1
|
|
8
|
+
);
|
|
9
|
+
CREATE TABLE passkeys (
|
|
10
|
+
id TEXT PRIMARY KEY,
|
|
11
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
12
|
+
public_key BLOB NOT NULL,
|
|
13
|
+
counter INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
transports TEXT NOT NULL,
|
|
15
|
+
name TEXT NOT NULL,
|
|
16
|
+
created_at TEXT NOT NULL,
|
|
17
|
+
last_used_at TEXT
|
|
18
|
+
);
|
|
19
|
+
CREATE INDEX passkeys_user ON passkeys(user_id);
|
|
20
|
+
CREATE TABLE security_challenges (
|
|
21
|
+
id_hash TEXT PRIMARY KEY,
|
|
22
|
+
purpose TEXT NOT NULL,
|
|
23
|
+
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
24
|
+
binding TEXT NOT NULL,
|
|
25
|
+
version INTEGER NOT NULL,
|
|
26
|
+
payload TEXT NOT NULL,
|
|
27
|
+
expires_at INTEGER NOT NULL,
|
|
28
|
+
attempts INTEGER NOT NULL DEFAULT 0
|
|
29
|
+
);
|
|
30
|
+
CREATE INDEX security_challenges_expiry ON security_challenges(expires_at);
|
|
31
|
+
CREATE TABLE recovery_codes (
|
|
32
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
33
|
+
code_hash TEXT NOT NULL,
|
|
34
|
+
PRIMARY KEY (user_id, code_hash)
|
|
35
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- One application process owns polling. Pending work is durable; running locks are memory-only.
|
|
2
|
+
CREATE TABLE feed_poll_jobs (
|
|
3
|
+
feed_id INTEGER PRIMARY KEY REFERENCES feeds(id) ON DELETE CASCADE,
|
|
4
|
+
host TEXT NOT NULL,
|
|
5
|
+
enqueued_at INTEGER NOT NULL,
|
|
6
|
+
available_at INTEGER NOT NULL,
|
|
7
|
+
attempts INTEGER NOT NULL DEFAULT 0
|
|
8
|
+
);
|
|
9
|
+
CREATE INDEX idx_feed_poll_jobs_due ON feed_poll_jobs(available_at, enqueued_at, feed_id);
|
|
10
|
+
CREATE INDEX idx_feed_poll_jobs_host ON feed_poll_jobs(host);
|
|
11
|
+
CREATE TABLE feed_poll_hosts (
|
|
12
|
+
host TEXT PRIMARY KEY,
|
|
13
|
+
retry_at INTEGER NOT NULL
|
|
14
|
+
) WITHOUT ROWID;
|
|
15
|
+
CREATE INDEX idx_feed_poll_jobs_fifo ON feed_poll_jobs(enqueued_at, feed_id);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- One event per committed feed update, not one waiting task per recipient.
|
|
2
|
+
-- Cursors are durable; one application process dispatches device deliveries.
|
|
3
|
+
CREATE TABLE push_outbox (
|
|
4
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
5
|
+
feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
|
|
6
|
+
max_item_id INTEGER NOT NULL,
|
|
7
|
+
item_count INTEGER NOT NULL,
|
|
8
|
+
subscription_ceiling INTEGER NOT NULL,
|
|
9
|
+
subscription_cursor INTEGER NOT NULL DEFAULT 0,
|
|
10
|
+
current_subscription INTEGER NOT NULL DEFAULT 0,
|
|
11
|
+
device_cursor INTEGER NOT NULL DEFAULT 0,
|
|
12
|
+
device_ceiling INTEGER NOT NULL DEFAULT 0,
|
|
13
|
+
target_device INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
available_at INTEGER NOT NULL,
|
|
16
|
+
created_at INTEGER NOT NULL
|
|
17
|
+
);
|
|
18
|
+
CREATE INDEX idx_push_outbox_due ON push_outbox(available_at, id);
|
|
19
|
+
CREATE INDEX idx_push_outbox_feed ON push_outbox(feed_id, id);
|