Port the database layer's foundation
Opens storage the way the Go backend does: <base>_users as main, with _articles and _recs attached, so queries join across them while each file checkpoints and vacuums alone -- and so the derived recommendation tables can be rebuilt without touching anything a user wrote. The suffixes match, so a database written by either side is readable by the other. Pragmas are repeated per schema rather than set once. PRAGMA applies to one database at a time, so setting WAL on main leaves articles journalling the slow way; the probe asserts WAL on all three rather than trusting the call. exp() and log() are registered as SQL functions as the Go connection does. Nothing calls them today -- they exist so a scoring query can decay a weight without pulling rows into the application. log() returns NULL for zero and negatives, since -inf and NaN both poison an ORDER BY. The schema is extracted rather than retyped: tools/extract_schema.py pulls all 44 DDL statements out of internal/db/db.go. Transcribing them by hand would drift, and a column differing by a default or a CHECK surfaces months later as a violation nobody can place. The probe targets what fails quietly -- an ATTACH that did not happen, a pragma that did not take, FTS5 triggers that never fire. The last is the sharpest: the index is trigger-maintained, so a missed trigger means search returns nothing while every other query looks fine. This is the foundation, not the layer. The remaining ~2,800 lines of query methods are mechanical against this base. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
51371bc parent: 1971b41 modified
spike/nim/.gitignore +1 -0 | @@ -10,3 +10,4 @@ store_probe | ||
| 10 | 10 | *.db |
| 11 | 11 | *.db-wal |
| 12 | 12 | *.db-shm |
| 13 | +gleandb_probe | |
| @@ -10,3 +10,4 @@ store_probe | |||
| 10 | *.db | 10 | *.db |
| 11 | *.db-wal | 11 | *.db-wal |
| 12 | *.db-shm | 12 | *.db-shm |
| 13 | +gleandb_probe | ||
added
spike/nim/atproto/gleandb.nim +147 -0 | new file mode 100644 | ||
| @@ -0,0 +1,147 @@ | ||
| 1 | +## Glean's database: three SQLite files behind one connection. | |
| 2 | +## | |
| 3 | +## `main` holds users and auth, `articles` the feeds and their contents, and | |
| 4 | +## `recs` the derived recommendation tables. They are separate files attached | |
| 5 | +## together, so a query can join across them while each still checkpoints and | |
| 6 | +## vacuums on its own -- and, more practically, so the derived tables can be | |
| 7 | +## deleted and rebuilt without touching anything a user wrote. | |
| 8 | +## | |
| 9 | +## The pragmas are copied from internal/db/db.go. They are not decoration: | |
| 10 | +## WAL is what lets readers run during a write, and the per-database repeats | |
| 11 | +## matter because PRAGMA applies to one schema at a time -- setting WAL on | |
| 12 | +## `main` leaves `articles` journalling the slow way. | |
| 13 | + | |
| 14 | +import std/[math, options, strformat, strutils] | |
| 15 | +import ./schema | |
| 16 | +import ./sqlite | |
| 17 | + | |
| 18 | +type | |
| 19 | + GleanDb* = object | |
| 20 | + db*: Db | |
| 21 | + basePath*: string | |
| 22 | + | |
| 23 | +{.push importc, cdecl.} | |
| 24 | +proc sqlite3_create_function_v2(db: pointer, name: cstring, nArg: cint, | |
| 25 | + eTextRep: cint, pApp: pointer, | |
| 26 | + xFunc, xStep, xFinal, xDestroy: pointer): cint | |
| 27 | +proc sqlite3_value_double(v: pointer): cdouble | |
| 28 | +proc sqlite3_result_double(ctx: pointer, v: cdouble) | |
| 29 | +proc sqlite3_result_null(ctx: pointer) | |
| 30 | +{.pop.} | |
| 31 | + | |
| 32 | +const | |
| 33 | + SQLITE_UTF8 = 1.cint | |
| 34 | + SQLITE_DETERMINISTIC = 0x000000800.cint | |
| 35 | + | |
| 36 | +# Registered because the Go connection registers them. Nothing in the current | |
| 37 | +# SQL calls either -- they exist so a scoring query can decay a weight | |
| 38 | +# exponentially without pulling the rows into the application first. | |
| 39 | +proc sqlExp(ctx: pointer, n: cint, args: ptr UncheckedArray[pointer]) | |
| 40 | + {.cdecl.} = | |
| 41 | + if n != 1: | |
| 42 | + sqlite3_result_null(ctx) | |
| 43 | + return | |
| 44 | + sqlite3_result_double(ctx, exp(sqlite3_value_double(args[0])).cdouble) | |
| 45 | + | |
| 46 | +proc sqlLog(ctx: pointer, n: cint, args: ptr UncheckedArray[pointer]) | |
| 47 | + {.cdecl.} = | |
| 48 | + if n != 1: | |
| 49 | + sqlite3_result_null(ctx) | |
| 50 | + return | |
| 51 | + let v = sqlite3_value_double(args[0]).float | |
| 52 | + # log(0) is -inf and log(negative) is NaN; SQL callers want neither, and a | |
| 53 | + # NULL propagates through an ORDER BY far more gracefully. | |
| 54 | + if v <= 0: | |
| 55 | + sqlite3_result_null(ctx) | |
| 56 | + else: | |
| 57 | + sqlite3_result_double(ctx, ln(v).cdouble) | |
| 58 | + | |
| 59 | +proc registerFunctions*(db: Db) = | |
| 60 | + for (name, fn) in [("exp", sqlExp), ("log", sqlLog)]: | |
| 61 | + let rc = sqlite3_create_function_v2( | |
| 62 | + db.handle, name.cstring, 1, | |
| 63 | + SQLITE_UTF8 or SQLITE_DETERMINISTIC, | |
| 64 | + nil, cast[pointer](fn), nil, nil, nil) | |
| 65 | + if rc != SQLITE_OK: | |
| 66 | + raise newException(SqliteError, "registering SQL function " & name) | |
| 67 | + | |
| 68 | +const | |
| 69 | + MainPragmas = [ | |
| 70 | + "journal_mode=WAL", | |
| 71 | + "wal_autocheckpoint=1000", | |
| 72 | + "busy_timeout=30000", | |
| 73 | + "synchronous=NORMAL", | |
| 74 | + "temp_store=FILE", | |
| 75 | + "mmap_size=268435456", | |
| 76 | + "auto_vacuum=INCREMENTAL", | |
| 77 | + ] | |
| 78 | + AttachedPragmas = [ | |
| 79 | + "journal_mode=WAL", | |
| 80 | + "wal_autocheckpoint=1000", | |
| 81 | + "busy_timeout=30000", | |
| 82 | + "synchronous=NORMAL", | |
| 83 | + "temp_store=FILE", | |
| 84 | + "mmap_size=268435456", | |
| 85 | + ] | |
| 86 | + | |
| 87 | +proc applyPragmas(db: Db, schemaName: string, pragmas: openArray[string]) = | |
| 88 | + for pragma in pragmas: | |
| 89 | + # A pragma that will not take is not worth failing a boot over -- some are | |
| 90 | + # advisory, and mmap_size in particular is refused on filesystems that | |
| 91 | + # cannot map. The ones that matter are asserted after opening instead. | |
| 92 | + try: | |
| 93 | + if schemaName.len == 0: | |
| 94 | + db.exec &"PRAGMA {pragma}" | |
| 95 | + else: | |
| 96 | + db.exec &"PRAGMA {schemaName}.{pragma}" | |
| 97 | + except SqliteError: | |
| 98 | + discard | |
| 99 | + | |
| 100 | +proc open*(basePath: string): GleanDb = | |
| 101 | + ## Open `<basePath>_users` and attach `_articles` and `_recs`. | |
| 102 | + ## | |
| 103 | + ## The suffixes match the Go layout, so a database written by either side is | |
| 104 | + ## readable by the other. | |
| 105 | + let | |
| 106 | + usersPath = basePath & "_users" | |
| 107 | + articlesPath = basePath & "_articles" | |
| 108 | + recsPath = basePath & "_recs" | |
| 109 | + | |
| 110 | + var db = sqlite.open(usersPath) | |
| 111 | + registerFunctions(db) | |
| 112 | + applyPragmas(db, "", MainPragmas) | |
| 113 | + | |
| 114 | + # Quoted and single-quote-escaped: a path is not necessarily tame, and this | |
| 115 | + # one can come from configuration. | |
| 116 | + for (name, path) in [("articles", articlesPath), ("recs", recsPath)]: | |
| 117 | + db.exec &"ATTACH DATABASE '{path.replace(\"'\", \"''\")}' AS {name}" | |
| 118 | + applyPragmas(db, name, AttachedPragmas) | |
| 119 | + | |
| 120 | + GleanDb(db: db, basePath: basePath) | |
| 121 | + | |
| 122 | +proc migrate*(g: GleanDb) = | |
| 123 | + ## Apply the schema. Every statement is IF NOT EXISTS, so this runs on every | |
| 124 | + ## boot rather than being gated on a version. | |
| 125 | + for stmt in usersSchema: g.db.exec stmt | |
| 126 | + for stmt in articlesSchema: g.db.exec stmt | |
| 127 | + for stmt in recsSchema: g.db.exec stmt | |
| 128 | + | |
| 129 | +proc createEmbeddingTables*(g: GleanDb, dimension: int) = | |
| 130 | + ## The vec0 tables carry their dimension in the DDL, so they cannot live in | |
| 131 | + ## the static schema -- it comes from GLEAN_EMBED_DIMENSION. | |
| 132 | + g.db.exec &"""CREATE VIRTUAL TABLE IF NOT EXISTS recs.feed_embeddings | |
| 133 | + USING vec0(feed_url TEXT PRIMARY KEY, embedding float[{dimension}])""" | |
| 134 | + g.db.exec &"""CREATE VIRTUAL TABLE IF NOT EXISTS recs.article_embeddings | |
| 135 | + USING vec0(article_id INTEGER PRIMARY KEY, embedding float[{dimension}])""" | |
| 136 | + | |
| 137 | +proc close*(g: var GleanDb) = | |
| 138 | + g.db.close() | |
| 139 | + | |
| 140 | +proc journalMode*(g: GleanDb, schemaName = ""): string = | |
| 141 | + let sql = if schemaName.len == 0: "PRAGMA journal_mode" | |
| 142 | + else: &"PRAGMA {schemaName}.journal_mode" | |
| 143 | + g.db.queryText(sql).get("") | |
| 144 | + | |
| 145 | +proc attachedSchemas*(g: GleanDb): seq[string] = | |
| 146 | + for row in g.db.rows("PRAGMA database_list"): | |
| 147 | + result.add row[1] | |
| new file mode 100644 | |||
| @@ -0,0 +1,147 @@ | |||
| 1 | +## Glean's database: three SQLite files behind one connection. | ||
| 2 | +## | ||
| 3 | +## `main` holds users and auth, `articles` the feeds and their contents, and | ||
| 4 | +## `recs` the derived recommendation tables. They are separate files attached | ||
| 5 | +## together, so a query can join across them while each still checkpoints and | ||
| 6 | +## vacuums on its own -- and, more practically, so the derived tables can be | ||
| 7 | +## deleted and rebuilt without touching anything a user wrote. | ||
| 8 | +## | ||
| 9 | +## The pragmas are copied from internal/db/db.go. They are not decoration: | ||
| 10 | +## WAL is what lets readers run during a write, and the per-database repeats | ||
| 11 | +## matter because PRAGMA applies to one schema at a time -- setting WAL on | ||
| 12 | +## `main` leaves `articles` journalling the slow way. | ||
| 13 | + | ||
| 14 | +import std/[math, options, strformat, strutils] | ||
| 15 | +import ./schema | ||
| 16 | +import ./sqlite | ||
| 17 | + | ||
| 18 | +type | ||
| 19 | + GleanDb* = object | ||
| 20 | + db*: Db | ||
| 21 | + basePath*: string | ||
| 22 | + | ||
| 23 | +{.push importc, cdecl.} | ||
| 24 | +proc sqlite3_create_function_v2(db: pointer, name: cstring, nArg: cint, | ||
| 25 | + eTextRep: cint, pApp: pointer, | ||
| 26 | + xFunc, xStep, xFinal, xDestroy: pointer): cint | ||
| 27 | +proc sqlite3_value_double(v: pointer): cdouble | ||
| 28 | +proc sqlite3_result_double(ctx: pointer, v: cdouble) | ||
| 29 | +proc sqlite3_result_null(ctx: pointer) | ||
| 30 | +{.pop.} | ||
| 31 | + | ||
| 32 | +const | ||
| 33 | + SQLITE_UTF8 = 1.cint | ||
| 34 | + SQLITE_DETERMINISTIC = 0x000000800.cint | ||
| 35 | + | ||
| 36 | +# Registered because the Go connection registers them. Nothing in the current | ||
| 37 | +# SQL calls either -- they exist so a scoring query can decay a weight | ||
| 38 | +# exponentially without pulling the rows into the application first. | ||
| 39 | +proc sqlExp(ctx: pointer, n: cint, args: ptr UncheckedArray[pointer]) | ||
| 40 | + {.cdecl.} = | ||
| 41 | + if n != 1: | ||
| 42 | + sqlite3_result_null(ctx) | ||
| 43 | + return | ||
| 44 | + sqlite3_result_double(ctx, exp(sqlite3_value_double(args[0])).cdouble) | ||
| 45 | + | ||
| 46 | +proc sqlLog(ctx: pointer, n: cint, args: ptr UncheckedArray[pointer]) | ||
| 47 | + {.cdecl.} = | ||
| 48 | + if n != 1: | ||
| 49 | + sqlite3_result_null(ctx) | ||
| 50 | + return | ||
| 51 | + let v = sqlite3_value_double(args[0]).float | ||
| 52 | + # log(0) is -inf and log(negative) is NaN; SQL callers want neither, and a | ||
| 53 | + # NULL propagates through an ORDER BY far more gracefully. | ||
| 54 | + if v <= 0: | ||
| 55 | + sqlite3_result_null(ctx) | ||
| 56 | + else: | ||
| 57 | + sqlite3_result_double(ctx, ln(v).cdouble) | ||
| 58 | + | ||
| 59 | +proc registerFunctions*(db: Db) = | ||
| 60 | + for (name, fn) in [("exp", sqlExp), ("log", sqlLog)]: | ||
| 61 | + let rc = sqlite3_create_function_v2( | ||
| 62 | + db.handle, name.cstring, 1, | ||
| 63 | + SQLITE_UTF8 or SQLITE_DETERMINISTIC, | ||
| 64 | + nil, cast[pointer](fn), nil, nil, nil) | ||
| 65 | + if rc != SQLITE_OK: | ||
| 66 | + raise newException(SqliteError, "registering SQL function " & name) | ||
| 67 | + | ||
| 68 | +const | ||
| 69 | + MainPragmas = [ | ||
| 70 | + "journal_mode=WAL", | ||
| 71 | + "wal_autocheckpoint=1000", | ||
| 72 | + "busy_timeout=30000", | ||
| 73 | + "synchronous=NORMAL", | ||
| 74 | + "temp_store=FILE", | ||
| 75 | + "mmap_size=268435456", | ||
| 76 | + "auto_vacuum=INCREMENTAL", | ||
| 77 | + ] | ||
| 78 | + AttachedPragmas = [ | ||
| 79 | + "journal_mode=WAL", | ||
| 80 | + "wal_autocheckpoint=1000", | ||
| 81 | + "busy_timeout=30000", | ||
| 82 | + "synchronous=NORMAL", | ||
| 83 | + "temp_store=FILE", | ||
| 84 | + "mmap_size=268435456", | ||
| 85 | + ] | ||
| 86 | + | ||
| 87 | +proc applyPragmas(db: Db, schemaName: string, pragmas: openArray[string]) = | ||
| 88 | + for pragma in pragmas: | ||
| 89 | + # A pragma that will not take is not worth failing a boot over -- some are | ||
| 90 | + # advisory, and mmap_size in particular is refused on filesystems that | ||
| 91 | + # cannot map. The ones that matter are asserted after opening instead. | ||
| 92 | + try: | ||
| 93 | + if schemaName.len == 0: | ||
| 94 | + db.exec &"PRAGMA {pragma}" | ||
| 95 | + else: | ||
| 96 | + db.exec &"PRAGMA {schemaName}.{pragma}" | ||
| 97 | + except SqliteError: | ||
| 98 | + discard | ||
| 99 | + | ||
| 100 | +proc open*(basePath: string): GleanDb = | ||
| 101 | + ## Open `<basePath>_users` and attach `_articles` and `_recs`. | ||
| 102 | + ## | ||
| 103 | + ## The suffixes match the Go layout, so a database written by either side is | ||
| 104 | + ## readable by the other. | ||
| 105 | + let | ||
| 106 | + usersPath = basePath & "_users" | ||
| 107 | + articlesPath = basePath & "_articles" | ||
| 108 | + recsPath = basePath & "_recs" | ||
| 109 | + | ||
| 110 | + var db = sqlite.open(usersPath) | ||
| 111 | + registerFunctions(db) | ||
| 112 | + applyPragmas(db, "", MainPragmas) | ||
| 113 | + | ||
| 114 | + # Quoted and single-quote-escaped: a path is not necessarily tame, and this | ||
| 115 | + # one can come from configuration. | ||
| 116 | + for (name, path) in [("articles", articlesPath), ("recs", recsPath)]: | ||
| 117 | + db.exec &"ATTACH DATABASE '{path.replace(\"'\", \"''\")}' AS {name}" | ||
| 118 | + applyPragmas(db, name, AttachedPragmas) | ||
| 119 | + | ||
| 120 | + GleanDb(db: db, basePath: basePath) | ||
| 121 | + | ||
| 122 | +proc migrate*(g: GleanDb) = | ||
| 123 | + ## Apply the schema. Every statement is IF NOT EXISTS, so this runs on every | ||
| 124 | + ## boot rather than being gated on a version. | ||
| 125 | + for stmt in usersSchema: g.db.exec stmt | ||
| 126 | + for stmt in articlesSchema: g.db.exec stmt | ||
| 127 | + for stmt in recsSchema: g.db.exec stmt | ||
| 128 | + | ||
| 129 | +proc createEmbeddingTables*(g: GleanDb, dimension: int) = | ||
| 130 | + ## The vec0 tables carry their dimension in the DDL, so they cannot live in | ||
| 131 | + ## the static schema -- it comes from GLEAN_EMBED_DIMENSION. | ||
| 132 | + g.db.exec &"""CREATE VIRTUAL TABLE IF NOT EXISTS recs.feed_embeddings | ||
| 133 | + USING vec0(feed_url TEXT PRIMARY KEY, embedding float[{dimension}])""" | ||
| 134 | + g.db.exec &"""CREATE VIRTUAL TABLE IF NOT EXISTS recs.article_embeddings | ||
| 135 | + USING vec0(article_id INTEGER PRIMARY KEY, embedding float[{dimension}])""" | ||
| 136 | + | ||
| 137 | +proc close*(g: var GleanDb) = | ||
| 138 | + g.db.close() | ||
| 139 | + | ||
| 140 | +proc journalMode*(g: GleanDb, schemaName = ""): string = | ||
| 141 | + let sql = if schemaName.len == 0: "PRAGMA journal_mode" | ||
| 142 | + else: &"PRAGMA {schemaName}.journal_mode" | ||
| 143 | + g.db.queryText(sql).get("") | ||
| 144 | + | ||
| 145 | +proc attachedSchemas*(g: GleanDb): seq[string] = | ||
| 146 | + for row in g.db.rows("PRAGMA database_list"): | ||
| 147 | + result.add row[1] | ||
added
spike/nim/atproto/schema.nim +232 -0 | new file mode 100644 | ||
| @@ -0,0 +1,232 @@ | ||
| 1 | +## Glean's schema, in the three databases it uses. | |
| 2 | +## | |
| 3 | +## Extracted mechanically from internal/db/db.go rather than retyped: 44 DDL | |
| 4 | +## statements transcribed by hand would drift from the Go definition, and a | |
| 5 | +## column that differs by a default or a CHECK is the kind of thing that only | |
| 6 | +## shows up as a constraint violation months later. | |
| 7 | +## | |
| 8 | +## Regenerate with tools/extract_schema.py after changing the Go schema. | |
| 9 | + | |
| 10 | +const usersSchema* = [ | |
| 11 | + """CREATE TABLE IF NOT EXISTS users ( | |
| 12 | + did TEXT PRIMARY KEY, | |
| 13 | + indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 14 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 15 | + follows_dirty BOOLEAN NOT NULL DEFAULT 1 | |
| 16 | + )""", | |
| 17 | + """CREATE TABLE IF NOT EXISTS follows ( | |
| 18 | + user_did TEXT NOT NULL, | |
| 19 | + target_did TEXT NOT NULL, | |
| 20 | + uri TEXT, | |
| 21 | + cid TEXT, | |
| 22 | + followed_at DATETIME, | |
| 23 | + PRIMARY KEY (user_did, target_did) | |
| 24 | + )""", | |
| 25 | + """CREATE TABLE IF NOT EXISTS oauth_auth_requests ( | |
| 26 | + state TEXT PRIMARY KEY, | |
| 27 | + data TEXT NOT NULL | |
| 28 | + )""", | |
| 29 | + """CREATE TABLE IF NOT EXISTS oauth_sessions ( | |
| 30 | + account_did TEXT NOT NULL, | |
| 31 | + session_id TEXT NOT NULL, | |
| 32 | + data TEXT NOT NULL, | |
| 33 | + PRIMARY KEY (account_did, session_id) | |
| 34 | + )""", | |
| 35 | + """CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)""", | |
| 36 | + """CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)""", | |
| 37 | + """CREATE TABLE IF NOT EXISTS user_settings ( | |
| 38 | + did TEXT PRIMARY KEY, | |
| 39 | + languages TEXT, | |
| 40 | + expanded_view BOOLEAN NOT NULL DEFAULT 0, | |
| 41 | + digest_enabled BOOLEAN NOT NULL DEFAULT 0, | |
| 42 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 43 | + )""", | |
| 44 | + """CREATE TABLE IF NOT EXISTS dismissed_recommendations ( | |
| 45 | + user_did TEXT NOT NULL, | |
| 46 | + target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article', 'person')), | |
| 47 | + target_id TEXT NOT NULL, | |
| 48 | + reason TEXT, | |
| 49 | + dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 50 | + PRIMARY KEY (user_did, target_type, target_id) | |
| 51 | + )""", | |
| 52 | + """CREATE TABLE IF NOT EXISTS recommendation_impressions ( | |
| 53 | + user_did TEXT NOT NULL, | |
| 54 | + target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article', 'person')), | |
| 55 | + target_id TEXT NOT NULL, | |
| 56 | + first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 57 | + last_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 58 | + shown_count INTEGER NOT NULL DEFAULT 1, | |
| 59 | + acted BOOLEAN NOT NULL DEFAULT 0, | |
| 60 | + PRIMARY KEY (user_did, target_type, target_id) | |
| 61 | + )""", | |
| 62 | + """CREATE INDEX IF NOT EXISTS idx_impressions_user_unacted ON recommendation_impressions(user_did, acted, shown_count)""", | |
| 63 | + """CREATE TABLE IF NOT EXISTS jetstream_cursor ( | |
| 64 | + id INTEGER PRIMARY KEY CHECK(id = 1), | |
| 65 | + cursor_us INTEGER NOT NULL | |
| 66 | + )""", | |
| 67 | +] | |
| 68 | + | |
| 69 | +const articlesSchema* = [ | |
| 70 | + """CREATE TABLE IF NOT EXISTS articles.feeds ( | |
| 71 | + feed_url TEXT PRIMARY KEY, | |
| 72 | + title TEXT, | |
| 73 | + site_url TEXT, | |
| 74 | + description TEXT, | |
| 75 | + feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json', 'atproto')), | |
| 76 | + last_fetched_at DATETIME, | |
| 77 | + last_error TEXT, | |
| 78 | + subscriber_count INTEGER NOT NULL DEFAULT 0, | |
| 79 | + consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0, | |
| 80 | + error_count INTEGER NOT NULL DEFAULT 0, | |
| 81 | + favicon_url TEXT | |
| 82 | + )""", | |
| 83 | + """CREATE TABLE IF NOT EXISTS articles.subscriptions ( | |
| 84 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 85 | + user_did TEXT NOT NULL, | |
| 86 | + feed_url TEXT NOT NULL, | |
| 87 | + title TEXT, | |
| 88 | + category TEXT, | |
| 89 | + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 90 | + uri TEXT, | |
| 91 | + cid TEXT, | |
| 92 | + UNIQUE(user_did, feed_url) | |
| 93 | + )""", | |
| 94 | + """CREATE TABLE IF NOT EXISTS articles.articles ( | |
| 95 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 96 | + feed_url TEXT NOT NULL, | |
| 97 | + guid TEXT NOT NULL, | |
| 98 | + title TEXT NOT NULL DEFAULT '', | |
| 99 | + url TEXT, | |
| 100 | + author TEXT, | |
| 101 | + summary TEXT, | |
| 102 | + content TEXT, | |
| 103 | + full_content TEXT, | |
| 104 | + published DATETIME, | |
| 105 | + updated DATETIME, | |
| 106 | + fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 107 | + language TEXT NOT NULL DEFAULT '', | |
| 108 | + UNIQUE(feed_url, guid) | |
| 109 | + )""", | |
| 110 | + """CREATE TABLE IF NOT EXISTS articles.read_state ( | |
| 111 | + user_did TEXT NOT NULL, | |
| 112 | + article_id INTEGER NOT NULL, | |
| 113 | + is_read BOOLEAN NOT NULL DEFAULT 0, | |
| 114 | + read_at DATETIME, | |
| 115 | + PRIMARY KEY (user_did, article_id) | |
| 116 | + )""", | |
| 117 | + """CREATE TABLE IF NOT EXISTS articles.read_state_history ( | |
| 118 | + user_did TEXT NOT NULL, | |
| 119 | + feed_url TEXT NOT NULL, | |
| 120 | + guid TEXT NOT NULL, | |
| 121 | + is_read BOOLEAN NOT NULL DEFAULT 0, | |
| 122 | + read_at DATETIME, | |
| 123 | + PRIMARY KEY (user_did, feed_url, guid) | |
| 124 | + )""", | |
| 125 | + """CREATE TABLE IF NOT EXISTS articles.annotations ( | |
| 126 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 127 | + uri TEXT NOT NULL UNIQUE, | |
| 128 | + author_did TEXT NOT NULL, | |
| 129 | + feed_url TEXT NOT NULL, | |
| 130 | + article_url TEXT NOT NULL, | |
| 131 | + quote TEXT, | |
| 132 | + note TEXT, | |
| 133 | + tags TEXT, | |
| 134 | + rating INTEGER, | |
| 135 | + created_at DATETIME NOT NULL, | |
| 136 | + cid TEXT | |
| 137 | + )""", | |
| 138 | + """CREATE TABLE IF NOT EXISTS articles.likes ( | |
| 139 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 140 | + uri TEXT NOT NULL UNIQUE, | |
| 141 | + author_did TEXT NOT NULL, | |
| 142 | + feed_url TEXT NOT NULL, | |
| 143 | + article_url TEXT NOT NULL, | |
| 144 | + created_at DATETIME NOT NULL, | |
| 145 | + cid TEXT, | |
| 146 | + UNIQUE(author_did, feed_url, article_url) | |
| 147 | + )""", | |
| 148 | + """CREATE INDEX IF NOT EXISTS articles.idx_subscriptions_feed_user ON subscriptions(feed_url, user_did)""", | |
| 149 | + """CREATE INDEX IF NOT EXISTS articles.idx_subscriptions_uri ON subscriptions(uri)""", | |
| 150 | + """CREATE INDEX IF NOT EXISTS articles.idx_likes_author_feed ON likes(author_did, feed_url, created_at)""", | |
| 151 | + """CREATE INDEX IF NOT EXISTS articles.idx_articles_url ON articles(url)""", | |
| 152 | + """CREATE INDEX IF NOT EXISTS articles.idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0""", | |
| 153 | + """CREATE INDEX IF NOT EXISTS articles.idx_annotations_article ON annotations(article_url)""", | |
| 154 | + """CREATE INDEX IF NOT EXISTS articles.idx_annotations_author ON annotations(author_did)""", | |
| 155 | + """CREATE INDEX IF NOT EXISTS articles.idx_annotations_created_at ON annotations(created_at DESC)""", | |
| 156 | + """CREATE INDEX IF NOT EXISTS articles.idx_likes_created_at ON likes(created_at DESC)""", | |
| 157 | + """CREATE INDEX IF NOT EXISTS articles.idx_articles_language ON articles(language)""", | |
| 158 | + """CREATE INDEX IF NOT EXISTS articles.idx_likes_article_created ON likes(feed_url, article_url, created_at)""", | |
| 159 | + """CREATE INDEX IF NOT EXISTS articles.idx_annotations_feed_article_created ON annotations(feed_url, article_url, created_at)""", | |
| 160 | + """CREATE INDEX IF NOT EXISTS articles.idx_likes_created_author ON likes(created_at, author_did)""", | |
| 161 | + """CREATE VIRTUAL TABLE IF NOT EXISTS articles.articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)""", | |
| 162 | + """CREATE TRIGGER IF NOT EXISTS articles.articles_ai AFTER INSERT ON articles BEGIN | |
| 163 | + INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author); | |
| 164 | + END""", | |
| 165 | + """CREATE TRIGGER IF NOT EXISTS articles.articles_ad AFTER DELETE ON articles BEGIN | |
| 166 | + INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author); | |
| 167 | + END""", | |
| 168 | + """CREATE TRIGGER IF NOT EXISTS articles.articles_au AFTER UPDATE ON articles BEGIN | |
| 169 | + INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author); | |
| 170 | + INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author); | |
| 171 | + END""", | |
| 172 | +] | |
| 173 | + | |
| 174 | +const recsSchema* = [ | |
| 175 | + """CREATE TABLE IF NOT EXISTS recs.feed_similarity ( | |
| 176 | + feed_a TEXT NOT NULL, | |
| 177 | + feed_b TEXT NOT NULL, | |
| 178 | + jaccard REAL NOT NULL, | |
| 179 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 180 | + PRIMARY KEY (feed_a, feed_b), | |
| 181 | + CHECK(feed_a < feed_b) | |
| 182 | + )""", | |
| 183 | + """CREATE TABLE IF NOT EXISTS recs.user_similarity ( | |
| 184 | + user_a TEXT NOT NULL, | |
| 185 | + user_b TEXT NOT NULL, | |
| 186 | + jaccard REAL NOT NULL, | |
| 187 | + common_feeds INTEGER NOT NULL, | |
| 188 | + common_likes INTEGER NOT NULL DEFAULT 0, | |
| 189 | + common_tags INTEGER NOT NULL DEFAULT 0, | |
| 190 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 191 | + PRIMARY KEY (user_a, user_b), | |
| 192 | + CHECK(user_a < user_b) | |
| 193 | + )""", | |
| 194 | + """CREATE TABLE IF NOT EXISTS recs.follow_distances ( | |
| 195 | + user_a TEXT NOT NULL, | |
| 196 | + user_b TEXT NOT NULL, | |
| 197 | + distance INTEGER NOT NULL CHECK(distance IN (1, 2, 3)), | |
| 198 | + PRIMARY KEY (user_a, user_b) | |
| 199 | + )""", | |
| 200 | + """CREATE TABLE IF NOT EXISTS recs.user_signal_weights ( | |
| 201 | + user_did TEXT PRIMARY KEY, | |
| 202 | + w_sub REAL NOT NULL DEFAULT 1.0, | |
| 203 | + w_like REAL NOT NULL DEFAULT 0.5, | |
| 204 | + w_tag REAL NOT NULL DEFAULT 0.3, | |
| 205 | + w_social REAL NOT NULL DEFAULT 0.7, | |
| 206 | + w_pop REAL NOT NULL DEFAULT 0.2, | |
| 207 | + w_category REAL NOT NULL DEFAULT 0.4, | |
| 208 | + w_content REAL NOT NULL DEFAULT 0.4, | |
| 209 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 210 | + )""", | |
| 211 | + """CREATE TABLE IF NOT EXISTS recs.user_signal_profiles ( | |
| 212 | + user_did TEXT PRIMARY KEY, | |
| 213 | + total_likes INTEGER NOT NULL DEFAULT 0, | |
| 214 | + total_tags INTEGER NOT NULL DEFAULT 0, | |
| 215 | + top_categories TEXT, | |
| 216 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 217 | + )""", | |
| 218 | + """CREATE TABLE IF NOT EXISTS recs.feed_embedding_meta ( | |
| 219 | + feed_url TEXT PRIMARY KEY, | |
| 220 | + source_text TEXT NOT NULL DEFAULT '' | |
| 221 | + )""", | |
| 222 | + """CREATE INDEX IF NOT EXISTS recs.idx_follow_distances_a_dist ON follow_distances(user_a, distance)""", | |
| 223 | + """CREATE INDEX IF NOT EXISTS recs.idx_user_similarity_b ON user_similarity(user_b)""", | |
| 224 | + """CREATE TABLE IF NOT EXISTS recs.precomputed_recommendations ( | |
| 225 | + user_did TEXT NOT NULL, | |
| 226 | + rec_type TEXT NOT NULL CHECK(rec_type IN ('feed', 'article', 'person')), | |
| 227 | + data TEXT NOT NULL, | |
| 228 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 229 | + PRIMARY KEY (user_did, rec_type) | |
| 230 | + )""", | |
| 231 | +] | |
| 232 | + | |
| new file mode 100644 | |||
| @@ -0,0 +1,232 @@ | |||
| 1 | +## Glean's schema, in the three databases it uses. | ||
| 2 | +## | ||
| 3 | +## Extracted mechanically from internal/db/db.go rather than retyped: 44 DDL | ||
| 4 | +## statements transcribed by hand would drift from the Go definition, and a | ||
| 5 | +## column that differs by a default or a CHECK is the kind of thing that only | ||
| 6 | +## shows up as a constraint violation months later. | ||
| 7 | +## | ||
| 8 | +## Regenerate with tools/extract_schema.py after changing the Go schema. | ||
| 9 | + | ||
| 10 | +const usersSchema* = [ | ||
| 11 | + """CREATE TABLE IF NOT EXISTS users ( | ||
| 12 | + did TEXT PRIMARY KEY, | ||
| 13 | + indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 14 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 15 | + follows_dirty BOOLEAN NOT NULL DEFAULT 1 | ||
| 16 | + )""", | ||
| 17 | + """CREATE TABLE IF NOT EXISTS follows ( | ||
| 18 | + user_did TEXT NOT NULL, | ||
| 19 | + target_did TEXT NOT NULL, | ||
| 20 | + uri TEXT, | ||
| 21 | + cid TEXT, | ||
| 22 | + followed_at DATETIME, | ||
| 23 | + PRIMARY KEY (user_did, target_did) | ||
| 24 | + )""", | ||
| 25 | + """CREATE TABLE IF NOT EXISTS oauth_auth_requests ( | ||
| 26 | + state TEXT PRIMARY KEY, | ||
| 27 | + data TEXT NOT NULL | ||
| 28 | + )""", | ||
| 29 | + """CREATE TABLE IF NOT EXISTS oauth_sessions ( | ||
| 30 | + account_did TEXT NOT NULL, | ||
| 31 | + session_id TEXT NOT NULL, | ||
| 32 | + data TEXT NOT NULL, | ||
| 33 | + PRIMARY KEY (account_did, session_id) | ||
| 34 | + )""", | ||
| 35 | + """CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)""", | ||
| 36 | + """CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)""", | ||
| 37 | + """CREATE TABLE IF NOT EXISTS user_settings ( | ||
| 38 | + did TEXT PRIMARY KEY, | ||
| 39 | + languages TEXT, | ||
| 40 | + expanded_view BOOLEAN NOT NULL DEFAULT 0, | ||
| 41 | + digest_enabled BOOLEAN NOT NULL DEFAULT 0, | ||
| 42 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | ||
| 43 | + )""", | ||
| 44 | + """CREATE TABLE IF NOT EXISTS dismissed_recommendations ( | ||
| 45 | + user_did TEXT NOT NULL, | ||
| 46 | + target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article', 'person')), | ||
| 47 | + target_id TEXT NOT NULL, | ||
| 48 | + reason TEXT, | ||
| 49 | + dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 50 | + PRIMARY KEY (user_did, target_type, target_id) | ||
| 51 | + )""", | ||
| 52 | + """CREATE TABLE IF NOT EXISTS recommendation_impressions ( | ||
| 53 | + user_did TEXT NOT NULL, | ||
| 54 | + target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article', 'person')), | ||
| 55 | + target_id TEXT NOT NULL, | ||
| 56 | + first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 57 | + last_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 58 | + shown_count INTEGER NOT NULL DEFAULT 1, | ||
| 59 | + acted BOOLEAN NOT NULL DEFAULT 0, | ||
| 60 | + PRIMARY KEY (user_did, target_type, target_id) | ||
| 61 | + )""", | ||
| 62 | + """CREATE INDEX IF NOT EXISTS idx_impressions_user_unacted ON recommendation_impressions(user_did, acted, shown_count)""", | ||
| 63 | + """CREATE TABLE IF NOT EXISTS jetstream_cursor ( | ||
| 64 | + id INTEGER PRIMARY KEY CHECK(id = 1), | ||
| 65 | + cursor_us INTEGER NOT NULL | ||
| 66 | + )""", | ||
| 67 | +] | ||
| 68 | + | ||
| 69 | +const articlesSchema* = [ | ||
| 70 | + """CREATE TABLE IF NOT EXISTS articles.feeds ( | ||
| 71 | + feed_url TEXT PRIMARY KEY, | ||
| 72 | + title TEXT, | ||
| 73 | + site_url TEXT, | ||
| 74 | + description TEXT, | ||
| 75 | + feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json', 'atproto')), | ||
| 76 | + last_fetched_at DATETIME, | ||
| 77 | + last_error TEXT, | ||
| 78 | + subscriber_count INTEGER NOT NULL DEFAULT 0, | ||
| 79 | + consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0, | ||
| 80 | + error_count INTEGER NOT NULL DEFAULT 0, | ||
| 81 | + favicon_url TEXT | ||
| 82 | + )""", | ||
| 83 | + """CREATE TABLE IF NOT EXISTS articles.subscriptions ( | ||
| 84 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 85 | + user_did TEXT NOT NULL, | ||
| 86 | + feed_url TEXT NOT NULL, | ||
| 87 | + title TEXT, | ||
| 88 | + category TEXT, | ||
| 89 | + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 90 | + uri TEXT, | ||
| 91 | + cid TEXT, | ||
| 92 | + UNIQUE(user_did, feed_url) | ||
| 93 | + )""", | ||
| 94 | + """CREATE TABLE IF NOT EXISTS articles.articles ( | ||
| 95 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 96 | + feed_url TEXT NOT NULL, | ||
| 97 | + guid TEXT NOT NULL, | ||
| 98 | + title TEXT NOT NULL DEFAULT '', | ||
| 99 | + url TEXT, | ||
| 100 | + author TEXT, | ||
| 101 | + summary TEXT, | ||
| 102 | + content TEXT, | ||
| 103 | + full_content TEXT, | ||
| 104 | + published DATETIME, | ||
| 105 | + updated DATETIME, | ||
| 106 | + fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 107 | + language TEXT NOT NULL DEFAULT '', | ||
| 108 | + UNIQUE(feed_url, guid) | ||
| 109 | + )""", | ||
| 110 | + """CREATE TABLE IF NOT EXISTS articles.read_state ( | ||
| 111 | + user_did TEXT NOT NULL, | ||
| 112 | + article_id INTEGER NOT NULL, | ||
| 113 | + is_read BOOLEAN NOT NULL DEFAULT 0, | ||
| 114 | + read_at DATETIME, | ||
| 115 | + PRIMARY KEY (user_did, article_id) | ||
| 116 | + )""", | ||
| 117 | + """CREATE TABLE IF NOT EXISTS articles.read_state_history ( | ||
| 118 | + user_did TEXT NOT NULL, | ||
| 119 | + feed_url TEXT NOT NULL, | ||
| 120 | + guid TEXT NOT NULL, | ||
| 121 | + is_read BOOLEAN NOT NULL DEFAULT 0, | ||
| 122 | + read_at DATETIME, | ||
| 123 | + PRIMARY KEY (user_did, feed_url, guid) | ||
| 124 | + )""", | ||
| 125 | + """CREATE TABLE IF NOT EXISTS articles.annotations ( | ||
| 126 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 127 | + uri TEXT NOT NULL UNIQUE, | ||
| 128 | + author_did TEXT NOT NULL, | ||
| 129 | + feed_url TEXT NOT NULL, | ||
| 130 | + article_url TEXT NOT NULL, | ||
| 131 | + quote TEXT, | ||
| 132 | + note TEXT, | ||
| 133 | + tags TEXT, | ||
| 134 | + rating INTEGER, | ||
| 135 | + created_at DATETIME NOT NULL, | ||
| 136 | + cid TEXT | ||
| 137 | + )""", | ||
| 138 | + """CREATE TABLE IF NOT EXISTS articles.likes ( | ||
| 139 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 140 | + uri TEXT NOT NULL UNIQUE, | ||
| 141 | + author_did TEXT NOT NULL, | ||
| 142 | + feed_url TEXT NOT NULL, | ||
| 143 | + article_url TEXT NOT NULL, | ||
| 144 | + created_at DATETIME NOT NULL, | ||
| 145 | + cid TEXT, | ||
| 146 | + UNIQUE(author_did, feed_url, article_url) | ||
| 147 | + )""", | ||
| 148 | + """CREATE INDEX IF NOT EXISTS articles.idx_subscriptions_feed_user ON subscriptions(feed_url, user_did)""", | ||
| 149 | + """CREATE INDEX IF NOT EXISTS articles.idx_subscriptions_uri ON subscriptions(uri)""", | ||
| 150 | + """CREATE INDEX IF NOT EXISTS articles.idx_likes_author_feed ON likes(author_did, feed_url, created_at)""", | ||
| 151 | + """CREATE INDEX IF NOT EXISTS articles.idx_articles_url ON articles(url)""", | ||
| 152 | + """CREATE INDEX IF NOT EXISTS articles.idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0""", | ||
| 153 | + """CREATE INDEX IF NOT EXISTS articles.idx_annotations_article ON annotations(article_url)""", | ||
| 154 | + """CREATE INDEX IF NOT EXISTS articles.idx_annotations_author ON annotations(author_did)""", | ||
| 155 | + """CREATE INDEX IF NOT EXISTS articles.idx_annotations_created_at ON annotations(created_at DESC)""", | ||
| 156 | + """CREATE INDEX IF NOT EXISTS articles.idx_likes_created_at ON likes(created_at DESC)""", | ||
| 157 | + """CREATE INDEX IF NOT EXISTS articles.idx_articles_language ON articles(language)""", | ||
| 158 | + """CREATE INDEX IF NOT EXISTS articles.idx_likes_article_created ON likes(feed_url, article_url, created_at)""", | ||
| 159 | + """CREATE INDEX IF NOT EXISTS articles.idx_annotations_feed_article_created ON annotations(feed_url, article_url, created_at)""", | ||
| 160 | + """CREATE INDEX IF NOT EXISTS articles.idx_likes_created_author ON likes(created_at, author_did)""", | ||
| 161 | + """CREATE VIRTUAL TABLE IF NOT EXISTS articles.articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)""", | ||
| 162 | + """CREATE TRIGGER IF NOT EXISTS articles.articles_ai AFTER INSERT ON articles BEGIN | ||
| 163 | + INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author); | ||
| 164 | + END""", | ||
| 165 | + """CREATE TRIGGER IF NOT EXISTS articles.articles_ad AFTER DELETE ON articles BEGIN | ||
| 166 | + INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author); | ||
| 167 | + END""", | ||
| 168 | + """CREATE TRIGGER IF NOT EXISTS articles.articles_au AFTER UPDATE ON articles BEGIN | ||
| 169 | + INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author); | ||
| 170 | + INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author); | ||
| 171 | + END""", | ||
| 172 | +] | ||
| 173 | + | ||
| 174 | +const recsSchema* = [ | ||
| 175 | + """CREATE TABLE IF NOT EXISTS recs.feed_similarity ( | ||
| 176 | + feed_a TEXT NOT NULL, | ||
| 177 | + feed_b TEXT NOT NULL, | ||
| 178 | + jaccard REAL NOT NULL, | ||
| 179 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 180 | + PRIMARY KEY (feed_a, feed_b), | ||
| 181 | + CHECK(feed_a < feed_b) | ||
| 182 | + )""", | ||
| 183 | + """CREATE TABLE IF NOT EXISTS recs.user_similarity ( | ||
| 184 | + user_a TEXT NOT NULL, | ||
| 185 | + user_b TEXT NOT NULL, | ||
| 186 | + jaccard REAL NOT NULL, | ||
| 187 | + common_feeds INTEGER NOT NULL, | ||
| 188 | + common_likes INTEGER NOT NULL DEFAULT 0, | ||
| 189 | + common_tags INTEGER NOT NULL DEFAULT 0, | ||
| 190 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 191 | + PRIMARY KEY (user_a, user_b), | ||
| 192 | + CHECK(user_a < user_b) | ||
| 193 | + )""", | ||
| 194 | + """CREATE TABLE IF NOT EXISTS recs.follow_distances ( | ||
| 195 | + user_a TEXT NOT NULL, | ||
| 196 | + user_b TEXT NOT NULL, | ||
| 197 | + distance INTEGER NOT NULL CHECK(distance IN (1, 2, 3)), | ||
| 198 | + PRIMARY KEY (user_a, user_b) | ||
| 199 | + )""", | ||
| 200 | + """CREATE TABLE IF NOT EXISTS recs.user_signal_weights ( | ||
| 201 | + user_did TEXT PRIMARY KEY, | ||
| 202 | + w_sub REAL NOT NULL DEFAULT 1.0, | ||
| 203 | + w_like REAL NOT NULL DEFAULT 0.5, | ||
| 204 | + w_tag REAL NOT NULL DEFAULT 0.3, | ||
| 205 | + w_social REAL NOT NULL DEFAULT 0.7, | ||
| 206 | + w_pop REAL NOT NULL DEFAULT 0.2, | ||
| 207 | + w_category REAL NOT NULL DEFAULT 0.4, | ||
| 208 | + w_content REAL NOT NULL DEFAULT 0.4, | ||
| 209 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | ||
| 210 | + )""", | ||
| 211 | + """CREATE TABLE IF NOT EXISTS recs.user_signal_profiles ( | ||
| 212 | + user_did TEXT PRIMARY KEY, | ||
| 213 | + total_likes INTEGER NOT NULL DEFAULT 0, | ||
| 214 | + total_tags INTEGER NOT NULL DEFAULT 0, | ||
| 215 | + top_categories TEXT, | ||
| 216 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | ||
| 217 | + )""", | ||
| 218 | + """CREATE TABLE IF NOT EXISTS recs.feed_embedding_meta ( | ||
| 219 | + feed_url TEXT PRIMARY KEY, | ||
| 220 | + source_text TEXT NOT NULL DEFAULT '' | ||
| 221 | + )""", | ||
| 222 | + """CREATE INDEX IF NOT EXISTS recs.idx_follow_distances_a_dist ON follow_distances(user_a, distance)""", | ||
| 223 | + """CREATE INDEX IF NOT EXISTS recs.idx_user_similarity_b ON user_similarity(user_b)""", | ||
| 224 | + """CREATE TABLE IF NOT EXISTS recs.precomputed_recommendations ( | ||
| 225 | + user_did TEXT NOT NULL, | ||
| 226 | + rec_type TEXT NOT NULL CHECK(rec_type IN ('feed', 'article', 'person')), | ||
| 227 | + data TEXT NOT NULL, | ||
| 228 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 229 | + PRIMARY KEY (user_did, rec_type) | ||
| 230 | + )""", | ||
| 231 | +] | ||
| 232 | + | ||
added
spike/nim/gleandb_probe.nim +161 -0 | new file mode 100644 | ||
| @@ -0,0 +1,161 @@ | ||
| 1 | +## Checks the three-database layout, the schema, and the behaviour that | |
| 2 | +## depends on both. | |
| 3 | +## | |
| 4 | +## nim c -r -d:ssl gleandb_probe.nim | |
| 5 | +## | |
| 6 | +## Most of this is about things that fail quietly. An ATTACH that did not | |
| 7 | +## happen, a pragma that did not take, an FTS index whose triggers never fire | |
| 8 | +## -- each leaves a database that works for the first query anyone tries and | |
| 9 | +## degrades or diverges later. | |
| 10 | + | |
| 11 | +import std/[options, os, strformat, strutils] | |
| 12 | +import atproto/[gleandb, schema, sqlite] | |
| 13 | + | |
| 14 | +var failures = 0 | |
| 15 | + | |
| 16 | +proc report(name: string, ok: bool, detail = "") = | |
| 17 | + if not ok: inc failures | |
| 18 | + let label = if ok: "PASS" else: "FAIL" | |
| 19 | + if detail.len > 0: echo &" {label} {name} -- {detail}" | |
| 20 | + else: echo &" {label} {name}" | |
| 21 | + | |
| 22 | +proc cleanup(base: string) = | |
| 23 | + for suffix in ["_users", "_articles", "_recs"]: | |
| 24 | + for ext in ["", "-wal", "-shm"]: | |
| 25 | + removeFile(base & suffix & ext) | |
| 26 | + | |
| 27 | +proc main() = | |
| 28 | + echo "Glean database layer probe" | |
| 29 | + echo "" | |
| 30 | + | |
| 31 | + let base = getTempDir() / "glean_db_probe" | |
| 32 | + cleanup(base) | |
| 33 | + defer: cleanup(base) | |
| 34 | + | |
| 35 | + var g = gleandb.open(base) | |
| 36 | + defer: g.close() | |
| 37 | + | |
| 38 | + report("schema statements extracted from the Go source", | |
| 39 | + usersSchema.len + articlesSchema.len + recsSchema.len == 44, | |
| 40 | + &"{usersSchema.len} + {articlesSchema.len} + {recsSchema.len}") | |
| 41 | + | |
| 42 | + # --- the three databases ------------------------------------------------ | |
| 43 | + block: | |
| 44 | + let schemas = g.attachedSchemas() | |
| 45 | + report("all three databases are attached", | |
| 46 | + "main" in schemas and "articles" in schemas and "recs" in schemas, | |
| 47 | + schemas.join(", ")) | |
| 48 | + report("each is a separate file on disk", | |
| 49 | + fileExists(base & "_users") and fileExists(base & "_articles") and | |
| 50 | + fileExists(base & "_recs")) | |
| 51 | + | |
| 52 | + # --- pragmas ------------------------------------------------------------ | |
| 53 | + # WAL has to be set per schema: PRAGMA applies to one at a time, so setting | |
| 54 | + # it on main leaves the others journalling the slow way. | |
| 55 | + block: | |
| 56 | + for name in ["", "articles", "recs"]: | |
| 57 | + let mode = g.journalMode(name).toLowerAscii | |
| 58 | + report(&"WAL is on for {(if name.len == 0: \"main\" else: name)}", | |
| 59 | + mode == "wal", mode) | |
| 60 | + | |
| 61 | + # --- custom SQL functions ---------------------------------------------- | |
| 62 | + block: | |
| 63 | + let e = g.db.queryText("SELECT exp(1.0)") | |
| 64 | + report("exp() is callable from SQL", | |
| 65 | + e.isSome and e.get.startsWith("2.718"), e.get("")) | |
| 66 | + let l = g.db.queryText("SELECT log(2.718281828459045)") | |
| 67 | + report("log() is callable from SQL", | |
| 68 | + l.isSome and l.get.startsWith("1.0") or l.get("") == "1.0", l.get("")) | |
| 69 | + # log(0) is -inf; returning NULL keeps an ORDER BY sane. | |
| 70 | + report("log() of zero is NULL", | |
| 71 | + g.db.queryText("SELECT log(0)").isNone) | |
| 72 | + report("log() of a negative is NULL", | |
| 73 | + g.db.queryText("SELECT log(-5)").isNone) | |
| 74 | + | |
| 75 | + # --- schema ------------------------------------------------------------- | |
| 76 | + g.migrate() | |
| 77 | + report("migrate() is idempotent", (g.migrate(); true)) | |
| 78 | + | |
| 79 | + block: | |
| 80 | + proc tableCount(schemaName: string): int = | |
| 81 | + let n = g.db.queryInt( | |
| 82 | + &"SELECT count(*) FROM {schemaName}.sqlite_master WHERE type='table'") | |
| 83 | + n.get(0).int | |
| 84 | + report("users tables created", tableCount("main") >= 7, $tableCount("main")) | |
| 85 | + report("articles tables created", tableCount("articles") >= 7, | |
| 86 | + $tableCount("articles")) | |
| 87 | + report("recs tables created", tableCount("recs") >= 7, $tableCount("recs")) | |
| 88 | + | |
| 89 | + # --- a real cross-database query --------------------------------------- | |
| 90 | + # The whole point of attaching rather than opening three connections. | |
| 91 | + block: | |
| 92 | + # `users` is keyed on the DID alone -- handles are resolved from atproto | |
| 93 | + # rather than stored, so there is no handle column to join on. | |
| 94 | + g.db.run("INSERT INTO users (did) VALUES (?)", p("did:plc:probe")) | |
| 95 | + g.db.run("""INSERT INTO articles.feeds (feed_url, title) | |
| 96 | + VALUES (?, ?)""", p("https://ex.test/feed"), p("Example")) | |
| 97 | + g.db.run("""INSERT INTO articles.subscriptions (user_did, feed_url) | |
| 98 | + VALUES (?, ?)""", p("did:plc:probe"), p("https://ex.test/feed")) | |
| 99 | + | |
| 100 | + let joined = g.db.queryText(""" | |
| 101 | + SELECT f.title FROM articles.subscriptions s | |
| 102 | + JOIN users u ON u.did = s.user_did | |
| 103 | + JOIN articles.feeds f ON f.feed_url = s.feed_url | |
| 104 | + WHERE u.did = ?""", p("did:plc:probe")) | |
| 105 | + report("a query joins across attached databases", | |
| 106 | + joined == some("Example"), joined.get("nothing")) | |
| 107 | + | |
| 108 | + # --- FTS5 --------------------------------------------------------------- | |
| 109 | + # The index is kept current by triggers, so inserting into `articles` alone | |
| 110 | + # must make the row findable. If the triggers were missed, search silently | |
| 111 | + # returns nothing while everything else looks fine. | |
| 112 | + block: | |
| 113 | + g.db.run("""INSERT INTO articles.articles | |
| 114 | + (feed_url, guid, title, summary, content, author) | |
| 115 | + VALUES (?, ?, ?, ?, ?, ?)""", | |
| 116 | + p("https://ex.test/feed"), p("g1"), | |
| 117 | + p("Nim systems programming"), p("a summary"), | |
| 118 | + p("the body text"), p("An Author")) | |
| 119 | + | |
| 120 | + let hit = g.db.queryText( | |
| 121 | + "SELECT title FROM articles.articles_fts WHERE articles_fts MATCH ?", | |
| 122 | + p("systems")) | |
| 123 | + report("FTS5 triggers index new rows", hit.isSome, hit.get("no match")) | |
| 124 | + | |
| 125 | + g.db.run("UPDATE articles.articles SET title = ? WHERE guid = ?", | |
| 126 | + p("Rust systems programming"), p("g1")) | |
| 127 | + let updated = g.db.queryText( | |
| 128 | + "SELECT title FROM articles.articles_fts WHERE articles_fts MATCH ?", | |
| 129 | + p("Rust")) | |
| 130 | + report("FTS5 triggers follow updates", updated.isSome, updated.get("no match")) | |
| 131 | + | |
| 132 | + g.db.run("DELETE FROM articles.articles WHERE guid = ?", p("g1")) | |
| 133 | + let afterDelete = g.db.queryText( | |
| 134 | + "SELECT title FROM articles.articles_fts WHERE articles_fts MATCH ?", | |
| 135 | + p("systems")) | |
| 136 | + # The detail is only interesting when this fails, and printing the | |
| 137 | + # fallback on success reads like the opposite of what happened. | |
| 138 | + report("FTS5 triggers follow deletes", afterDelete.isNone, | |
| 139 | + if afterDelete.isNone: "row gone from the index" | |
| 140 | + else: "STILL INDEXED: " & afterDelete.get) | |
| 141 | + | |
| 142 | + # --- vec0 --------------------------------------------------------------- | |
| 143 | + block: | |
| 144 | + g.createEmbeddingTables(dimension = 4) | |
| 145 | + report("embedding tables take their dimension from config", | |
| 146 | + g.db.queryInt( | |
| 147 | + "SELECT count(*) FROM recs.sqlite_master WHERE name LIKE '%embeddings%'" | |
| 148 | + ).get(0) > 0) | |
| 149 | + # Proves sqlite-vec is registered on this connection, not just linked. | |
| 150 | + let ver = g.db.queryText("SELECT vec_version()") | |
| 151 | + report("sqlite-vec is live on the glean connection", ver.isSome, ver.get("")) | |
| 152 | + | |
| 153 | + echo "" | |
| 154 | + if failures == 0: | |
| 155 | + echo "RESULT: the database layer matches Glean's layout and behaviour." | |
| 156 | + else: | |
| 157 | + echo &"RESULT: {failures} check(s) failed." | |
| 158 | + quit 1 | |
| 159 | + | |
| 160 | +when isMainModule: | |
| 161 | + main() | |
| new file mode 100644 | |||
| @@ -0,0 +1,161 @@ | |||
| 1 | +## Checks the three-database layout, the schema, and the behaviour that | ||
| 2 | +## depends on both. | ||
| 3 | +## | ||
| 4 | +## nim c -r -d:ssl gleandb_probe.nim | ||
| 5 | +## | ||
| 6 | +## Most of this is about things that fail quietly. An ATTACH that did not | ||
| 7 | +## happen, a pragma that did not take, an FTS index whose triggers never fire | ||
| 8 | +## -- each leaves a database that works for the first query anyone tries and | ||
| 9 | +## degrades or diverges later. | ||
| 10 | + | ||
| 11 | +import std/[options, os, strformat, strutils] | ||
| 12 | +import atproto/[gleandb, schema, sqlite] | ||
| 13 | + | ||
| 14 | +var failures = 0 | ||
| 15 | + | ||
| 16 | +proc report(name: string, ok: bool, detail = "") = | ||
| 17 | + if not ok: inc failures | ||
| 18 | + let label = if ok: "PASS" else: "FAIL" | ||
| 19 | + if detail.len > 0: echo &" {label} {name} -- {detail}" | ||
| 20 | + else: echo &" {label} {name}" | ||
| 21 | + | ||
| 22 | +proc cleanup(base: string) = | ||
| 23 | + for suffix in ["_users", "_articles", "_recs"]: | ||
| 24 | + for ext in ["", "-wal", "-shm"]: | ||
| 25 | + removeFile(base & suffix & ext) | ||
| 26 | + | ||
| 27 | +proc main() = | ||
| 28 | + echo "Glean database layer probe" | ||
| 29 | + echo "" | ||
| 30 | + | ||
| 31 | + let base = getTempDir() / "glean_db_probe" | ||
| 32 | + cleanup(base) | ||
| 33 | + defer: cleanup(base) | ||
| 34 | + | ||
| 35 | + var g = gleandb.open(base) | ||
| 36 | + defer: g.close() | ||
| 37 | + | ||
| 38 | + report("schema statements extracted from the Go source", | ||
| 39 | + usersSchema.len + articlesSchema.len + recsSchema.len == 44, | ||
| 40 | + &"{usersSchema.len} + {articlesSchema.len} + {recsSchema.len}") | ||
| 41 | + | ||
| 42 | + # --- the three databases ------------------------------------------------ | ||
| 43 | + block: | ||
| 44 | + let schemas = g.attachedSchemas() | ||
| 45 | + report("all three databases are attached", | ||
| 46 | + "main" in schemas and "articles" in schemas and "recs" in schemas, | ||
| 47 | + schemas.join(", ")) | ||
| 48 | + report("each is a separate file on disk", | ||
| 49 | + fileExists(base & "_users") and fileExists(base & "_articles") and | ||
| 50 | + fileExists(base & "_recs")) | ||
| 51 | + | ||
| 52 | + # --- pragmas ------------------------------------------------------------ | ||
| 53 | + # WAL has to be set per schema: PRAGMA applies to one at a time, so setting | ||
| 54 | + # it on main leaves the others journalling the slow way. | ||
| 55 | + block: | ||
| 56 | + for name in ["", "articles", "recs"]: | ||
| 57 | + let mode = g.journalMode(name).toLowerAscii | ||
| 58 | + report(&"WAL is on for {(if name.len == 0: \"main\" else: name)}", | ||
| 59 | + mode == "wal", mode) | ||
| 60 | + | ||
| 61 | + # --- custom SQL functions ---------------------------------------------- | ||
| 62 | + block: | ||
| 63 | + let e = g.db.queryText("SELECT exp(1.0)") | ||
| 64 | + report("exp() is callable from SQL", | ||
| 65 | + e.isSome and e.get.startsWith("2.718"), e.get("")) | ||
| 66 | + let l = g.db.queryText("SELECT log(2.718281828459045)") | ||
| 67 | + report("log() is callable from SQL", | ||
| 68 | + l.isSome and l.get.startsWith("1.0") or l.get("") == "1.0", l.get("")) | ||
| 69 | + # log(0) is -inf; returning NULL keeps an ORDER BY sane. | ||
| 70 | + report("log() of zero is NULL", | ||
| 71 | + g.db.queryText("SELECT log(0)").isNone) | ||
| 72 | + report("log() of a negative is NULL", | ||
| 73 | + g.db.queryText("SELECT log(-5)").isNone) | ||
| 74 | + | ||
| 75 | + # --- schema ------------------------------------------------------------- | ||
| 76 | + g.migrate() | ||
| 77 | + report("migrate() is idempotent", (g.migrate(); true)) | ||
| 78 | + | ||
| 79 | + block: | ||
| 80 | + proc tableCount(schemaName: string): int = | ||
| 81 | + let n = g.db.queryInt( | ||
| 82 | + &"SELECT count(*) FROM {schemaName}.sqlite_master WHERE type='table'") | ||
| 83 | + n.get(0).int | ||
| 84 | + report("users tables created", tableCount("main") >= 7, $tableCount("main")) | ||
| 85 | + report("articles tables created", tableCount("articles") >= 7, | ||
| 86 | + $tableCount("articles")) | ||
| 87 | + report("recs tables created", tableCount("recs") >= 7, $tableCount("recs")) | ||
| 88 | + | ||
| 89 | + # --- a real cross-database query --------------------------------------- | ||
| 90 | + # The whole point of attaching rather than opening three connections. | ||
| 91 | + block: | ||
| 92 | + # `users` is keyed on the DID alone -- handles are resolved from atproto | ||
| 93 | + # rather than stored, so there is no handle column to join on. | ||
| 94 | + g.db.run("INSERT INTO users (did) VALUES (?)", p("did:plc:probe")) | ||
| 95 | + g.db.run("""INSERT INTO articles.feeds (feed_url, title) | ||
| 96 | + VALUES (?, ?)""", p("https://ex.test/feed"), p("Example")) | ||
| 97 | + g.db.run("""INSERT INTO articles.subscriptions (user_did, feed_url) | ||
| 98 | + VALUES (?, ?)""", p("did:plc:probe"), p("https://ex.test/feed")) | ||
| 99 | + | ||
| 100 | + let joined = g.db.queryText(""" | ||
| 101 | + SELECT f.title FROM articles.subscriptions s | ||
| 102 | + JOIN users u ON u.did = s.user_did | ||
| 103 | + JOIN articles.feeds f ON f.feed_url = s.feed_url | ||
| 104 | + WHERE u.did = ?""", p("did:plc:probe")) | ||
| 105 | + report("a query joins across attached databases", | ||
| 106 | + joined == some("Example"), joined.get("nothing")) | ||
| 107 | + | ||
| 108 | + # --- FTS5 --------------------------------------------------------------- | ||
| 109 | + # The index is kept current by triggers, so inserting into `articles` alone | ||
| 110 | + # must make the row findable. If the triggers were missed, search silently | ||
| 111 | + # returns nothing while everything else looks fine. | ||
| 112 | + block: | ||
| 113 | + g.db.run("""INSERT INTO articles.articles | ||
| 114 | + (feed_url, guid, title, summary, content, author) | ||
| 115 | + VALUES (?, ?, ?, ?, ?, ?)""", | ||
| 116 | + p("https://ex.test/feed"), p("g1"), | ||
| 117 | + p("Nim systems programming"), p("a summary"), | ||
| 118 | + p("the body text"), p("An Author")) | ||
| 119 | + | ||
| 120 | + let hit = g.db.queryText( | ||
| 121 | + "SELECT title FROM articles.articles_fts WHERE articles_fts MATCH ?", | ||
| 122 | + p("systems")) | ||
| 123 | + report("FTS5 triggers index new rows", hit.isSome, hit.get("no match")) | ||
| 124 | + | ||
| 125 | + g.db.run("UPDATE articles.articles SET title = ? WHERE guid = ?", | ||
| 126 | + p("Rust systems programming"), p("g1")) | ||
| 127 | + let updated = g.db.queryText( | ||
| 128 | + "SELECT title FROM articles.articles_fts WHERE articles_fts MATCH ?", | ||
| 129 | + p("Rust")) | ||
| 130 | + report("FTS5 triggers follow updates", updated.isSome, updated.get("no match")) | ||
| 131 | + | ||
| 132 | + g.db.run("DELETE FROM articles.articles WHERE guid = ?", p("g1")) | ||
| 133 | + let afterDelete = g.db.queryText( | ||
| 134 | + "SELECT title FROM articles.articles_fts WHERE articles_fts MATCH ?", | ||
| 135 | + p("systems")) | ||
| 136 | + # The detail is only interesting when this fails, and printing the | ||
| 137 | + # fallback on success reads like the opposite of what happened. | ||
| 138 | + report("FTS5 triggers follow deletes", afterDelete.isNone, | ||
| 139 | + if afterDelete.isNone: "row gone from the index" | ||
| 140 | + else: "STILL INDEXED: " & afterDelete.get) | ||
| 141 | + | ||
| 142 | + # --- vec0 --------------------------------------------------------------- | ||
| 143 | + block: | ||
| 144 | + g.createEmbeddingTables(dimension = 4) | ||
| 145 | + report("embedding tables take their dimension from config", | ||
| 146 | + g.db.queryInt( | ||
| 147 | + "SELECT count(*) FROM recs.sqlite_master WHERE name LIKE '%embeddings%'" | ||
| 148 | + ).get(0) > 0) | ||
| 149 | + # Proves sqlite-vec is registered on this connection, not just linked. | ||
| 150 | + let ver = g.db.queryText("SELECT vec_version()") | ||
| 151 | + report("sqlite-vec is live on the glean connection", ver.isSome, ver.get("")) | ||
| 152 | + | ||
| 153 | + echo "" | ||
| 154 | + if failures == 0: | ||
| 155 | + echo "RESULT: the database layer matches Glean's layout and behaviour." | ||
| 156 | + else: | ||
| 157 | + echo &"RESULT: {failures} check(s) failed." | ||
| 158 | + quit 1 | ||
| 159 | + | ||
| 160 | +when isMainModule: | ||
| 161 | + main() | ||
modified
spike/nim/readme.md +41 -0 | @@ -16,6 +16,7 @@ nim c -r -d:ssl xrpc_probe.nim # signing | ||
| 16 | 16 | nim c -r -d:ssl jetstream_probe.nim # firehose |
| 17 | 17 | nim c -r -d:ssl reconnect_probe.nim # supervisor |
| 18 | 18 | nim c -r -d:ssl store_probe.nim # persistence |
| 19 | +nim c -r -d:ssl gleandb_probe.nim # database | |
| 19 | 20 | ``` |
| 20 | 21 | |
| 21 | 22 | Reusable code lives in `atproto/`; the `*_probe.nim` files are the checks. |
| @@ -244,6 +245,46 @@ one is a restart that silently drops every session. | ||
| 244 | 245 | refresh token; together they are the account. The database file wants the |
| 245 | 246 | protection of a password store, not of a cache. |
| 246 | 247 | |
| 248 | +## 8. The database layer — foundation done | |
| 249 | + | |
| 250 | +`atproto/gleandb.nim` opens Glean's storage the way the Go backend does: | |
| 251 | +`<base>_users` as `main`, with `_articles` and `_recs` **attached**, so a | |
| 252 | +query can join across them while each file still checkpoints and vacuums | |
| 253 | +alone — and so the derived recommendation tables can be dropped and rebuilt | |
| 254 | +without touching anything a user wrote. The suffixes match, so a database | |
| 255 | +written by either implementation is readable by the other. | |
| 256 | + | |
| 257 | +Pragmas are repeated per schema rather than set once, which is the detail | |
| 258 | +worth knowing: `PRAGMA` applies to one database at a time, so setting WAL on | |
| 259 | +`main` leaves `articles` journalling the slow way. The probe asserts WAL on | |
| 260 | +all three rather than trusting the call. | |
| 261 | + | |
| 262 | +`exp()` and `log()` are registered as SQL functions, as the Go connection | |
| 263 | +does. Nothing in the current SQL calls either; they exist so a scoring query | |
| 264 | +can decay a weight without pulling rows into the application. `log()` returns | |
| 265 | +NULL for zero and negatives, since `-inf` and NaN both poison an `ORDER BY`. | |
| 266 | + | |
| 267 | +**The schema is extracted, not retyped.** `tools/extract_schema.py` pulls all | |
| 268 | +44 DDL statements out of `internal/db/db.go` into `atproto/schema.nim`. | |
| 269 | +Hand-transcribing them would drift, and a column differing by a default or a | |
| 270 | +CHECK constraint surfaces months later as a violation nobody can place. | |
| 271 | + | |
| 272 | +The probe leans on things that fail quietly: an ATTACH that did not happen, a | |
| 273 | +pragma that did not take, FTS5 triggers that never fire. That last one is the | |
| 274 | +sharpest — the index is maintained by triggers, so if they were missed, | |
| 275 | +search returns nothing while every other query looks healthy. Insert, update | |
| 276 | +and delete are each checked against the index. | |
| 277 | + | |
| 278 | +### Scope | |
| 279 | + | |
| 280 | +This is the foundation, not the whole layer. Glean's `internal/db` is ~2,800 | |
| 281 | +lines of non-test code; what is ported here is the connection, the schema and | |
| 282 | +the primitives, plus a cross-database join, FTS5 and vec0 proven end to end. | |
| 283 | + | |
| 284 | +The remaining query surface — `article.go`, `feed.go`, `social.go`, | |
| 285 | +`retention.go` and friends — is mechanical against this base, and large. It | |
| 286 | +is the first part of the port where the work is volume rather than risk. | |
| 287 | + | |
| 247 | 288 | ## What this does and does not prove |
| 248 | 289 | |
| 249 | 290 | Proven: the cryptography, the storage layer, and the OAuth flow up to user |
| @@ -16,6 +16,7 @@ nim c -r -d:ssl xrpc_probe.nim # signing | |||
| 16 | nim c -r -d:ssl jetstream_probe.nim # firehose | 16 | nim c -r -d:ssl jetstream_probe.nim # firehose |
| 17 | nim c -r -d:ssl reconnect_probe.nim # supervisor | 17 | nim c -r -d:ssl reconnect_probe.nim # supervisor |
| 18 | nim c -r -d:ssl store_probe.nim # persistence | 18 | nim c -r -d:ssl store_probe.nim # persistence |
| 19 | +nim c -r -d:ssl gleandb_probe.nim # database | ||
| 19 | ``` | 20 | ``` |
| 20 | 21 | ||
| 21 | Reusable code lives in `atproto/`; the `*_probe.nim` files are the checks. | 22 | Reusable code lives in `atproto/`; the `*_probe.nim` files are the checks. |
| @@ -244,6 +245,46 @@ one is a restart that silently drops every session. | |||
| 244 | refresh token; together they are the account. The database file wants the | 245 | refresh token; together they are the account. The database file wants the |
| 245 | protection of a password store, not of a cache. | 246 | protection of a password store, not of a cache. |
| 246 | 247 | ||
| 248 | +## 8. The database layer — foundation done | ||
| 249 | + | ||
| 250 | +`atproto/gleandb.nim` opens Glean's storage the way the Go backend does: | ||
| 251 | +`<base>_users` as `main`, with `_articles` and `_recs` **attached**, so a | ||
| 252 | +query can join across them while each file still checkpoints and vacuums | ||
| 253 | +alone — and so the derived recommendation tables can be dropped and rebuilt | ||
| 254 | +without touching anything a user wrote. The suffixes match, so a database | ||
| 255 | +written by either implementation is readable by the other. | ||
| 256 | + | ||
| 257 | +Pragmas are repeated per schema rather than set once, which is the detail | ||
| 258 | +worth knowing: `PRAGMA` applies to one database at a time, so setting WAL on | ||
| 259 | +`main` leaves `articles` journalling the slow way. The probe asserts WAL on | ||
| 260 | +all three rather than trusting the call. | ||
| 261 | + | ||
| 262 | +`exp()` and `log()` are registered as SQL functions, as the Go connection | ||
| 263 | +does. Nothing in the current SQL calls either; they exist so a scoring query | ||
| 264 | +can decay a weight without pulling rows into the application. `log()` returns | ||
| 265 | +NULL for zero and negatives, since `-inf` and NaN both poison an `ORDER BY`. | ||
| 266 | + | ||
| 267 | +**The schema is extracted, not retyped.** `tools/extract_schema.py` pulls all | ||
| 268 | +44 DDL statements out of `internal/db/db.go` into `atproto/schema.nim`. | ||
| 269 | +Hand-transcribing them would drift, and a column differing by a default or a | ||
| 270 | +CHECK constraint surfaces months later as a violation nobody can place. | ||
| 271 | + | ||
| 272 | +The probe leans on things that fail quietly: an ATTACH that did not happen, a | ||
| 273 | +pragma that did not take, FTS5 triggers that never fire. That last one is the | ||
| 274 | +sharpest — the index is maintained by triggers, so if they were missed, | ||
| 275 | +search returns nothing while every other query looks healthy. Insert, update | ||
| 276 | +and delete are each checked against the index. | ||
| 277 | + | ||
| 278 | +### Scope | ||
| 279 | + | ||
| 280 | +This is the foundation, not the whole layer. Glean's `internal/db` is ~2,800 | ||
| 281 | +lines of non-test code; what is ported here is the connection, the schema and | ||
| 282 | +the primitives, plus a cross-database join, FTS5 and vec0 proven end to end. | ||
| 283 | + | ||
| 284 | +The remaining query surface — `article.go`, `feed.go`, `social.go`, | ||
| 285 | +`retention.go` and friends — is mechanical against this base, and large. It | ||
| 286 | +is the first part of the port where the work is volume rather than risk. | ||
| 287 | + | ||
| 247 | ## What this does and does not prove | 288 | ## What this does and does not prove |
| 248 | 289 | ||
| 249 | Proven: the cryptography, the storage layer, and the OAuth flow up to user | 290 | Proven: the cryptography, the storage layer, and the OAuth flow up to user |
added
spike/nim/tools/extract_schema.py +72 -0 | new file mode 100755 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Regenerate atproto/schema.nim from the Go schema. | |
| 3 | + | |
| 4 | +The DDL lives in internal/db/db.go as three []string literals. Retyping 44 | |
| 5 | +statements into Nim would drift -- a differing default or CHECK constraint | |
| 6 | +surfaces months later as a violation nobody can place -- so they are copied | |
| 7 | +mechanically instead. | |
| 8 | + | |
| 9 | + python3 tools/extract_schema.py | |
| 10 | +""" | |
| 11 | +import pathlib | |
| 12 | +import re | |
| 13 | +import sys | |
| 14 | + | |
| 15 | +GO = pathlib.Path(__file__).resolve().parents[3] / "internal" / "db" / "db.go" | |
| 16 | +OUT = pathlib.Path(__file__).resolve().parents[1] / "atproto" / "schema.nim" | |
| 17 | + | |
| 18 | +HEADER = '''## Glean's schema, in the three databases it uses. | |
| 19 | +## | |
| 20 | +## Extracted mechanically from internal/db/db.go rather than retyped: 44 DDL | |
| 21 | +## statements transcribed by hand would drift from the Go definition, and a | |
| 22 | +## column that differs by a default or a CHECK is the kind of thing that only | |
| 23 | +## shows up as a constraint violation months later. | |
| 24 | +## | |
| 25 | +## Regenerate with tools/extract_schema.py after changing the Go schema. | |
| 26 | + | |
| 27 | +''' | |
| 28 | + | |
| 29 | + | |
| 30 | +def groups(src): | |
| 31 | + """Locate each `var <name>Schema = []string{ ... }` block by line.""" | |
| 32 | + lines = src.splitlines() | |
| 33 | + found = [] | |
| 34 | + for i, line in enumerate(lines): | |
| 35 | + m = re.match(r"var (\w+Schema) = \[\]string\{", line) | |
| 36 | + if not m: | |
| 37 | + continue | |
| 38 | + for j in range(i + 1, len(lines)): | |
| 39 | + if lines[j] == "}": | |
| 40 | + found.append((m.group(1), i + 1, j)) | |
| 41 | + break | |
| 42 | + return lines, found | |
| 43 | + | |
| 44 | + | |
| 45 | +def main(): | |
| 46 | + if not GO.exists(): | |
| 47 | + sys.exit(f"cannot find {GO}") | |
| 48 | + src = GO.read_text() | |
| 49 | + lines, found = groups(src) | |
| 50 | + if not found: | |
| 51 | + sys.exit("no schema blocks found; did db.go change shape?") | |
| 52 | + | |
| 53 | + out = [HEADER] | |
| 54 | + total = 0 | |
| 55 | + for name, start, end in found: | |
| 56 | + body = "\n".join(lines[start:end]) | |
| 57 | + stmts = re.findall(r"`([^`]*)`", body) | |
| 58 | + total += len(stmts) | |
| 59 | + out.append(f"const {name}* = [\n") | |
| 60 | + for st in stmts: | |
| 61 | + if '"""' in st: | |
| 62 | + sys.exit(f"statement contains a triple quote: {st[:60]}") | |
| 63 | + out.append(f' """{st}""",\n') | |
| 64 | + out.append("]\n\n") | |
| 65 | + print(f"{name}: {len(stmts)} statements") | |
| 66 | + | |
| 67 | + OUT.write_text("".join(out)) | |
| 68 | + print(f"wrote {OUT} ({total} statements)") | |
| 69 | + | |
| 70 | + | |
| 71 | +if __name__ == "__main__": | |
| 72 | + main() | |
| new file mode 100755 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +"""Regenerate atproto/schema.nim from the Go schema. | ||
| 3 | + | ||
| 4 | +The DDL lives in internal/db/db.go as three []string literals. Retyping 44 | ||
| 5 | +statements into Nim would drift -- a differing default or CHECK constraint | ||
| 6 | +surfaces months later as a violation nobody can place -- so they are copied | ||
| 7 | +mechanically instead. | ||
| 8 | + | ||
| 9 | + python3 tools/extract_schema.py | ||
| 10 | +""" | ||
| 11 | +import pathlib | ||
| 12 | +import re | ||
| 13 | +import sys | ||
| 14 | + | ||
| 15 | +GO = pathlib.Path(__file__).resolve().parents[3] / "internal" / "db" / "db.go" | ||
| 16 | +OUT = pathlib.Path(__file__).resolve().parents[1] / "atproto" / "schema.nim" | ||
| 17 | + | ||
| 18 | +HEADER = '''## Glean's schema, in the three databases it uses. | ||
| 19 | +## | ||
| 20 | +## Extracted mechanically from internal/db/db.go rather than retyped: 44 DDL | ||
| 21 | +## statements transcribed by hand would drift from the Go definition, and a | ||
| 22 | +## column that differs by a default or a CHECK is the kind of thing that only | ||
| 23 | +## shows up as a constraint violation months later. | ||
| 24 | +## | ||
| 25 | +## Regenerate with tools/extract_schema.py after changing the Go schema. | ||
| 26 | + | ||
| 27 | +''' | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +def groups(src): | ||
| 31 | + """Locate each `var <name>Schema = []string{ ... }` block by line.""" | ||
| 32 | + lines = src.splitlines() | ||
| 33 | + found = [] | ||
| 34 | + for i, line in enumerate(lines): | ||
| 35 | + m = re.match(r"var (\w+Schema) = \[\]string\{", line) | ||
| 36 | + if not m: | ||
| 37 | + continue | ||
| 38 | + for j in range(i + 1, len(lines)): | ||
| 39 | + if lines[j] == "}": | ||
| 40 | + found.append((m.group(1), i + 1, j)) | ||
| 41 | + break | ||
| 42 | + return lines, found | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +def main(): | ||
| 46 | + if not GO.exists(): | ||
| 47 | + sys.exit(f"cannot find {GO}") | ||
| 48 | + src = GO.read_text() | ||
| 49 | + lines, found = groups(src) | ||
| 50 | + if not found: | ||
| 51 | + sys.exit("no schema blocks found; did db.go change shape?") | ||
| 52 | + | ||
| 53 | + out = [HEADER] | ||
| 54 | + total = 0 | ||
| 55 | + for name, start, end in found: | ||
| 56 | + body = "\n".join(lines[start:end]) | ||
| 57 | + stmts = re.findall(r"`([^`]*)`", body) | ||
| 58 | + total += len(stmts) | ||
| 59 | + out.append(f"const {name}* = [\n") | ||
| 60 | + for st in stmts: | ||
| 61 | + if '"""' in st: | ||
| 62 | + sys.exit(f"statement contains a triple quote: {st[:60]}") | ||
| 63 | + out.append(f' """{st}""",\n') | ||
| 64 | + out.append("]\n\n") | ||
| 65 | + print(f"{name}: {len(stmts)} statements") | ||
| 66 | + | ||
| 67 | + OUT.write_text("".join(out)) | ||
| 68 | + print(f"wrote {OUT} ({total} statements)") | ||
| 69 | + | ||
| 70 | + | ||
| 71 | +if __name__ == "__main__": | ||
| 72 | + main() | ||