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.
Files changed (136) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/package.json +57 -4
  4. package/server/dist/api/archive.js +19 -0
  5. package/server/dist/api/auth.js +167 -0
  6. package/server/dist/api/feeds.js +207 -0
  7. package/server/dist/api/filters.js +32 -0
  8. package/server/dist/api/folders.js +53 -0
  9. package/server/dist/api/index.js +37 -0
  10. package/server/dist/api/items.js +129 -0
  11. package/server/dist/api/oauth.js +14 -0
  12. package/server/dist/api/onboarding.js +30 -0
  13. package/server/dist/api/push.js +51 -0
  14. package/server/dist/api/system.js +42 -0
  15. package/server/dist/api/tokens.js +28 -0
  16. package/server/dist/api/v1/index.js +178 -0
  17. package/server/dist/api/v1/resources.js +653 -0
  18. package/server/dist/api/v1/serialize.js +48 -0
  19. package/server/dist/auth/avatar.js +13 -0
  20. package/server/dist/auth/capabilities.js +53 -0
  21. package/server/dist/auth/middleware.js +77 -0
  22. package/server/dist/auth/pairing.js +36 -0
  23. package/server/dist/auth/password.js +21 -0
  24. package/server/dist/auth/security.js +126 -0
  25. package/server/dist/auth/securityRoutes.js +249 -0
  26. package/server/dist/auth/tokens.js +64 -0
  27. package/server/dist/auth/users.js +54 -0
  28. package/server/dist/backup-cli.js +101 -0
  29. package/server/dist/cli.js +135 -0
  30. package/server/dist/clientAddress.js +26 -0
  31. package/server/dist/config.js +51 -0
  32. package/server/dist/db/index.js +40 -0
  33. package/server/dist/db/migrations/0001_init.sql +70 -0
  34. package/server/dist/db/migrations/0002_feed_health.sql +5 -0
  35. package/server/dist/db/migrations/0003_retention.sql +13 -0
  36. package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
  37. package/server/dist/db/migrations/0005_folders.sql +15 -0
  38. package/server/dist/db/migrations/0006_feed_position.sql +5 -0
  39. package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
  40. package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
  41. package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
  42. package/server/dist/db/migrations/0010_token_scope.sql +3 -0
  43. package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
  44. package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
  45. package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
  46. package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
  47. package/server/dist/db/migrations/0015_push.sql +13 -0
  48. package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
  49. package/server/dist/db/migrations/0017_devices.sql +13 -0
  50. package/server/dist/db/migrations/0018_change_log.sql +166 -0
  51. package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
  52. package/server/dist/db/migrations/0020_fts.sql +32 -0
  53. package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
  54. package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
  55. package/server/dist/db/migrations/0023_server_editions.sql +81 -0
  56. package/server/dist/db/migrations/0024_inbox.sql +5 -0
  57. package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
  58. package/server/dist/db/migrations/0026_article_content.sql +43 -0
  59. package/server/dist/db/migrations/0027_oauth.sql +38 -0
  60. package/server/dist/db/migrations/0028_user_intro.sql +2 -0
  61. package/server/dist/db/migrations/0029_account_security.sql +35 -0
  62. package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
  63. package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
  64. package/server/dist/db/migrations/0032_normalize_published_at.sql +12 -0
  65. package/server/dist/feedkeeper.js +38 -0
  66. package/server/dist/feeds/archive.js +334 -0
  67. package/server/dist/feeds/articleContent.js +26 -0
  68. package/server/dist/feeds/cleanup.js +179 -0
  69. package/server/dist/feeds/dates.js +21 -0
  70. package/server/dist/feeds/discovery.js +153 -0
  71. package/server/dist/feeds/extractor.js +208 -0
  72. package/server/dist/feeds/feedIcon.js +171 -0
  73. package/server/dist/feeds/fetcher.js +217 -0
  74. package/server/dist/feeds/fullText.js +91 -0
  75. package/server/dist/feeds/fullTextQueue.js +159 -0
  76. package/server/dist/feeds/icon.js +97 -0
  77. package/server/dist/feeds/imageCache.js +266 -0
  78. package/server/dist/feeds/imageCacheSettings.js +19 -0
  79. package/server/dist/feeds/imageTypes.js +16 -0
  80. package/server/dist/feeds/inbox.js +128 -0
  81. package/server/dist/feeds/opml.js +190 -0
  82. package/server/dist/feeds/pollQueue.js +301 -0
  83. package/server/dist/feeds/pollSettings.js +23 -0
  84. package/server/dist/feeds/poller.js +223 -0
  85. package/server/dist/feeds/repository.js +687 -0
  86. package/server/dist/feeds/requestGate.js +99 -0
  87. package/server/dist/feeds/service.js +162 -0
  88. package/server/dist/feeds/ssrfGuard.js +98 -0
  89. package/server/dist/feeds/text.js +35 -0
  90. package/server/dist/mcp/digest.js +73 -0
  91. package/server/dist/mcp/http.js +46 -0
  92. package/server/dist/mcp/items.js +73 -0
  93. package/server/dist/mcp/promptsAndResources.js +43 -0
  94. package/server/dist/mcp/server.js +547 -0
  95. package/server/dist/native/editionSelection.js +127 -0
  96. package/server/dist/native/editionText.js +15 -0
  97. package/server/dist/native/editions.js +218 -0
  98. package/server/dist/native/preferences.js +66 -0
  99. package/server/dist/native/requests.js +35 -0
  100. package/server/dist/oauth/constants.js +6 -0
  101. package/server/dist/oauth/host.js +8 -0
  102. package/server/dist/oauth/pages.js +148 -0
  103. package/server/dist/oauth/routes.js +370 -0
  104. package/server/dist/oauth/store.js +104 -0
  105. package/server/dist/push.js +147 -0
  106. package/server/dist/pushOutbox.js +164 -0
  107. package/server/dist/security-recover-cli.js +29 -0
  108. package/server/dist/server.js +108 -0
  109. package/server/dist/setup-cli.js +97 -0
  110. package/server/dist/sync/changeLog.js +170 -0
  111. package/server/dist/sync/mutations.js +144 -0
  112. package/server/dist/trustProxy.js +14 -0
  113. package/server/dist/version.js +6 -0
  114. package/server/package.json +6 -0
  115. package/web/dist/app-icon-180.png +0 -0
  116. package/web/dist/app-icon-192.png +0 -0
  117. package/web/dist/app-icon-512.png +0 -0
  118. package/web/dist/assets/index-B4e2oqbo.css +2 -0
  119. package/web/dist/assets/index-xx6TyOwB.js +32 -0
  120. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  121. package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  122. package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  123. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  124. package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  125. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  126. package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
  127. package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
  128. package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
  129. package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
  130. package/web/dist/favicon-32.png +0 -0
  131. package/web/dist/index.html +40 -0
  132. package/web/dist/logo.svg +50 -0
  133. package/web/dist/manifest.webmanifest +64 -0
  134. package/web/dist/robots.txt +3 -0
  135. package/web/dist/sw.js +198 -0
  136. 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,2 @@
1
+ -- Per-subscription opt-in: unread articles of this feed count towards the number on the app icon.
2
+ ALTER TABLE subscriptions ADD COLUMN badge INTEGER NOT NULL DEFAULT 0;
@@ -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,2 @@
1
+ -- Re-discover existing icons on the next feed poll; keep the last good image until then.
2
+ UPDATE feeds SET icon_checked_at = NULL;
@@ -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,2 @@
1
+ -- Optional, provider-independent linked overviews for native editions.
2
+ ALTER TABLE editions ADD COLUMN overview TEXT CHECK (overview IS NULL OR json_valid(overview));
@@ -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,2 @@
1
+ -- The short introduction to the app is shown to every account once; this is when the person closed it (NULL: not yet).
2
+ ALTER TABLE users ADD COLUMN intro_dismissed_at TEXT;
@@ -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);