feedkeeper 0.0.0-stage → 0.23.0

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 (137) 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 +162 -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/app.js +100 -0
  20. package/server/dist/auth/avatar.js +13 -0
  21. package/server/dist/auth/capabilities.js +53 -0
  22. package/server/dist/auth/middleware.js +77 -0
  23. package/server/dist/auth/pairing.js +36 -0
  24. package/server/dist/auth/password.js +9 -0
  25. package/server/dist/auth/plan.js +58 -0
  26. package/server/dist/auth/security.js +117 -0
  27. package/server/dist/auth/securityRoutes.js +249 -0
  28. package/server/dist/auth/tokens.js +64 -0
  29. package/server/dist/auth/users.js +54 -0
  30. package/server/dist/backup-cli.js +101 -0
  31. package/server/dist/cli.js +135 -0
  32. package/server/dist/clientAddress.js +26 -0
  33. package/server/dist/config.js +51 -0
  34. package/server/dist/db/index.js +40 -0
  35. package/server/dist/db/migrations/0001_init.sql +70 -0
  36. package/server/dist/db/migrations/0002_feed_health.sql +5 -0
  37. package/server/dist/db/migrations/0003_retention.sql +13 -0
  38. package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
  39. package/server/dist/db/migrations/0005_folders.sql +15 -0
  40. package/server/dist/db/migrations/0006_feed_position.sql +5 -0
  41. package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
  42. package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
  43. package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
  44. package/server/dist/db/migrations/0010_token_scope.sql +3 -0
  45. package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
  46. package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
  47. package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
  48. package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
  49. package/server/dist/db/migrations/0015_push.sql +13 -0
  50. package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
  51. package/server/dist/db/migrations/0017_devices.sql +13 -0
  52. package/server/dist/db/migrations/0018_change_log.sql +166 -0
  53. package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
  54. package/server/dist/db/migrations/0020_fts.sql +32 -0
  55. package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
  56. package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
  57. package/server/dist/db/migrations/0023_server_editions.sql +81 -0
  58. package/server/dist/db/migrations/0024_inbox.sql +5 -0
  59. package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
  60. package/server/dist/db/migrations/0026_article_content.sql +43 -0
  61. package/server/dist/db/migrations/0027_oauth.sql +38 -0
  62. package/server/dist/db/migrations/0028_user_intro.sql +2 -0
  63. package/server/dist/db/migrations/0029_account_security.sql +35 -0
  64. package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
  65. package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
  66. package/server/dist/feedkeeper.js +38 -0
  67. package/server/dist/feeds/archive.js +334 -0
  68. package/server/dist/feeds/articleContent.js +26 -0
  69. package/server/dist/feeds/cleanup.js +179 -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 +222 -0
  85. package/server/dist/feeds/repository.js +674 -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/index.js +15 -0
  91. package/server/dist/mcp/digest.js +73 -0
  92. package/server/dist/mcp/http.js +46 -0
  93. package/server/dist/mcp/items.js +73 -0
  94. package/server/dist/mcp/promptsAndResources.js +43 -0
  95. package/server/dist/mcp/server.js +547 -0
  96. package/server/dist/native/editionSelection.js +127 -0
  97. package/server/dist/native/editionText.js +15 -0
  98. package/server/dist/native/editions.js +218 -0
  99. package/server/dist/native/preferences.js +66 -0
  100. package/server/dist/native/requests.js +35 -0
  101. package/server/dist/oauth/constants.js +6 -0
  102. package/server/dist/oauth/host.js +8 -0
  103. package/server/dist/oauth/pages.js +148 -0
  104. package/server/dist/oauth/routes.js +361 -0
  105. package/server/dist/oauth/store.js +104 -0
  106. package/server/dist/push.js +147 -0
  107. package/server/dist/pushOutbox.js +164 -0
  108. package/server/dist/security-recover-cli.js +29 -0
  109. package/server/dist/server.js +108 -0
  110. package/server/dist/setup-cli.js +97 -0
  111. package/server/dist/sync/changeLog.js +170 -0
  112. package/server/dist/sync/mutations.js +144 -0
  113. package/server/dist/trustProxy.js +14 -0
  114. package/server/dist/version.js +6 -0
  115. package/server/package.json +6 -0
  116. package/web/dist/app-icon-180.png +0 -0
  117. package/web/dist/app-icon-192.png +0 -0
  118. package/web/dist/app-icon-512.png +0 -0
  119. package/web/dist/assets/index-B4e2oqbo.css +2 -0
  120. package/web/dist/assets/index-xx6TyOwB.js +32 -0
  121. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  122. package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  123. package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  124. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  125. package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  126. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  127. package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
  128. package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
  129. package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
  130. package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
  131. package/web/dist/favicon-32.png +0 -0
  132. package/web/dist/index.html +40 -0
  133. package/web/dist/logo.svg +50 -0
  134. package/web/dist/manifest.webmanifest +64 -0
  135. package/web/dist/robots.txt +3 -0
  136. package/web/dist/sw.js +198 -0
  137. package/web/dist/theme-init.js +10 -0
@@ -0,0 +1,51 @@
1
+ import { config as loadEnv } from "dotenv";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { readPollSettings } from "./feeds/pollSettings.js";
5
+ import { parseTrustProxy } from "./trustProxy.js";
6
+ import { readImageCacheSettings } from "./feeds/imageCacheSettings.js";
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ // Loads the repo-root .env regardless of the process's current working
9
+ // directory, so `npm run dev`/`start` behave the same from anywhere.
10
+ // `quiet: true` suppresses dotenv's own console output (including its
11
+ // unrelated third-party promo "tips"), which has no place in server logs.
12
+ loadEnv({ path: join(__dirname, "../../.env"), quiet: true });
13
+ function required(name, fallback) {
14
+ const value = process.env[name] ?? fallback;
15
+ if (value === undefined) {
16
+ throw new Error(`Missing required environment variable: ${name}`);
17
+ }
18
+ return value;
19
+ }
20
+ export const config = {
21
+ imageCache: readImageCacheSettings(),
22
+ polling: readPollSettings(),
23
+ port: Number(process.env.PORT ?? 3000),
24
+ publicUrl: required("PUBLIC_URL", "http://localhost:3000"),
25
+ databasePath: required("DATABASE_PATH", "./data/feedkeeper.sqlite"),
26
+ // Images of saved articles live outside the database so it stays small.
27
+ archivePath: process.env.ARCHIVE_PATH ?? join(dirname(process.env.DATABASE_PATH ?? "./data/feedkeeper.sqlite"), "archive"),
28
+ sessionSecret: required("SESSION_SECRET"),
29
+ allowSignup: process.env.ALLOW_SIGNUP === "true",
30
+ trustProxy: parseTrustProxy(process.env.TRUST_PROXY),
31
+ clientIpHeader: process.env.CLIENT_IP_HEADER?.trim() || null,
32
+ minPollIntervalMinutes: Number(process.env.MIN_POLL_INTERVAL_MINUTES ?? 5),
33
+ retentionReadDays: Number(process.env.RETENTION_READ_DAYS ?? 30),
34
+ retentionMaxDays: Number(process.env.RETENTION_MAX_DAYS ?? 90),
35
+ retentionMaxItemsPerFeed: Number(process.env.RETENTION_MAX_ITEMS_PER_FEED ?? 1000),
36
+ autoCleanupEnabled: process.env.AUTO_CLEANUP_ENABLED !== "false",
37
+ // Optional: only these hosts may register OAuth redirect URIs (comma-separated). Loopback addresses are always allowed.
38
+ oauthAllowedRedirectHosts: (process.env.OAUTH_ALLOWED_REDIRECT_HOSTS ?? "")
39
+ .split(",")
40
+ .map((host) => host.trim().toLowerCase())
41
+ .filter(Boolean),
42
+ showGithubLink: process.env.SHOW_GITHUB_LINK !== "false",
43
+ githubUrl: process.env.GITHUB_URL ?? "https://github.com/visualfusion/feedkeeper",
44
+ };
45
+ if (!config.sessionSecret.trim()) {
46
+ throw new Error("SESSION_SECRET must not be empty");
47
+ }
48
+ if (config.sessionSecret.trim().length < 32) {
49
+ console.warn("[config] SESSION_SECRET is shorter than 32 characters; use a long random secret for new installations");
50
+ }
51
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,40 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdirSync, readdirSync, readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { config } from "../config.js";
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const MIGRATIONS_DIR = join(__dirname, "migrations");
8
+ mkdirSync(dirname(config.databasePath), { recursive: true });
9
+ export const db = new Database(config.databasePath);
10
+ db.pragma("journal_mode = WAL");
11
+ db.pragma("foreign_keys = ON");
12
+ function ensureMigrationsTable() {
13
+ db.exec(`CREATE TABLE IF NOT EXISTS _migrations (
14
+ name TEXT PRIMARY KEY,
15
+ applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
16
+ )`);
17
+ }
18
+ export function runMigrations() {
19
+ ensureMigrationsTable();
20
+ const applied = new Set(db.prepare("SELECT name FROM _migrations").all().map((row) => row.name));
21
+ const files = readdirSync(MIGRATIONS_DIR)
22
+ .filter((file) => file.endsWith(".sql"))
23
+ .sort();
24
+ for (const file of files) {
25
+ if (applied.has(file))
26
+ continue;
27
+ const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8");
28
+ const applyMigration = db.transaction(() => {
29
+ db.exec(sql);
30
+ db.prepare("INSERT INTO _migrations (name) VALUES (?)").run(file);
31
+ });
32
+ applyMigration();
33
+ console.log(`[db] applied migration ${file}`);
34
+ }
35
+ }
36
+ export function hasAnyUser() {
37
+ const row = db.prepare("SELECT COUNT(*) AS count FROM users").get();
38
+ return row.count > 0;
39
+ }
40
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,70 @@
1
+ -- Initial schema.
2
+ -- Feeds are shared across users so the same source URL is only ever fetched
3
+ -- once; per-user state (subscription label, read status) lives separately.
4
+
5
+ CREATE TABLE users (
6
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7
+ email TEXT NOT NULL UNIQUE,
8
+ password_hash TEXT NOT NULL,
9
+ display_name TEXT NOT NULL,
10
+ role TEXT NOT NULL CHECK (role IN ('admin', 'user')) DEFAULT 'user',
11
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
12
+ );
13
+
14
+ CREATE TABLE personal_access_tokens (
15
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
17
+ name TEXT NOT NULL,
18
+ token_hash TEXT NOT NULL UNIQUE,
19
+ token_prefix TEXT NOT NULL,
20
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
21
+ last_used_at TEXT
22
+ );
23
+
24
+ CREATE INDEX idx_pat_user ON personal_access_tokens(user_id);
25
+
26
+ CREATE TABLE feeds (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ url TEXT NOT NULL UNIQUE,
29
+ title TEXT,
30
+ site_url TEXT,
31
+ poll_interval_minutes INTEGER NOT NULL DEFAULT 15,
32
+ last_polled_at TEXT,
33
+ last_error TEXT,
34
+ etag TEXT,
35
+ last_modified TEXT,
36
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
37
+ );
38
+
39
+ CREATE TABLE subscriptions (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
42
+ feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
43
+ label TEXT,
44
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
45
+ UNIQUE (user_id, feed_id)
46
+ );
47
+
48
+ CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
49
+ CREATE INDEX idx_subscriptions_feed ON subscriptions(feed_id);
50
+
51
+ CREATE TABLE items (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
54
+ guid TEXT NOT NULL,
55
+ title TEXT,
56
+ link TEXT,
57
+ content_snippet TEXT,
58
+ published_at TEXT,
59
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
60
+ UNIQUE (feed_id, guid)
61
+ );
62
+
63
+ CREATE INDEX idx_items_feed ON items(feed_id, published_at DESC);
64
+
65
+ CREATE TABLE item_reads (
66
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
67
+ item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
68
+ read_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
69
+ PRIMARY KEY (user_id, item_id)
70
+ );
@@ -0,0 +1,5 @@
1
+ ALTER TABLE feeds ADD COLUMN last_success_at TEXT;
2
+ ALTER TABLE feeds ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0;
3
+
4
+ UPDATE feeds SET last_success_at = last_polled_at WHERE last_error IS NULL AND last_polled_at IS NOT NULL;
5
+ UPDATE feeds SET consecutive_errors = 1 WHERE last_error IS NOT NULL;
@@ -0,0 +1,13 @@
1
+ -- Migration 0003: System settings for database retention and housekeeping
2
+
3
+ CREATE TABLE IF NOT EXISTS system_settings (
4
+ key TEXT PRIMARY KEY,
5
+ value TEXT NOT NULL,
6
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
7
+ );
8
+
9
+ INSERT OR IGNORE INTO system_settings (key, value) VALUES
10
+ ('retention_read_days', '30'),
11
+ ('retention_max_days', '90'),
12
+ ('retention_max_items_per_feed', '1000'),
13
+ ('auto_cleanup_enabled', 'true');
@@ -0,0 +1,20 @@
1
+ -- Migration 0004: Bookmarks (Saved Items) and User Muted Keywords
2
+
3
+ CREATE TABLE IF NOT EXISTS item_bookmarks (
4
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5
+ item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
6
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
7
+ PRIMARY KEY (user_id, item_id)
8
+ );
9
+
10
+ CREATE INDEX IF NOT EXISTS idx_bookmarks_user ON item_bookmarks(user_id);
11
+
12
+ CREATE TABLE IF NOT EXISTS user_muted_keywords (
13
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
15
+ keyword TEXT NOT NULL,
16
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
17
+ UNIQUE (user_id, keyword)
18
+ );
19
+
20
+ CREATE INDEX IF NOT EXISTS idx_muted_user ON user_muted_keywords(user_id);
@@ -0,0 +1,15 @@
1
+ -- Migration 0005: Feed Folders / Categories
2
+
3
+ CREATE TABLE IF NOT EXISTS folders (
4
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6
+ name TEXT NOT NULL,
7
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
8
+ UNIQUE (user_id, name)
9
+ );
10
+
11
+ CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id);
12
+
13
+ ALTER TABLE subscriptions ADD COLUMN folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL;
14
+
15
+ CREATE INDEX IF NOT EXISTS idx_subscriptions_folder ON subscriptions(folder_id);
@@ -0,0 +1,5 @@
1
+ -- Migration 0006: Custom Feed Ordering (position)
2
+
3
+ ALTER TABLE subscriptions ADD COLUMN position INTEGER NOT NULL DEFAULT 0;
4
+
5
+ CREATE INDEX IF NOT EXISTS idx_subscriptions_user_pos ON subscriptions(user_id, position);
@@ -0,0 +1,2 @@
1
+ -- Migration 0007: Store image URL for feed items
2
+ ALTER TABLE items ADD COLUMN image_url TEXT;
@@ -0,0 +1,2 @@
1
+ -- Migration 0008: Store full content HTML for feed items
2
+ ALTER TABLE items ADD COLUMN content_html TEXT;
@@ -0,0 +1 @@
1
+ ALTER TABLE items ADD COLUMN full_content_html TEXT;
@@ -0,0 +1,3 @@
1
+ -- Existing tokens keep their original write access. New tokens can be read-only.
2
+ ALTER TABLE personal_access_tokens
3
+ ADD COLUMN scope TEXT NOT NULL DEFAULT 'write' CHECK (scope IN ('read', 'write'));
@@ -0,0 +1,8 @@
1
+ -- Profile photos live in their own table so user lookups on every request
2
+ -- never load image data, and backups keep covering a single SQLite file.
3
+ CREATE TABLE user_avatars (
4
+ user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
5
+ mime_type TEXT NOT NULL CHECK (mime_type IN ('image/jpeg', 'image/png', 'image/webp')),
6
+ data BLOB NOT NULL,
7
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
8
+ );
@@ -0,0 +1,5 @@
1
+ -- Per-subscription choice whether the reader may fetch full articles, and a
2
+ -- per-feed memory of sites that answer full-text requests with a consent wall.
3
+ ALTER TABLE subscriptions ADD COLUMN full_text_mode TEXT NOT NULL DEFAULT 'auto' CHECK (full_text_mode IN ('auto', 'never'));
4
+ ALTER TABLE feeds ADD COLUMN full_text_blocks INTEGER NOT NULL DEFAULT 0;
5
+ ALTER TABLE feeds ADD COLUMN full_text_blocked_at TEXT;
@@ -0,0 +1,4 @@
1
+ -- Icon declared by the feed's website, so the browser does not rely on a
2
+ -- guessed /favicon.ico that may be missing or blank.
3
+ ALTER TABLE feeds ADD COLUMN icon_url TEXT;
4
+ ALTER TABLE feeds ADD COLUMN icon_checked_at TEXT;
@@ -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;