@hatk/hatk 0.0.1-alpha.11 → 0.0.1-alpha.111

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 (224) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +113 -0
  4. package/dist/backfill.d.ts +36 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +153 -37
  7. package/dist/car.js +1 -1
  8. package/dist/classifiers.d.ts +228 -0
  9. package/dist/classifiers.d.ts.map +1 -0
  10. package/dist/classifiers.js +688 -0
  11. package/dist/cli.js +248 -1043
  12. package/dist/config.d.ts +179 -1
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +78 -8
  15. package/dist/database/adapter-factory.d.ts +6 -0
  16. package/dist/database/adapter-factory.d.ts.map +1 -0
  17. package/dist/database/adapter-factory.js +20 -0
  18. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  19. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  20. package/dist/database/adapters/duckdb-search.js +27 -0
  21. package/dist/database/adapters/duckdb.d.ts +25 -0
  22. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  23. package/dist/database/adapters/duckdb.js +161 -0
  24. package/dist/database/adapters/sqlite-search.d.ts +16 -0
  25. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  26. package/dist/database/adapters/sqlite-search.js +76 -0
  27. package/dist/database/adapters/sqlite.d.ts +18 -0
  28. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  29. package/dist/database/adapters/sqlite.js +88 -0
  30. package/dist/database/db.d.ts +290 -0
  31. package/dist/database/db.d.ts.map +1 -0
  32. package/dist/database/db.js +2073 -0
  33. package/dist/database/dialect.d.ts +45 -0
  34. package/dist/database/dialect.d.ts.map +1 -0
  35. package/dist/database/dialect.js +72 -0
  36. package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
  37. package/dist/database/fts.d.ts.map +1 -0
  38. package/dist/{fts.js → database/fts.js} +122 -34
  39. package/dist/database/index.d.ts +7 -0
  40. package/dist/database/index.d.ts.map +1 -0
  41. package/dist/database/index.js +6 -0
  42. package/dist/database/ports.d.ts +50 -0
  43. package/dist/database/ports.d.ts.map +1 -0
  44. package/dist/database/ports.js +1 -0
  45. package/dist/database/schema.d.ts +91 -0
  46. package/dist/database/schema.d.ts.map +1 -0
  47. package/dist/{schema.js → database/schema.js} +153 -54
  48. package/dist/dev-entry.d.ts +8 -0
  49. package/dist/dev-entry.d.ts.map +1 -0
  50. package/dist/dev-entry.js +131 -0
  51. package/dist/feeds.d.ts +26 -8
  52. package/dist/feeds.d.ts.map +1 -1
  53. package/dist/feeds.js +72 -16
  54. package/dist/hooks.d.ts +102 -0
  55. package/dist/hooks.d.ts.map +1 -0
  56. package/dist/hooks.js +186 -0
  57. package/dist/hydrate.d.ts +55 -6
  58. package/dist/hydrate.d.ts.map +1 -1
  59. package/dist/hydrate.js +21 -20
  60. package/dist/indexer.d.ts +194 -3
  61. package/dist/indexer.d.ts.map +1 -1
  62. package/dist/indexer.js +627 -92
  63. package/dist/jetstream.d.ts +118 -0
  64. package/dist/jetstream.d.ts.map +1 -0
  65. package/dist/jetstream.js +238 -0
  66. package/dist/labels.d.ts +36 -0
  67. package/dist/labels.d.ts.map +1 -1
  68. package/dist/labels.js +71 -6
  69. package/dist/lex-types.d.ts +2 -0
  70. package/dist/lex-types.d.ts.map +1 -1
  71. package/dist/lexicon-resolve.d.ts.map +1 -1
  72. package/dist/lexicon-resolve.js +27 -112
  73. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  74. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  75. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  76. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  77. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  78. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  79. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  80. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  81. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  82. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  83. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  84. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  85. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  86. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  87. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  88. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  89. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  90. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  91. package/dist/logger.d.ts +29 -0
  92. package/dist/logger.d.ts.map +1 -1
  93. package/dist/logger.js +29 -0
  94. package/dist/main.js +212 -75
  95. package/dist/mst.d.ts +15 -0
  96. package/dist/mst.d.ts.map +1 -1
  97. package/dist/mst.js +13 -0
  98. package/dist/oauth/db.d.ts +27 -1
  99. package/dist/oauth/db.d.ts.map +1 -1
  100. package/dist/oauth/db.js +80 -19
  101. package/dist/oauth/discovery.d.ts +10 -0
  102. package/dist/oauth/discovery.d.ts.map +1 -1
  103. package/dist/oauth/discovery.js +54 -2
  104. package/dist/oauth/server.d.ts +131 -1
  105. package/dist/oauth/server.d.ts.map +1 -1
  106. package/dist/oauth/server.js +504 -39
  107. package/dist/oauth/service-describe.d.ts +42 -0
  108. package/dist/oauth/service-describe.d.ts.map +1 -0
  109. package/dist/oauth/service-describe.js +108 -0
  110. package/dist/oauth/session.d.ts +32 -0
  111. package/dist/oauth/session.d.ts.map +1 -0
  112. package/dist/oauth/session.js +131 -0
  113. package/dist/opengraph.d.ts +10 -0
  114. package/dist/opengraph.d.ts.map +1 -1
  115. package/dist/opengraph.js +94 -42
  116. package/dist/pds-proxy.d.ts +92 -0
  117. package/dist/pds-proxy.d.ts.map +1 -0
  118. package/dist/pds-proxy.js +396 -0
  119. package/dist/private-collections.d.ts +13 -0
  120. package/dist/private-collections.d.ts.map +1 -0
  121. package/dist/private-collections.js +27 -0
  122. package/dist/push.d.ts +54 -0
  123. package/dist/push.d.ts.map +1 -0
  124. package/dist/push.js +367 -0
  125. package/dist/renderer.d.ts +27 -0
  126. package/dist/renderer.d.ts.map +1 -0
  127. package/dist/renderer.js +46 -0
  128. package/dist/resolve-hatk.d.ts +6 -0
  129. package/dist/resolve-hatk.d.ts.map +1 -0
  130. package/dist/resolve-hatk.js +20 -0
  131. package/dist/response.d.ts +16 -0
  132. package/dist/response.d.ts.map +1 -0
  133. package/dist/response.js +69 -0
  134. package/dist/scanner.d.ts +22 -0
  135. package/dist/scanner.d.ts.map +1 -0
  136. package/dist/scanner.js +92 -0
  137. package/dist/seed.d.ts +19 -0
  138. package/dist/seed.d.ts.map +1 -1
  139. package/dist/seed.js +43 -4
  140. package/dist/server-init.d.ts +8 -0
  141. package/dist/server-init.d.ts.map +1 -0
  142. package/dist/server-init.js +75 -0
  143. package/dist/server.d.ts +46 -3
  144. package/dist/server.d.ts.map +1 -1
  145. package/dist/server.js +1137 -649
  146. package/dist/setup.d.ts +28 -1
  147. package/dist/setup.d.ts.map +1 -1
  148. package/dist/setup.js +50 -3
  149. package/dist/spaces/blob.d.ts +112 -0
  150. package/dist/spaces/blob.d.ts.map +1 -0
  151. package/dist/spaces/blob.js +460 -0
  152. package/dist/spaces/credential.d.ts +82 -0
  153. package/dist/spaces/credential.d.ts.map +1 -0
  154. package/dist/spaces/credential.js +220 -0
  155. package/dist/spaces/engine.d.ts +97 -0
  156. package/dist/spaces/engine.d.ts.map +1 -0
  157. package/dist/spaces/engine.js +563 -0
  158. package/dist/spaces/guard.d.ts +41 -0
  159. package/dist/spaces/guard.d.ts.map +1 -0
  160. package/dist/spaces/guard.js +79 -0
  161. package/dist/spaces/identity.d.ts +37 -0
  162. package/dist/spaces/identity.d.ts.map +1 -0
  163. package/dist/spaces/identity.js +93 -0
  164. package/dist/spaces/index.d.ts +56 -0
  165. package/dist/spaces/index.d.ts.map +1 -0
  166. package/dist/spaces/index.js +134 -0
  167. package/dist/spaces/notify.d.ts +60 -0
  168. package/dist/spaces/notify.d.ts.map +1 -0
  169. package/dist/spaces/notify.js +128 -0
  170. package/dist/spaces/records.d.ts +37 -0
  171. package/dist/spaces/records.d.ts.map +1 -0
  172. package/dist/spaces/records.js +63 -0
  173. package/dist/spaces/signature.d.ts +36 -0
  174. package/dist/spaces/signature.d.ts.map +1 -0
  175. package/dist/spaces/signature.js +83 -0
  176. package/dist/spaces/store.d.ts +51 -0
  177. package/dist/spaces/store.d.ts.map +1 -0
  178. package/dist/spaces/store.js +99 -0
  179. package/dist/spaces/uri.d.ts +60 -0
  180. package/dist/spaces/uri.d.ts.map +1 -0
  181. package/dist/spaces/uri.js +97 -0
  182. package/dist/spaces/verify.d.ts +46 -0
  183. package/dist/spaces/verify.d.ts.map +1 -0
  184. package/dist/spaces/verify.js +94 -0
  185. package/dist/spaces/viewer.d.ts +51 -0
  186. package/dist/spaces/viewer.d.ts.map +1 -0
  187. package/dist/spaces/viewer.js +167 -0
  188. package/dist/spaces/visibility.d.ts +57 -0
  189. package/dist/spaces/visibility.d.ts.map +1 -0
  190. package/dist/spaces/visibility.js +81 -0
  191. package/dist/templates/feed.tpl +18 -0
  192. package/dist/templates/hook.tpl +5 -0
  193. package/dist/templates/label.tpl +15 -0
  194. package/dist/templates/og.tpl +17 -0
  195. package/dist/templates/seed.tpl +11 -0
  196. package/dist/templates/setup.tpl +5 -0
  197. package/dist/templates/test-feed.tpl +19 -0
  198. package/dist/templates/test-xrpc.tpl +19 -0
  199. package/dist/templates/xrpc.tpl +53 -0
  200. package/dist/test.d.ts +3 -1
  201. package/dist/test.d.ts.map +1 -1
  202. package/dist/test.js +60 -36
  203. package/dist/views.js +1 -1
  204. package/dist/vite-plugin.d.ts +1 -1
  205. package/dist/vite-plugin.d.ts.map +1 -1
  206. package/dist/vite-plugin.js +254 -66
  207. package/dist/xrpc.d.ts +143 -12
  208. package/dist/xrpc.d.ts.map +1 -1
  209. package/dist/xrpc.js +282 -55
  210. package/package.json +29 -9
  211. package/public/admin-auth.js +271 -306
  212. package/public/admin.html +652 -61
  213. package/dist/db.d.ts +0 -135
  214. package/dist/db.d.ts.map +0 -1
  215. package/dist/db.js +0 -1365
  216. package/dist/fts.d.ts.map +0 -1
  217. package/dist/oauth/hooks.d.ts +0 -10
  218. package/dist/oauth/hooks.d.ts.map +0 -1
  219. package/dist/oauth/hooks.js +0 -40
  220. package/dist/schema.d.ts +0 -51
  221. package/dist/schema.d.ts.map +0 -1
  222. package/dist/test-browser.d.ts +0 -14
  223. package/dist/test-browser.d.ts.map +0 -1
  224. package/dist/test-browser.js +0 -26
@@ -0,0 +1,2073 @@
1
+ import { toSnakeCase, q } from "./schema.js";
2
+ import { getSearchColumns, stripStopWords, getSearchPort, updateFtsRecord, deleteFtsRecord } from "./fts.js";
3
+ import { emit, timer } from "../logger.js";
4
+ import { OAUTH_DDL } from "../oauth/db.js";
5
+ import { getDialect } from "./dialect.js";
6
+ import { collectionFromRecordUri, spaceFromUri } from "../spaces/uri.js";
7
+ import { spaceFilterSql } from "../spaces/visibility.js";
8
+ let port;
9
+ let dialect;
10
+ const schemas = new Map();
11
+ export function getDatabasePort() {
12
+ return port;
13
+ }
14
+ export function getSqlDialect() {
15
+ return dialect;
16
+ }
17
+ export function closeDatabase() {
18
+ port?.close();
19
+ }
20
+ async function run(sql, params = []) {
21
+ return port.execute(sql, params);
22
+ }
23
+ export async function runBatch(operations) {
24
+ await port.beginTransaction();
25
+ try {
26
+ for (const op of operations) {
27
+ try {
28
+ await port.execute(op.sql, op.params);
29
+ }
30
+ catch {
31
+ // Skip bad records, continue with rest of batch
32
+ }
33
+ }
34
+ await port.commit();
35
+ }
36
+ catch {
37
+ await port.rollback();
38
+ }
39
+ }
40
+ async function all(sql, params = []) {
41
+ return port.query(sql, params);
42
+ }
43
+ /**
44
+ * Open the database and leave its schema correct, whether it is new or not.
45
+ *
46
+ * The order is the point. An index names a column, so it cannot be built until
47
+ * the column is there, and on an existing database the column only arrives when
48
+ * `migrateSchema` runs — `CREATE TABLE IF NOT EXISTS` sees a table already and
49
+ * adds nothing. So: create the tables, reconcile the columns against the
50
+ * lexicons, then build the indexes.
51
+ *
52
+ * `indexStatements` may be left out, and callers that build from nothing do:
53
+ * a combined script passed as `ddlStatements` is valid on an empty database,
54
+ * because there every column is created with its table.
55
+ */
56
+ export async function initDatabase(adapter, dbPath, tableSchemas, ddlStatements, indexStatements = []) {
57
+ port = adapter;
58
+ dialect = getDialect(adapter.dialect);
59
+ await port.open(dbPath);
60
+ for (const schema of tableSchemas) {
61
+ schemas.set(schema.collection, schema);
62
+ }
63
+ for (const ddl of ddlStatements) {
64
+ await port.executeMultiple(ddl);
65
+ }
66
+ // Internal tables for backfill state
67
+ await run(`CREATE TABLE IF NOT EXISTS _repos (
68
+ did TEXT PRIMARY KEY,
69
+ status TEXT NOT NULL DEFAULT 'pending',
70
+ handle TEXT,
71
+ backfilled_at ${dialect.timestampType},
72
+ rev TEXT,
73
+ retry_count INTEGER NOT NULL DEFAULT 0,
74
+ retry_after INTEGER NOT NULL DEFAULT 0
75
+ )`);
76
+ // Migration: add handle column to existing _repos tables
77
+ try {
78
+ await run(`ALTER TABLE _repos ADD COLUMN handle TEXT`);
79
+ }
80
+ catch { }
81
+ // Re-queue repos with missing handles so backfill populates them
82
+ await run(`UPDATE _repos SET status = 'pending' WHERE handle IS NULL`);
83
+ // Covering index for the status rollups the admin overview and backfill queue
84
+ // read. Skipped on DuckDB, which scans the column just as fast and would only
85
+ // pay the ART index's insert cost.
86
+ if (port.dialect !== 'duckdb') {
87
+ await run(`CREATE INDEX IF NOT EXISTS idx_repos_status ON _repos(status)`);
88
+ }
89
+ // resolveHandleToDid runs on every feed request that takes an `actor` handle,
90
+ // and without this it scans all of _repos. Not UNIQUE: during a handle change
91
+ // two rows can briefly claim the same handle, and a constraint violation there
92
+ // would fail the indexer write.
93
+ await run(`CREATE INDEX IF NOT EXISTS idx_repos_handle ON _repos(handle)`);
94
+ await run(`CREATE TABLE IF NOT EXISTS _cursor (
95
+ key TEXT PRIMARY KEY,
96
+ value TEXT NOT NULL
97
+ )`);
98
+ // Permissioned-space sync state. `_space_watch` is one row per space this
99
+ // instance follows; `_space_repos` is the writer set, which is the sync
100
+ // boundary — a space is the aggregation of one repo per writer, each on that
101
+ // writer's own PDS, so progress is tracked per (space, writer) rather than
102
+ // per space.
103
+ //
104
+ // `reader_did` is the account whose session last minted a credential for the
105
+ // space. hatk is not itself a member of anything: it reads as somebody who
106
+ // is, so which somebody worked last is worth remembering.
107
+ await run(`CREATE TABLE IF NOT EXISTS _space_watch (
108
+ space TEXT PRIMARY KEY,
109
+ authority TEXT NOT NULL,
110
+ space_type TEXT NOT NULL,
111
+ reader_did TEXT,
112
+ registered_until TEXT,
113
+ last_error TEXT,
114
+ updated_at TEXT NOT NULL
115
+ )`);
116
+ await run(`CREATE TABLE IF NOT EXISTS _space_repos (
117
+ space TEXT NOT NULL,
118
+ did TEXT NOT NULL,
119
+ pds TEXT,
120
+ rev TEXT,
121
+ synced_at TEXT,
122
+ PRIMARY KEY (space, did)
123
+ )`);
124
+ await run(`CREATE INDEX IF NOT EXISTS idx_space_repos_space ON _space_repos(space)`);
125
+ // Labels table (atproto-compatible)
126
+ if (dialect.supportsSequences) {
127
+ await run(`CREATE SEQUENCE IF NOT EXISTS _labels_seq START 1`);
128
+ await run(`CREATE TABLE IF NOT EXISTS _labels (
129
+ id INTEGER PRIMARY KEY DEFAULT nextval('_labels_seq'),
130
+ src TEXT NOT NULL,
131
+ uri TEXT NOT NULL,
132
+ val TEXT NOT NULL,
133
+ neg ${dialect.typeMap.boolean} DEFAULT FALSE,
134
+ cts ${dialect.timestampType} NOT NULL,
135
+ exp ${dialect.timestampType}
136
+ )`);
137
+ }
138
+ else {
139
+ await run(`CREATE TABLE IF NOT EXISTS _labels (
140
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
141
+ src TEXT NOT NULL,
142
+ uri TEXT NOT NULL,
143
+ val TEXT NOT NULL,
144
+ neg INTEGER DEFAULT 0,
145
+ cts TEXT NOT NULL,
146
+ exp TEXT
147
+ )`);
148
+ }
149
+ await run(`CREATE INDEX IF NOT EXISTS idx_labels_uri ON _labels(uri)`);
150
+ await run(`CREATE INDEX IF NOT EXISTS idx_labels_src ON _labels(src)`);
151
+ // Preferences table (generic key-value per user)
152
+ await run(`CREATE TABLE IF NOT EXISTS _preferences (
153
+ did TEXT NOT NULL,
154
+ key TEXT NOT NULL,
155
+ value ${dialect.jsonType} NOT NULL,
156
+ updated_at ${dialect.timestampType} DEFAULT ${dialect.currentTimestamp},
157
+ PRIMARY KEY (did, key)
158
+ )`);
159
+ // Reports table (user-submitted moderation reports)
160
+ if (dialect.supportsSequences) {
161
+ await run(`CREATE SEQUENCE IF NOT EXISTS _reports_seq START 1`);
162
+ await run(`CREATE TABLE IF NOT EXISTS _reports (
163
+ id INTEGER PRIMARY KEY DEFAULT nextval('_reports_seq'),
164
+ subject_uri TEXT NOT NULL,
165
+ subject_did TEXT NOT NULL,
166
+ label TEXT NOT NULL,
167
+ reason TEXT,
168
+ reported_by TEXT NOT NULL,
169
+ status TEXT NOT NULL DEFAULT 'open',
170
+ resolved_by TEXT,
171
+ resolved_at ${dialect.timestampType},
172
+ created_at ${dialect.timestampType} NOT NULL
173
+ )`);
174
+ }
175
+ else {
176
+ await run(`CREATE TABLE IF NOT EXISTS _reports (
177
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
178
+ subject_uri TEXT NOT NULL,
179
+ subject_did TEXT NOT NULL,
180
+ label TEXT NOT NULL,
181
+ reason TEXT,
182
+ reported_by TEXT NOT NULL,
183
+ status TEXT NOT NULL DEFAULT 'open',
184
+ resolved_by TEXT,
185
+ resolved_at TEXT,
186
+ created_at TEXT NOT NULL
187
+ )`);
188
+ }
189
+ await run(`CREATE INDEX IF NOT EXISTS idx_reports_status ON _reports(status)`);
190
+ await run(`CREATE INDEX IF NOT EXISTS idx_reports_subject_uri ON _reports(subject_uri)`);
191
+ // Classifier scores. One row per (subject, classifier); a rescan replaces it.
192
+ // `fingerprint` hashes the state that was scored, so an unchanged subject is
193
+ // skipped on the next scan instead of being paid for again.
194
+ if (dialect.supportsSequences) {
195
+ await run(`CREATE SEQUENCE IF NOT EXISTS _classifications_seq START 1`);
196
+ await run(`CREATE TABLE IF NOT EXISTS _classifications (
197
+ id INTEGER PRIMARY KEY DEFAULT nextval('_classifications_seq'),
198
+ subject_uri TEXT NOT NULL,
199
+ subject_did TEXT NOT NULL,
200
+ classifier TEXT NOT NULL,
201
+ signals ${dialect.jsonType} NOT NULL,
202
+ state ${dialect.jsonType},
203
+ top_signal TEXT,
204
+ top_score DOUBLE NOT NULL DEFAULT 0,
205
+ fingerprint TEXT NOT NULL,
206
+ model TEXT,
207
+ scanned_at ${dialect.timestampType} NOT NULL
208
+ )`);
209
+ }
210
+ else {
211
+ await run(`CREATE TABLE IF NOT EXISTS _classifications (
212
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
213
+ subject_uri TEXT NOT NULL,
214
+ subject_did TEXT NOT NULL,
215
+ classifier TEXT NOT NULL,
216
+ signals TEXT NOT NULL,
217
+ state TEXT,
218
+ top_signal TEXT,
219
+ top_score REAL NOT NULL DEFAULT 0,
220
+ fingerprint TEXT NOT NULL,
221
+ model TEXT,
222
+ scanned_at TEXT NOT NULL
223
+ )`);
224
+ }
225
+ await run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_classifications_subject ON _classifications(subject_uri, classifier)`);
226
+ await run(`CREATE INDEX IF NOT EXISTS idx_classifications_score ON _classifications(top_score)`);
227
+ await run(`CREATE INDEX IF NOT EXISTS idx_classifications_did ON _classifications(subject_did)`);
228
+ // Push notification tokens
229
+ await run(`CREATE TABLE IF NOT EXISTS _push_tokens (
230
+ did TEXT NOT NULL,
231
+ token TEXT NOT NULL,
232
+ platform TEXT NOT NULL,
233
+ created_at TEXT NOT NULL,
234
+ PRIMARY KEY (did, token)
235
+ )`);
236
+ // OAuth tables
237
+ await port.executeMultiple(OAUTH_DDL);
238
+ // Migrations: add pds_auth_server to existing sessions tables
239
+ try {
240
+ await run(`ALTER TABLE _oauth_sessions ADD COLUMN pds_auth_server TEXT`);
241
+ }
242
+ catch { }
243
+ // Endpoints read from the auth server's metadata document. NULL on rows that
244
+ // predate this, and the OAuth code falls back to the paths it used to
245
+ // hardcode, so sessions authorized before the upgrade still refresh.
246
+ try {
247
+ await run(`ALTER TABLE _oauth_sessions ADD COLUMN pds_token_endpoint TEXT`);
248
+ }
249
+ catch { }
250
+ try {
251
+ await run(`ALTER TABLE _oauth_requests ADD COLUMN pds_authorization_endpoint TEXT`);
252
+ }
253
+ catch { }
254
+ try {
255
+ await run(`ALTER TABLE _oauth_requests ADD COLUMN pds_token_endpoint TEXT`);
256
+ }
257
+ catch { }
258
+ // What a session asked for and was granted. NULL on sessions that predate
259
+ // this, which are treated as they were before: a scope refusal ends them.
260
+ try {
261
+ await run(`ALTER TABLE _oauth_sessions ADD COLUMN requested_scope TEXT`);
262
+ }
263
+ catch { }
264
+ try {
265
+ await run(`ALTER TABLE _oauth_sessions ADD COLUMN granted_scope TEXT`);
266
+ }
267
+ catch { }
268
+ try {
269
+ await run(`ALTER TABLE _oauth_requests ADD COLUMN pds_scope TEXT`);
270
+ }
271
+ catch { }
272
+ // Now the columns the indexes below name are all present, on a database of
273
+ // any age. A new collection's table was created above and needs nothing; an
274
+ // existing one gets whatever the lexicons have gained since it was written.
275
+ const changes = await migrateSchema(tableSchemas);
276
+ for (const ddl of indexStatements) {
277
+ await port.executeMultiple(ddl);
278
+ }
279
+ return changes;
280
+ }
281
+ /** Normalize SQL type names to handle dialect differences (e.g. VARCHAR → TEXT) */
282
+ function normalizeType(type) {
283
+ const upper = type.toUpperCase();
284
+ if (upper === 'VARCHAR' || upper === 'CHARACTER VARYING')
285
+ return 'TEXT';
286
+ if (upper === 'TIMESTAMP WITH TIME ZONE')
287
+ return 'TIMESTAMPTZ';
288
+ if (upper === 'BOOLEAN' || upper === 'BOOL')
289
+ return 'BOOLEAN';
290
+ if (upper === 'INT' || upper === 'INT4' || upper === 'INT8' || upper === 'BIGINT' || upper === 'SMALLINT')
291
+ return 'INTEGER';
292
+ return upper;
293
+ }
294
+ async function getExistingColumns(tableName) {
295
+ if (!/^[a-zA-Z0-9._]+$/.test(tableName)) {
296
+ throw new Error(`Invalid table name for introspection: ${tableName}`);
297
+ }
298
+ const cols = new Map();
299
+ try {
300
+ const query = dialect.introspectColumnsQuery(tableName);
301
+ const rows = await all(query);
302
+ for (const row of rows) {
303
+ // SQLite PRAGMA returns { name, type }, DuckDB returns { column_name, data_type }
304
+ const name = (row.column_name || row.name);
305
+ const type = normalizeType((row.data_type || row.type || 'TEXT'));
306
+ cols.set(name, type);
307
+ }
308
+ }
309
+ catch {
310
+ // Table doesn't exist yet
311
+ }
312
+ return cols;
313
+ }
314
+ function diffColumns(tableName, existingCols, expectedCols, changes) {
315
+ for (const [colName, colType] of expectedCols) {
316
+ if (!existingCols.has(colName)) {
317
+ changes.push({ table: tableName, action: 'add', column: colName, type: colType });
318
+ }
319
+ }
320
+ for (const [colName] of existingCols) {
321
+ if (!expectedCols.has(colName)) {
322
+ changes.push({ table: tableName, action: 'drop', column: colName });
323
+ }
324
+ }
325
+ for (const [colName, colType] of expectedCols) {
326
+ const existingType = existingCols.get(colName);
327
+ if (existingType && normalizeType(existingType) !== normalizeType(colType)) {
328
+ changes.push({ table: tableName, action: 'retype', column: colName, type: colType });
329
+ }
330
+ }
331
+ }
332
+ /** Build expected columns map for a child/union table */
333
+ function buildChildExpectedCols(columns) {
334
+ const expected = new Map();
335
+ expected.set('parent_uri', 'TEXT');
336
+ expected.set('parent_did', 'TEXT');
337
+ for (const col of columns) {
338
+ expected.set(col.name, normalizeType(col.sqlType));
339
+ }
340
+ return expected;
341
+ }
342
+ export async function migrateSchema(tableSchemas) {
343
+ const changes = [];
344
+ const newCollections = new Set();
345
+ for (const schema of tableSchemas) {
346
+ if (schema.columns.length === 0)
347
+ continue; // generic JSON storage, skip
348
+ const tableName = schema.collection;
349
+ const existingCols = await getExistingColumns(tableName);
350
+ if (existingCols.size === 0) {
351
+ newCollections.add(schema.collection);
352
+ continue; // table just created, nothing to migrate
353
+ }
354
+ // Expected columns: base columns (uri, cid, did, indexed_at) + schema columns
355
+ const expectedCols = new Map();
356
+ expectedCols.set('uri', 'TEXT');
357
+ expectedCols.set('cid', 'TEXT');
358
+ expectedCols.set('did', 'TEXT');
359
+ expectedCols.set('space', 'TEXT');
360
+ expectedCols.set('indexed_at', normalizeType(dialect.timestampType));
361
+ for (const col of schema.columns) {
362
+ expectedCols.set(col.name, normalizeType(col.sqlType));
363
+ }
364
+ diffColumns(tableName, existingCols, expectedCols, changes);
365
+ // Diff child tables
366
+ for (const child of schema.children) {
367
+ const childTable = child.tableName.replace(/"/g, '');
368
+ const existingChildCols = await getExistingColumns(childTable);
369
+ if (existingChildCols.size === 0)
370
+ continue;
371
+ diffColumns(childTable, existingChildCols, buildChildExpectedCols(child.columns), changes);
372
+ }
373
+ // Diff union branch tables
374
+ for (const union of schema.unions) {
375
+ for (const branch of union.branches) {
376
+ const branchTable = branch.tableName.replace(/"/g, '');
377
+ const existingBranchCols = await getExistingColumns(branchTable);
378
+ if (existingBranchCols.size === 0)
379
+ continue;
380
+ diffColumns(branchTable, existingBranchCols, buildChildExpectedCols(branch.columns), changes);
381
+ }
382
+ }
383
+ }
384
+ // Detect and drop orphaned child/union tables (query table list once)
385
+ let allTableNames = null;
386
+ try {
387
+ const rows = await all(dialect.listTablesQuery);
388
+ allTableNames = rows.map((r) => r.table_name);
389
+ }
390
+ catch { }
391
+ if (allTableNames) {
392
+ for (const schema of tableSchemas) {
393
+ if (schema.columns.length === 0)
394
+ continue;
395
+ const expectedTables = new Set();
396
+ for (const child of schema.children) {
397
+ expectedTables.add(child.tableName.replace(/"/g, ''));
398
+ }
399
+ for (const union of schema.unions) {
400
+ for (const branch of union.branches) {
401
+ expectedTables.add(branch.tableName.replace(/"/g, ''));
402
+ }
403
+ }
404
+ for (const name of allTableNames) {
405
+ if (name.startsWith(schema.collection + '__') && !expectedTables.has(name)) {
406
+ await run(`DROP TABLE IF EXISTS "${name}"`);
407
+ emit('migration', 'drop_table', { table: name });
408
+ }
409
+ }
410
+ }
411
+ }
412
+ if (changes.length > 0) {
413
+ await applyMigrationChanges(changes);
414
+ }
415
+ // Trigger backfill only for genuinely new collections (tables created this startup)
416
+ // Previously this checked ALL empty tables, which caused infinite resync loops
417
+ // for collections that are legitimately empty (e.g. blocks when nobody has blocked)
418
+ if (newCollections.size > 0) {
419
+ const [hasRepos] = await all(`SELECT 1 FROM _repos LIMIT 1`);
420
+ if (hasRepos) {
421
+ await run(`UPDATE _repos SET status = 'pending' WHERE status = 'active'`);
422
+ for (const collection of newCollections) {
423
+ emit('migration', 'new_collection', { collection });
424
+ }
425
+ }
426
+ }
427
+ return changes;
428
+ }
429
+ async function applyMigrationChanges(changes) {
430
+ for (const change of changes) {
431
+ const quotedTable = `"${change.table}"`;
432
+ const quotedColumn = `"${change.column}"`;
433
+ // Index naming mirrors generateCreateTableSQL and the 'add' branch below:
434
+ // the table name with dots replaced by underscores, then the column name.
435
+ const indexName = `idx_${change.table.replace(/\./g, '_')}_${change.column}`;
436
+ // SQLite refuses to drop a column an index still references ("error in
437
+ // index ... after drop column"), so any DROP COLUMN has to clear it first.
438
+ const dropColumn = async () => {
439
+ await run(`DROP INDEX IF EXISTS "${indexName}"`);
440
+ await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${quotedColumn}`);
441
+ };
442
+ try {
443
+ switch (change.action) {
444
+ case 'add': {
445
+ await run(`ALTER TABLE ${quotedTable} ADD COLUMN ${quotedColumn} ${change.type}`);
446
+ emit('migration', 'add_column', { table: change.table, column: change.column, type: change.type });
447
+ const schema = schemas.get(change.table);
448
+ // `space` is indexed like a ref column but is not one: it is in the
449
+ // base row rather than the lexicon, so refColumns never names it, and
450
+ // an existing table that gains it would otherwise scan on every read.
451
+ if (change.column === 'space' || schema?.refColumns.includes(change.column)) {
452
+ await run(`CREATE INDEX IF NOT EXISTS ${indexName} ON ${quotedTable}(${quotedColumn})`);
453
+ }
454
+ break;
455
+ }
456
+ case 'drop':
457
+ await dropColumn();
458
+ emit('migration', 'drop_column', { table: change.table, column: change.column });
459
+ break;
460
+ case 'retype':
461
+ // A ref column is always TEXT, so a type change means it stopped
462
+ // being one — the index goes with the old column and is not rebuilt.
463
+ await dropColumn();
464
+ await run(`ALTER TABLE ${quotedTable} ADD COLUMN ${quotedColumn} ${change.type}`);
465
+ emit('migration', 'retype_column', { table: change.table, column: change.column, type: change.type });
466
+ break;
467
+ }
468
+ }
469
+ catch (err) {
470
+ console.warn(`[migration] failed to ${change.action} column "${change.column}" on "${change.table}": ${err.message}`);
471
+ emit('migration', 'error', {
472
+ action: change.action,
473
+ table: change.table,
474
+ column: change.column,
475
+ error: err.message,
476
+ });
477
+ }
478
+ }
479
+ }
480
+ export async function getCursor(key) {
481
+ const rows = await all(`SELECT value FROM _cursor WHERE key = $1`, [key]);
482
+ return rows[0]?.value || null;
483
+ }
484
+ export async function setCursor(key, value) {
485
+ await run(`INSERT OR REPLACE INTO _cursor (key, value) VALUES ($1, $2)`, [key, value]);
486
+ }
487
+ export async function getRepoStatus(did) {
488
+ const rows = await all(`SELECT status FROM _repos WHERE did = $1`, [did]);
489
+ return rows[0]?.status || null;
490
+ }
491
+ export async function setRepoStatus(did, status, rev, opts) {
492
+ if (status === 'active') {
493
+ // Update existing row preserving handle if not provided
494
+ await run(`UPDATE _repos SET status = $1, handle = COALESCE($2, handle), backfilled_at = $3, rev = COALESCE($4, rev), retry_count = 0, retry_after = 0 WHERE did = $5`, [status, opts?.handle || null, new Date().toISOString(), rev || null, did]);
495
+ // Insert if row didn't exist yet
496
+ await run(`INSERT OR IGNORE INTO _repos (did, status, handle, backfilled_at, rev, retry_count, retry_after) VALUES ($1, $2, $3, $4, $5, 0, 0)`, [did, status, opts?.handle || null, new Date().toISOString(), rev || null]);
497
+ }
498
+ else if (status === 'failed' && opts) {
499
+ await run(`UPDATE _repos SET status = $1, retry_count = $2, retry_after = $3, handle = COALESCE($4, handle) WHERE did = $5`, [status, opts.retryCount ?? 0, opts.retryAfter ?? 0, opts.handle || null, did]);
500
+ // If row didn't exist yet, insert it
501
+ await run(`INSERT OR IGNORE INTO _repos (did, status, handle, retry_count, retry_after) VALUES ($1, $2, $3, $4, $5)`, [did, status, opts.handle || null, opts.retryCount ?? 0, opts.retryAfter ?? 0]);
502
+ }
503
+ else {
504
+ await run(`UPDATE _repos SET status = $1 WHERE did = $2`, [status, did]);
505
+ await run(`INSERT OR IGNORE INTO _repos (did, status) VALUES ($1, $2)`, [did, status]);
506
+ }
507
+ }
508
+ /** Update the handle for a DID if it exists in _repos. */
509
+ export async function updateRepoHandle(did, handle) {
510
+ await run(`UPDATE _repos SET handle = $1 WHERE did = $2`, [handle, did]);
511
+ }
512
+ export async function getRepoRev(did) {
513
+ const rows = await all(`SELECT rev FROM _repos WHERE did = $1`, [did]);
514
+ return rows[0]?.rev ?? null;
515
+ }
516
+ export async function getRepoRetryInfo(did) {
517
+ const rows = await all(`SELECT retry_count, retry_after FROM _repos WHERE did = $1`, [did]);
518
+ if (rows.length === 0)
519
+ return null;
520
+ return { retryCount: Number(rows[0].retry_count), retryAfter: Number(rows[0].retry_after) };
521
+ }
522
+ export async function listRetryEligibleRepos(maxRetries) {
523
+ const now = Math.floor(Date.now() / 1000);
524
+ const rows = await all(`SELECT did FROM _repos WHERE status = 'failed' AND retry_after <= $1 AND retry_count < $2`, [now, maxRetries]);
525
+ return rows.map((r) => r.did);
526
+ }
527
+ export async function listPendingRepos() {
528
+ const rows = await all(`SELECT did FROM _repos WHERE status = 'pending'`);
529
+ return rows.map((r) => r.did);
530
+ }
531
+ export async function listActiveRepoDids() {
532
+ const rows = await all(`SELECT did FROM _repos WHERE status = 'active'`);
533
+ return rows.map((r) => r.did);
534
+ }
535
+ /**
536
+ * Repo statuses whose records no read serves: taken down by an admin here, or
537
+ * gone quiet on the network — deactivated (or suspended, or taken down by its
538
+ * host) and deleted. A SQL list, for `status NOT IN ...`.
539
+ */
540
+ export const HIDDEN_REPO_STATUSES = "('takendown', 'deactivated', 'deleted')";
541
+ /**
542
+ * Remove every record an account holds in the index, public and in spaces:
543
+ * what an account deleted on the network leaves behind. Each goes the way
544
+ * {@link deleteRecord} takes one — out of the search index, its child and
545
+ * union rows, then the row itself.
546
+ */
547
+ export async function purgeRepoRecords(did) {
548
+ let removed = 0;
549
+ for (const [collection, schema] of schemas) {
550
+ const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE did = $1`, [did]);
551
+ for (const { uri } of rows) {
552
+ await deleteRecord(collection, uri);
553
+ removed++;
554
+ }
555
+ }
556
+ return removed;
557
+ }
558
+ export async function removeRepo(did) {
559
+ await run(`DELETE FROM _repos WHERE did = $1`, [did]);
560
+ }
561
+ export async function getRepoHandle(did) {
562
+ const rows = await all(`SELECT handle FROM _repos WHERE did = $1`, [did]);
563
+ return rows[0]?.handle ?? null;
564
+ }
565
+ export async function listAllRepoStatuses() {
566
+ return (await all(`SELECT did, status FROM _repos`));
567
+ }
568
+ export async function listReposPaginated(opts = {}) {
569
+ const { limit = 50, offset = 0, status, q } = opts;
570
+ const conditions = [];
571
+ const params = [];
572
+ let paramIdx = 1;
573
+ if (status) {
574
+ conditions.push(`status = $${paramIdx++}`);
575
+ params.push(status);
576
+ }
577
+ if (q) {
578
+ conditions.push(`(did ${dialect.ilike} $${paramIdx} OR handle ${dialect.ilike} $${paramIdx})`);
579
+ params.push(`%${q}%`);
580
+ paramIdx++;
581
+ }
582
+ const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
583
+ const countRows = await all(`SELECT ${dialect.countAsInteger} as total FROM _repos${where}`, params);
584
+ const total = Number(countRows[0]?.total || 0);
585
+ const rows = await all(`SELECT did, handle, status, backfilled_at, rev FROM _repos${where} ORDER BY CASE WHEN backfilled_at IS NULL THEN 1 ELSE 0 END, backfilled_at DESC, did LIMIT $${paramIdx++} OFFSET $${paramIdx++}`, [...params, limit, offset]);
586
+ return { repos: rows, total };
587
+ }
588
+ export async function getCollectionCounts() {
589
+ const entries = [...schemas];
590
+ const counts = {};
591
+ for (const [collection] of entries)
592
+ counts[collection] = 0;
593
+ if (entries.length === 0)
594
+ return counts;
595
+ // One round trip instead of one per collection — reads are serialized on a
596
+ // single connection, so N queries means N waits behind whatever else is
597
+ // reading.
598
+ const sql = entries
599
+ .map(([collection, schema]) => `SELECT '${collection.replace(/'/g, "''")}' as collection, ${dialect.countAsInteger} as count FROM ${schema.tableName}`)
600
+ .join(' UNION ALL ');
601
+ const rows = await all(sql);
602
+ for (const row of rows)
603
+ counts[row.collection] = Number(row.count || 0);
604
+ return counts;
605
+ }
606
+ export async function getRepoStatusCounts() {
607
+ const rows = await all(`SELECT status, ${dialect.countAsInteger} as count FROM _repos GROUP BY status`);
608
+ const counts = {};
609
+ for (const row of rows)
610
+ counts[row.status] = Number(row.count);
611
+ return counts;
612
+ }
613
+ export async function getDatabaseSize() {
614
+ if (dialect.supportsSequences) {
615
+ // DuckDB: pragma_database_size returns pre-formatted strings
616
+ const rows = await all('SELECT database_size, memory_usage, memory_limit FROM pragma_database_size()');
617
+ return rows[0] ?? {};
618
+ }
619
+ // SQLite: compute from page_count * page_size
620
+ const pages = await all('SELECT page_count FROM pragma_page_count()');
621
+ const sizes = await all('SELECT page_size FROM pragma_page_size()');
622
+ const pageCount = Number(pages[0]?.page_count ?? 0);
623
+ const pageSize = Number(sizes[0]?.page_size ?? 0);
624
+ const bytes = pageCount * pageSize;
625
+ const mib = (bytes / 1024 / 1024).toFixed(1);
626
+ return { database_size: `${mib} MiB`, memory_usage: 'N/A', memory_limit: 'N/A' };
627
+ }
628
+ export async function getLabelCount(val) {
629
+ const rows = await all(`SELECT ${dialect.countAsInteger} as count FROM _labels WHERE val = $1`, [
630
+ val,
631
+ ]);
632
+ return Number(rows[0]?.count || 0);
633
+ }
634
+ export async function deleteLabels(val) {
635
+ const count = await getLabelCount(val);
636
+ await run(`DELETE FROM _labels WHERE val = $1`, [val]);
637
+ return count;
638
+ }
639
+ export async function getRecentRecords(collection, limit) {
640
+ const schema = schemas.get(collection);
641
+ if (!schema)
642
+ return [];
643
+ const gate = spaceFilterSql('t', 2);
644
+ const rows = await all(`SELECT t.* FROM ${schema.tableName} t JOIN _repos r ON t.did = r.did WHERE t.indexed_at > r.backfilled_at AND ${gate.sql} ORDER BY t.indexed_at DESC LIMIT $1`, [limit, ...gate.params]);
645
+ return rows;
646
+ }
647
+ export async function getSchemaDump() {
648
+ let rows;
649
+ if (dialect.supportsSequences) {
650
+ // DuckDB: use duckdb_tables() for full DDL
651
+ rows = await all(`SELECT sql FROM duckdb_tables() ORDER BY table_name`);
652
+ }
653
+ else {
654
+ // SQLite: use sqlite_master, skip FTS shadow/internal tables
655
+ rows = await all(`SELECT sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_fts_%' AND sql IS NOT NULL ORDER BY name`);
656
+ }
657
+ // Normalize indentation and formatting
658
+ return rows
659
+ .map((r) => {
660
+ let sql = r.sql.trim();
661
+ // Remove quotes around column names (SQLite adds them for some columns)
662
+ sql = sql.replace(/\n\s*"(\w+)"/g, '\n$1');
663
+ // Ensure closing paren is on its own line
664
+ sql = sql.replace(/([^(\s])\)$/, '$1\n)');
665
+ // Normalize leading-comma columns added by ALTER TABLE into trailing commas
666
+ sql = sql.replace(/\n\s*,\s*/g, ',\n');
667
+ // Split into lines and re-indent consistently
668
+ const lines = sql.split('\n').map((l) => l.trim());
669
+ sql = lines
670
+ .map((line, i) => {
671
+ if (i === 0)
672
+ return line; // CREATE TABLE line
673
+ if (line.startsWith(')'))
674
+ return ')'; // closing paren at top level
675
+ return ' ' + line; // indent columns
676
+ })
677
+ .join('\n');
678
+ return sql + ';';
679
+ })
680
+ .join('\n\n');
681
+ }
682
+ export function buildInsertOp(collection, uri, cid, authorDid, record) {
683
+ const schema = schemas.get(collection);
684
+ if (!schema)
685
+ throw new Error(`Unknown collection: ${collection}`);
686
+ // Derived from the URI rather than passed in. A space record's URI already
687
+ // names its space, so reading it here means no write path can land a space
688
+ // row with the column left NULL — which would publish it to every reader.
689
+ const colNames = ['uri', 'cid', 'did', 'space', 'indexed_at'];
690
+ const placeholders = ['$1', '$2', '$3', '$4', '$5'];
691
+ const values = [uri, cid, authorDid, spaceFromUri(uri) ?? null, new Date().toISOString()];
692
+ let paramIdx = 6;
693
+ for (const col of schema.columns) {
694
+ let rawValue = record[col.originalName];
695
+ // Handle strongRef expansion: subject_uri reads record.subject.uri, subject__cid reads record.subject.cid
696
+ if (rawValue && typeof rawValue === 'object' && col.name.endsWith('_uri') && col.isRef) {
697
+ rawValue = rawValue.uri;
698
+ }
699
+ else if (col.originalName.endsWith('__cid') && record[col.originalName.replace('__cid', '')]) {
700
+ rawValue = record[col.originalName.replace('__cid', '')].cid;
701
+ }
702
+ colNames.push(q(col.name));
703
+ placeholders.push(`$${paramIdx++}`);
704
+ if (rawValue === undefined || rawValue === null) {
705
+ values.push(null);
706
+ }
707
+ else if (col.isJson) {
708
+ values.push(JSON.stringify(rawValue));
709
+ }
710
+ else {
711
+ values.push(rawValue);
712
+ }
713
+ }
714
+ const sql = `INSERT OR REPLACE INTO ${schema.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`;
715
+ return { sql, params: values };
716
+ }
717
+ export async function insertRecord(collection, uri, cid, authorDid, record) {
718
+ const schema = schemas.get(collection);
719
+ if (!schema)
720
+ throw new Error(`Unknown collection: ${collection}`);
721
+ const { sql, params } = buildInsertOp(collection, uri, cid, authorDid, record);
722
+ await run(sql, params);
723
+ // Insert child table rows
724
+ for (const child of schema.children) {
725
+ const items = record[child.fieldName];
726
+ if (!Array.isArray(items))
727
+ continue;
728
+ // Delete existing child rows (handles INSERT OR REPLACE on main table)
729
+ await run(`DELETE FROM ${child.tableName} WHERE parent_uri = $1`, [uri]);
730
+ for (const item of items) {
731
+ const colNames = ['parent_uri', 'parent_did'];
732
+ const placeholders = ['$1', '$2'];
733
+ const values = [uri, authorDid];
734
+ let idx = 3;
735
+ for (const col of child.columns) {
736
+ colNames.push(q(col.name));
737
+ placeholders.push(`$${idx++}`);
738
+ const raw = item[col.originalName];
739
+ if (raw === undefined || raw === null) {
740
+ values.push(null);
741
+ }
742
+ else if (col.isJson) {
743
+ values.push(JSON.stringify(raw));
744
+ }
745
+ else {
746
+ values.push(raw);
747
+ }
748
+ }
749
+ await run(`INSERT INTO ${child.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`, values);
750
+ }
751
+ }
752
+ // Insert union branch rows
753
+ for (const union of schema.unions) {
754
+ const unionValue = record[union.fieldName];
755
+ if (!unionValue || !unionValue.$type)
756
+ continue;
757
+ const branch = union.branches.find((b) => b.type === unionValue.$type);
758
+ if (!branch)
759
+ continue;
760
+ // Delete existing branch rows (handles INSERT OR REPLACE)
761
+ for (const b of union.branches) {
762
+ await run(`DELETE FROM ${b.tableName} WHERE parent_uri = $1`, [uri]);
763
+ }
764
+ if (branch.isArray && branch.arrayField) {
765
+ // Array branch (e.g., embed.images) — insert one row per array item
766
+ const items = unionValue[branch.arrayField];
767
+ if (!Array.isArray(items))
768
+ continue;
769
+ for (const item of items) {
770
+ const colNames = ['parent_uri', 'parent_did'];
771
+ const placeholders = ['$1', '$2'];
772
+ const values = [uri, authorDid];
773
+ let idx = 3;
774
+ for (const col of branch.columns) {
775
+ colNames.push(q(col.name));
776
+ placeholders.push(`$${idx++}`);
777
+ const raw = item[col.originalName];
778
+ if (raw === undefined || raw === null) {
779
+ values.push(null);
780
+ }
781
+ else if (col.isJson) {
782
+ values.push(JSON.stringify(raw));
783
+ }
784
+ else {
785
+ values.push(raw);
786
+ }
787
+ }
788
+ await run(`INSERT INTO ${branch.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`, values);
789
+ }
790
+ }
791
+ else {
792
+ // Single-value branch — extract data from wrapper or direct properties
793
+ const branchData = resolveBranchData(unionValue, branch);
794
+ const colNames = ['parent_uri', 'parent_did'];
795
+ const placeholders = ['$1', '$2'];
796
+ const values = [uri, authorDid];
797
+ let idx = 3;
798
+ for (const col of branch.columns) {
799
+ colNames.push(q(col.name));
800
+ placeholders.push(`$${idx++}`);
801
+ const raw = branchData[col.originalName];
802
+ if (raw === undefined || raw === null) {
803
+ values.push(null);
804
+ }
805
+ else if (col.isJson) {
806
+ values.push(JSON.stringify(raw));
807
+ }
808
+ else {
809
+ values.push(raw);
810
+ }
811
+ }
812
+ await run(`INSERT INTO ${branch.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`, values);
813
+ }
814
+ }
815
+ // Incrementally update FTS index for this record
816
+ await updateFtsRecord(collection, uri);
817
+ }
818
+ /** Extract branch data from a union value, handling wrapper properties */
819
+ function resolveBranchData(unionValue, branch) {
820
+ if (branch.wrapperField) {
821
+ const wrapper = unionValue[branch.wrapperField];
822
+ if (wrapper && typeof wrapper === 'object')
823
+ return wrapper;
824
+ }
825
+ return unionValue;
826
+ }
827
+ export async function deleteRecord(collection, uri) {
828
+ const schema = schemas.get(collection);
829
+ if (!schema)
830
+ return;
831
+ // Remove from FTS index before deleting the record data
832
+ await deleteFtsRecord(collection, uri);
833
+ for (const child of schema.children) {
834
+ await run(`DELETE FROM ${child.tableName} WHERE parent_uri = $1`, [uri]);
835
+ }
836
+ for (const union of schema.unions) {
837
+ for (const branch of union.branches) {
838
+ await run(`DELETE FROM ${branch.tableName} WHERE parent_uri = $1`, [uri]);
839
+ }
840
+ }
841
+ await run(`DELETE FROM ${schema.tableName} WHERE uri = $1`, [uri]);
842
+ }
843
+ /**
844
+ * Remove every row a writer holds in one space.
845
+ *
846
+ * Scoped by `(space, did)` rather than by DID alone: the same account can hold
847
+ * public repo records in the same collections, and a full re-read of one space
848
+ * must not take those with it. Child and union rows are cleared by parent URI
849
+ * for the same reason — `parent_did` does not say which space a row came from.
850
+ */
851
+ export async function purgeSpaceRecords(space, did, collections) {
852
+ const CHUNK = 500;
853
+ for (const collection of collections) {
854
+ const schema = schemas.get(collection);
855
+ if (!schema)
856
+ continue;
857
+ const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE space = $1 AND did = $2`, [
858
+ space,
859
+ did,
860
+ ]);
861
+ const uris = rows.map((r) => r.uri);
862
+ if (uris.length === 0)
863
+ continue;
864
+ for (const uri of uris)
865
+ await deleteFtsRecord(collection, uri);
866
+ for (let i = 0; i < uris.length; i += CHUNK) {
867
+ const batch = uris.slice(i, i + CHUNK);
868
+ const placeholders = batch.map((_, j) => `$${j + 1}`).join(',');
869
+ for (const child of schema.children) {
870
+ await run(`DELETE FROM ${child.tableName} WHERE parent_uri IN (${placeholders})`, batch);
871
+ }
872
+ for (const union of schema.unions) {
873
+ for (const branch of union.branches) {
874
+ await run(`DELETE FROM ${branch.tableName} WHERE parent_uri IN (${placeholders})`, batch);
875
+ }
876
+ }
877
+ }
878
+ await run(`DELETE FROM ${schema.tableName} WHERE space = $1 AND did = $2`, [space, did]);
879
+ }
880
+ }
881
+ /** Every writer holding rows for a space, as the index currently has it. */
882
+ export async function listSpaceRecordDids(space, collections) {
883
+ const dids = new Set();
884
+ for (const collection of collections) {
885
+ const schema = schemas.get(collection);
886
+ if (!schema)
887
+ continue;
888
+ const rows = await all(`SELECT DISTINCT did FROM ${schema.tableName} WHERE space = $1`, [space]);
889
+ for (const row of rows)
890
+ dids.add(row.did);
891
+ }
892
+ return [...dids];
893
+ }
894
+ export async function insertLabels(labels) {
895
+ if (labels.length === 0)
896
+ return;
897
+ for (const label of labels) {
898
+ // Skip if an active (non-negated, non-expired, not-superseded-by-negation) label already exists for this src+uri+val
899
+ const existing = await all(`SELECT 1 FROM _labels l1 WHERE l1.src = $1 AND l1.uri = $2 AND l1.val = $3 AND l1.neg = false AND (l1.exp IS NULL OR l1.exp > CURRENT_TIMESTAMP) AND NOT EXISTS (SELECT 1 FROM _labels l2 WHERE l2.uri = l1.uri AND l2.val = l1.val AND l2.neg = true AND l2.id > l1.id) LIMIT 1`, [label.src, label.uri, label.val]);
900
+ if (!label.neg && existing.length > 0)
901
+ continue;
902
+ await run(`INSERT INTO _labels (src, uri, val, neg, cts, exp) VALUES ($1, $2, $3, $4, $5, $6)`, [
903
+ label.src,
904
+ label.uri,
905
+ label.val,
906
+ label.neg || false,
907
+ label.cts || new Date().toISOString(),
908
+ label.exp || null,
909
+ ]);
910
+ }
911
+ }
912
+ export async function queryLabelsForUris(uris) {
913
+ if (uris.length === 0)
914
+ return new Map();
915
+ const placeholders = uris.map((_, i) => `$${i + 1}`).join(',');
916
+ const rows = await all(`SELECT src, uri, val, neg, cts, exp FROM _labels l1 WHERE uri IN (${placeholders}) AND (exp IS NULL OR exp > CURRENT_TIMESTAMP) AND neg = false AND NOT EXISTS (SELECT 1 FROM _labels l2 WHERE l2.uri = l1.uri AND l2.val = l1.val AND l2.neg = true AND l2.id > l1.id) GROUP BY uri, val`, uris);
917
+ const result = new Map();
918
+ for (const row of rows) {
919
+ const key = row.uri;
920
+ if (!result.has(key))
921
+ result.set(key, []);
922
+ result.get(key).push({
923
+ src: row.src,
924
+ uri: row.uri,
925
+ val: row.val,
926
+ neg: !!row.neg,
927
+ cts: normalizeValue(row.cts),
928
+ ...(row.exp ? { exp: String(row.exp) } : {}),
929
+ });
930
+ }
931
+ return result;
932
+ }
933
+ /** Per-process token + counter, so concurrent calls never share a staging table. */
934
+ const STAGING_TOKEN = Math.floor(Math.random() * 0xffffff).toString(36);
935
+ let stagingCounter = 0;
936
+ function nextStagingId() {
937
+ return `${STAGING_TOKEN}_${(stagingCounter++).toString(36)}`;
938
+ }
939
+ export async function bulkInsertRecords(records) {
940
+ if (records.length === 0)
941
+ return 0;
942
+ // Group records by collection
943
+ const byCollection = new Map();
944
+ for (const rec of records) {
945
+ const list = byCollection.get(rec.collection) || [];
946
+ list.push(rec);
947
+ byCollection.set(rec.collection, list);
948
+ }
949
+ let inserted = 0;
950
+ for (const [collection, recs] of byCollection) {
951
+ const schema = schemas.get(collection);
952
+ if (!schema)
953
+ continue;
954
+ // Staging tables are named per call, not per collection: runBackfill
955
+ // imports repos in parallel, so two workers on the same collection would
956
+ // otherwise drop and recreate each other's staging table mid-insert.
957
+ const stagingTables = [];
958
+ const stagingName = (suffix) => {
959
+ const name = `_staging_${collection.replace(/\./g, '_')}${suffix}_${nextStagingId()}`;
960
+ stagingTables.push(name);
961
+ return name;
962
+ };
963
+ try {
964
+ const stagingTable = stagingName('');
965
+ const allCols = ['uri', 'cid', 'did', 'space', 'indexed_at', ...schema.columns.map((c) => q(c.name))];
966
+ const colDefs = [
967
+ 'uri TEXT',
968
+ 'cid TEXT',
969
+ 'did TEXT',
970
+ 'space TEXT',
971
+ 'indexed_at TEXT',
972
+ ...schema.columns.map((c) => {
973
+ const t = c.sqlType;
974
+ // Use TEXT for timestamp columns in staging (will cast on merge)
975
+ return `${q(c.name)} ${t === 'TIMESTAMP' || t === 'TIMESTAMPTZ' ? 'TEXT' : t}`;
976
+ }),
977
+ ];
978
+ await port.execute(`DROP TABLE IF EXISTS ${stagingTable}`, []);
979
+ await port.execute(`CREATE TABLE ${stagingTable} (${colDefs.join(', ')})`, []);
980
+ const inserter = await port.createBulkInserter(stagingTable, allCols);
981
+ const now = new Date().toISOString();
982
+ for (const rec of recs) {
983
+ try {
984
+ const values = [rec.uri, rec.cid, rec.did, spaceFromUri(rec.uri) ?? null, now];
985
+ for (const col of schema.columns) {
986
+ values.push(resolveColumnValue(col, rec.record));
987
+ }
988
+ inserter.append(values);
989
+ inserted++;
990
+ }
991
+ catch {
992
+ // Skip bad records
993
+ }
994
+ }
995
+ await inserter.close();
996
+ // Merge into target, filtering rows that would violate NOT NULL
997
+ const selectCols = allCols.map((name) => {
998
+ const col = schema.columns.find((c) => q(c.name) === name);
999
+ if (name === 'indexed_at' || (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ'))) {
1000
+ return `${dialect.tryCastTimestamp(name)} AS ${name}`;
1001
+ }
1002
+ return name;
1003
+ });
1004
+ const notNullChecks = ['uri IS NOT NULL', 'did IS NOT NULL'];
1005
+ for (const col of schema.columns) {
1006
+ if (col.notNull) {
1007
+ if (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ') {
1008
+ notNullChecks.push(`${dialect.tryCastTimestamp(q(col.name))} IS NOT NULL`);
1009
+ }
1010
+ else {
1011
+ notNullChecks.push(`${q(col.name)} IS NOT NULL`);
1012
+ }
1013
+ }
1014
+ }
1015
+ const whereClause = notNullChecks.length ? ` WHERE ${notNullChecks.join(' AND ')}` : '';
1016
+ await port.execute(`INSERT OR REPLACE INTO ${schema.tableName} (${allCols.join(', ')}) SELECT ${selectCols.join(', ')} FROM ${stagingTable}${whereClause}`, []);
1017
+ await port.execute(`DROP TABLE ${stagingTable}`, []);
1018
+ // Populate child tables
1019
+ for (const child of schema.children) {
1020
+ const childStagingTable = stagingName(`__${child.fieldName}`);
1021
+ const childColDefs = [
1022
+ 'parent_uri TEXT',
1023
+ 'parent_did TEXT',
1024
+ ...child.columns.map((c) => {
1025
+ const t = c.sqlType;
1026
+ return `${q(c.name)} ${t === 'TIMESTAMP' || t === 'TIMESTAMPTZ' ? 'TEXT' : t}`;
1027
+ }),
1028
+ ];
1029
+ const childAllCols = ['parent_uri', 'parent_did', ...child.columns.map((c) => q(c.name))];
1030
+ await port.execute(`DROP TABLE IF EXISTS ${childStagingTable}`, []);
1031
+ await port.execute(`CREATE TABLE ${childStagingTable} (${childColDefs.join(', ')})`, []);
1032
+ const childInserter = await port.createBulkInserter(childStagingTable, childAllCols);
1033
+ for (const rec of recs) {
1034
+ const items = rec.record[child.fieldName];
1035
+ if (!Array.isArray(items))
1036
+ continue;
1037
+ for (const item of items) {
1038
+ try {
1039
+ const values = [rec.uri, rec.did];
1040
+ for (const col of child.columns) {
1041
+ values.push(resolveRawColumnValue(col, item));
1042
+ }
1043
+ childInserter.append(values);
1044
+ }
1045
+ catch {
1046
+ // Skip bad items
1047
+ }
1048
+ }
1049
+ }
1050
+ await childInserter.close();
1051
+ // Delete existing child rows for these URIs, then merge staging
1052
+ const uriPlaceholders = recs.map((_, i) => `$${i + 1}`).join(',');
1053
+ await port.execute(`DELETE FROM ${child.tableName} WHERE parent_uri IN (${uriPlaceholders})`, recs.map((r) => r.uri));
1054
+ const childSelectCols = childAllCols.map((name) => {
1055
+ const col = child.columns.find((c) => q(c.name) === name);
1056
+ if (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ')) {
1057
+ return `${dialect.tryCastTimestamp(name)} AS ${name}`;
1058
+ }
1059
+ return name;
1060
+ });
1061
+ await port.execute(`INSERT INTO ${child.tableName} (${childAllCols.join(', ')}) SELECT ${childSelectCols.join(', ')} FROM ${childStagingTable} WHERE parent_uri IS NOT NULL`, []);
1062
+ await port.execute(`DROP TABLE ${childStagingTable}`, []);
1063
+ }
1064
+ // Populate union branch tables
1065
+ for (const union of schema.unions) {
1066
+ for (const branch of union.branches) {
1067
+ const branchStagingTable = stagingName(`__${toSnakeCase(union.fieldName)}_${branch.branchName}`);
1068
+ const branchColDefs = [
1069
+ 'parent_uri TEXT',
1070
+ 'parent_did TEXT',
1071
+ ...branch.columns.map((c) => {
1072
+ const t = c.sqlType;
1073
+ return `${q(c.name)} ${t === 'TIMESTAMP' || t === 'TIMESTAMPTZ' ? 'TEXT' : t}`;
1074
+ }),
1075
+ ];
1076
+ const branchAllCols = ['parent_uri', 'parent_did', ...branch.columns.map((c) => q(c.name))];
1077
+ await port.execute(`DROP TABLE IF EXISTS ${branchStagingTable}`, []);
1078
+ await port.execute(`CREATE TABLE ${branchStagingTable} (${branchColDefs.join(', ')})`, []);
1079
+ const branchInserter = await port.createBulkInserter(branchStagingTable, branchAllCols);
1080
+ for (const rec of recs) {
1081
+ const unionValue = rec.record[union.fieldName];
1082
+ if (!unionValue || typeof unionValue !== 'object')
1083
+ continue;
1084
+ if (unionValue.$type !== branch.type)
1085
+ continue;
1086
+ if (branch.isArray && branch.arrayField) {
1087
+ const items = resolveBranchData(unionValue, branch)[branch.arrayField];
1088
+ if (!Array.isArray(items))
1089
+ continue;
1090
+ for (const item of items) {
1091
+ try {
1092
+ const values = [rec.uri, rec.did];
1093
+ for (const col of branch.columns) {
1094
+ values.push(resolveRawColumnValue(col, item));
1095
+ }
1096
+ branchInserter.append(values);
1097
+ }
1098
+ catch {
1099
+ // Skip bad items
1100
+ }
1101
+ }
1102
+ }
1103
+ else {
1104
+ try {
1105
+ const branchData = resolveBranchData(unionValue, branch);
1106
+ const values = [rec.uri, rec.did];
1107
+ for (const col of branch.columns) {
1108
+ values.push(resolveRawColumnValue(col, branchData));
1109
+ }
1110
+ branchInserter.append(values);
1111
+ }
1112
+ catch {
1113
+ // Skip bad records
1114
+ }
1115
+ }
1116
+ }
1117
+ await branchInserter.close();
1118
+ // Delete existing branch rows for these URIs, then merge staging
1119
+ const uriPlaceholders = recs.map((_, i) => `$${i + 1}`).join(',');
1120
+ await port.execute(`DELETE FROM ${branch.tableName} WHERE parent_uri IN (${uriPlaceholders})`, recs.map((r) => r.uri));
1121
+ const branchSelectCols = branchAllCols.map((name) => {
1122
+ const col = branch.columns.find((c) => q(c.name) === name);
1123
+ if (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ')) {
1124
+ return `${dialect.tryCastTimestamp(name)} AS ${name}`;
1125
+ }
1126
+ return name;
1127
+ });
1128
+ await port.execute(`INSERT INTO ${branch.tableName} (${branchAllCols.join(', ')}) SELECT ${branchSelectCols.join(', ')} FROM ${branchStagingTable} WHERE parent_uri IS NOT NULL`, []);
1129
+ await port.execute(`DROP TABLE ${branchStagingTable}`, []);
1130
+ }
1131
+ }
1132
+ }
1133
+ finally {
1134
+ // Drop on the error path too, so a failed batch leaves nothing behind.
1135
+ for (const name of stagingTables) {
1136
+ try {
1137
+ await port.execute(`DROP TABLE IF EXISTS ${name}`, []);
1138
+ }
1139
+ catch { }
1140
+ }
1141
+ }
1142
+ }
1143
+ return inserted;
1144
+ }
1145
+ /** Extract a column value from a record, handling strongRef expansion and type coercion for bulk insert */
1146
+ function resolveColumnValue(col, record) {
1147
+ let rawValue = record[col.originalName];
1148
+ if (rawValue && typeof rawValue === 'object' && col.name.endsWith('_uri') && col.isRef) {
1149
+ rawValue = rawValue.uri;
1150
+ }
1151
+ else if (col.originalName.endsWith('__cid') && record[col.originalName.replace('__cid', '')]) {
1152
+ rawValue = record[col.originalName.replace('__cid', '')].cid;
1153
+ }
1154
+ return coerceValue(col.sqlType, rawValue);
1155
+ }
1156
+ /** Extract a raw column value from a data object and coerce for bulk insert */
1157
+ function resolveRawColumnValue(col, data) {
1158
+ return coerceValue(col.sqlType, data[col.originalName]);
1159
+ }
1160
+ /** Coerce a value to the appropriate type for insertion */
1161
+ function coerceValue(sqlType, rawValue) {
1162
+ if (rawValue === undefined || rawValue === null)
1163
+ return null;
1164
+ // Objects and arrays always need JSON stringification regardless of sqlType
1165
+ // (on SQLite, JSON columns map to TEXT but still need stringification)
1166
+ if (typeof rawValue === 'object' && !(rawValue instanceof Uint8Array)) {
1167
+ return JSON.stringify(rawValue);
1168
+ }
1169
+ if (sqlType === 'JSON' || sqlType === 'TEXT') {
1170
+ return String(rawValue);
1171
+ }
1172
+ if (sqlType === 'INTEGER' || sqlType === 'BIGINT') {
1173
+ return typeof rawValue === 'number' ? rawValue : parseInt(rawValue);
1174
+ }
1175
+ if (sqlType === 'BOOLEAN')
1176
+ return !!rawValue;
1177
+ return String(rawValue);
1178
+ }
1179
+ export async function queryRecords(collection, opts = {}) {
1180
+ const schema = schemas.get(collection);
1181
+ if (!schema)
1182
+ throw new Error(`Unknown collection: ${collection}`);
1183
+ const { limit = 20, cursor, filters, sort = 'indexed_at', order = 'desc' } = opts;
1184
+ // `order` is interpolated into the ORDER BY clause, not bound as a
1185
+ // parameter, so it has to be an exact match rather than a trusted caller
1186
+ // value — anything else is SQL reaching the engine verbatim.
1187
+ const direction = String(order).toUpperCase();
1188
+ if (direction !== 'ASC' && direction !== 'DESC')
1189
+ throw new Error(`Invalid sort order: ${order}`);
1190
+ // SQLite (and DuckDB) read a negative LIMIT as unbounded, so an unchecked
1191
+ // -1 returns the entire table.
1192
+ if (!Number.isInteger(limit) || limit < 1)
1193
+ throw new Error(`Invalid limit: ${limit}`);
1194
+ // Validate sort field exists
1195
+ const sortCol = sort === 'indexed_at' ? 'indexed_at' : schema.columns.find((c) => c.originalName === sort || c.name === sort);
1196
+ const sortName = typeof sortCol === 'string' ? sortCol : sortCol?.name;
1197
+ if (!sortName)
1198
+ throw new Error(`Invalid sort field: ${sort}`);
1199
+ const conditions = [];
1200
+ const params = [];
1201
+ let paramIdx = 1;
1202
+ // Cursor pagination — compound keyset (sortCol, cid)
1203
+ if (cursor) {
1204
+ const parsed = unpackCursor(cursor);
1205
+ if (parsed) {
1206
+ const op = direction === 'DESC' ? '<' : '>';
1207
+ const pSort1 = `$${paramIdx++}`;
1208
+ const pSort2 = `$${paramIdx++}`;
1209
+ const pCid = `$${paramIdx++}`;
1210
+ conditions.push(`(t.${sortName} ${op} ${pSort1} OR (t.${sortName} = ${pSort2} AND t.cid ${op} ${pCid}))`);
1211
+ params.push(parsed.primary, parsed.primary, parsed.cid);
1212
+ }
1213
+ }
1214
+ // Field filters — validate each against schema
1215
+ if (filters) {
1216
+ const validColumns = new Set(schema.columns.map((c) => c.name));
1217
+ validColumns.add('did');
1218
+ for (const [key, value] of Object.entries(filters)) {
1219
+ const colName = toSnakeCase(key);
1220
+ if (!validColumns.has(colName))
1221
+ continue; // silently skip invalid filters
1222
+ conditions.push(`t.${colName} = $${paramIdx++}`);
1223
+ params.push(value);
1224
+ }
1225
+ }
1226
+ // Space rows are served only to a read scoped to that space; see spaces/visibility.ts.
1227
+ const gate = spaceFilterSql('t', paramIdx);
1228
+ conditions.push(gate.sql);
1229
+ params.push(...gate.params);
1230
+ paramIdx = gate.nextIdx;
1231
+ let sql = `SELECT t.*, r.handle FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did WHERE (r.status IS NULL OR r.status NOT IN ${HIDDEN_REPO_STATUSES})`;
1232
+ if (conditions.length)
1233
+ sql += ' AND ' + conditions.join(' AND ');
1234
+ sql += ` ORDER BY t.${sortName} ${direction}, t.cid ${direction} LIMIT $${paramIdx++}`;
1235
+ params.push(limit + 1); // fetch one extra for cursor
1236
+ const rows = await all(sql, params);
1237
+ const hasMore = rows.length > limit;
1238
+ if (hasMore)
1239
+ rows.pop();
1240
+ // Attach child data if this collection has decomposed arrays
1241
+ if (schema.children.length > 0 && rows.length > 0) {
1242
+ const uris = rows.map((r) => r.uri);
1243
+ const childData = new Map();
1244
+ for (const child of schema.children) {
1245
+ const childRows = await getChildRows(child.tableName, uris);
1246
+ childData.set(child.fieldName, childRows);
1247
+ }
1248
+ for (const row of rows) {
1249
+ ;
1250
+ row.__childData = childData;
1251
+ }
1252
+ }
1253
+ // Attach union branch data
1254
+ if (schema.unions.length > 0 && rows.length > 0) {
1255
+ const uris = rows.map((r) => r.uri);
1256
+ const unionData = new Map();
1257
+ for (const union of schema.unions) {
1258
+ const branchData = new Map();
1259
+ for (const branch of union.branches) {
1260
+ const branchRows = await getChildRows(branch.tableName, uris);
1261
+ branchData.set(branch.branchName, branchRows);
1262
+ }
1263
+ unionData.set(union.fieldName, branchData);
1264
+ }
1265
+ for (const row of rows) {
1266
+ ;
1267
+ row.__unionData = unionData;
1268
+ }
1269
+ }
1270
+ const lastRow = rows[rows.length - 1];
1271
+ const nextCursor = hasMore && lastRow ? packCursor(lastRow[sortName], lastRow.cid) : undefined;
1272
+ return { records: rows, cursor: nextCursor };
1273
+ }
1274
+ export async function getRecordByUri(uri) {
1275
+ for (const [_collection, schema] of schemas) {
1276
+ const gate = spaceFilterSql('t', 2);
1277
+ const rows = await all(`SELECT t.*, r.handle FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did WHERE t.uri = $1 AND ${gate.sql} AND (r.status IS NULL OR r.status NOT IN ${HIDDEN_REPO_STATUSES})`, [uri, ...gate.params]);
1278
+ if (rows.length > 0) {
1279
+ const row = rows[0];
1280
+ if (schema.children.length > 0) {
1281
+ const childData = new Map();
1282
+ for (const child of schema.children) {
1283
+ const childRows = await getChildRows(child.tableName, [uri]);
1284
+ childData.set(child.fieldName, childRows);
1285
+ }
1286
+ ;
1287
+ row.__childData = childData;
1288
+ }
1289
+ if (schema.unions.length > 0) {
1290
+ const unionData = new Map();
1291
+ for (const union of schema.unions) {
1292
+ const branchData = new Map();
1293
+ for (const branch of union.branches) {
1294
+ const branchRows = await getChildRows(branch.tableName, [uri]);
1295
+ branchData.set(branch.branchName, branchRows);
1296
+ }
1297
+ unionData.set(union.fieldName, branchData);
1298
+ }
1299
+ ;
1300
+ row.__unionData = unionData;
1301
+ }
1302
+ return row;
1303
+ }
1304
+ }
1305
+ return null;
1306
+ }
1307
+ export async function getRecordsByUris(collection, uris) {
1308
+ if (uris.length === 0)
1309
+ return [];
1310
+ const schema = schemas.get(collection);
1311
+ if (!schema)
1312
+ return [];
1313
+ const placeholders = uris.map((_, i) => `$${i + 1}`).join(',');
1314
+ const gate = spaceFilterSql('t', uris.length + 1);
1315
+ const rows = await all(`SELECT t.*, r.handle FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did WHERE t.uri IN (${placeholders}) AND ${gate.sql} AND (r.status IS NULL OR r.status NOT IN ${HIDDEN_REPO_STATUSES})`, [...uris, ...gate.params]);
1316
+ // Batch-fetch child rows for all URIs
1317
+ const childData = new Map();
1318
+ for (const child of schema.children) {
1319
+ const childRows = await getChildRows(child.tableName, uris);
1320
+ childData.set(child.fieldName, childRows);
1321
+ }
1322
+ // Batch-fetch union branch rows for all URIs
1323
+ const unionData = new Map();
1324
+ for (const union of schema.unions) {
1325
+ const branchData = new Map();
1326
+ for (const branch of union.branches) {
1327
+ const branchRows = await getChildRows(branch.tableName, uris);
1328
+ branchData.set(branch.branchName, branchRows);
1329
+ }
1330
+ unionData.set(union.fieldName, branchData);
1331
+ }
1332
+ // Attach child data to rows for reshapeRow
1333
+ for (const row of rows) {
1334
+ ;
1335
+ row.__childData = childData;
1336
+ if (unionData.size > 0)
1337
+ row.__unionData = unionData;
1338
+ }
1339
+ // Preserve ordering
1340
+ const byUri = new Map(rows.map((r) => [r.uri, r]));
1341
+ return uris.map((u) => byUri.get(u)).filter(Boolean);
1342
+ }
1343
+ /** Fetch records by URIs and return as a shaped Map keyed by URI. */
1344
+ export async function getRecordsMap(collection, uris) {
1345
+ if (uris.length === 0)
1346
+ return new Map();
1347
+ const records = await getRecordsByUris(collection, uris);
1348
+ const map = new Map();
1349
+ for (const r of records) {
1350
+ const shaped = reshapeRow(r, r?.__childData, r?.__unionData);
1351
+ if (shaped)
1352
+ map.set(shaped.uri, shaped);
1353
+ }
1354
+ return map;
1355
+ }
1356
+ /**
1357
+ * Multi-phase search across any collection's records.
1358
+ *
1359
+ * 1. **BM25** — Full-text search via DuckDB FTS. Multi-word queries use conjunctive
1360
+ * mode (ALL terms required) to avoid spurious single-token matches.
1361
+ * 2. **Exact substring** — ILIKE scan on all TEXT/JSON columns catches phrase matches
1362
+ * that BM25 missed or ranked low (e.g. "bad bunny"). Results are prepended to BM25.
1363
+ * 3. **Recent rows** — ILIKE scan of rows ingested since the last FTS rebuild, so newly
1364
+ * written records are immediately searchable before the index catches up.
1365
+ * 4. **Fuzzy** — Jaro-Winkler similarity fallback for typo tolerance when earlier phases
1366
+ * return fewer than `limit` results.
1367
+ *
1368
+ * All phases derive searchable columns generically from the collection schema — no
1369
+ * column names are hardcoded.
1370
+ */
1371
+ export async function searchRecords(collection, query, opts = {}) {
1372
+ const schema = schemas.get(collection);
1373
+ if (!schema)
1374
+ throw new Error(`Unknown collection: ${collection}`);
1375
+ const elapsed = timer();
1376
+ const { limit = 20, fuzzy = true } = opts;
1377
+ const textCols = schema.columns.filter((c) => c.sqlType === 'TEXT');
1378
+ // Also check if FTS has indexed any columns (including derived JSON columns)
1379
+ const ftsSearchCols = getSearchColumns(collection);
1380
+ if (textCols.length === 0 && ftsSearchCols.length === 0) {
1381
+ throw new Error(`No searchable columns in ${collection}`);
1382
+ }
1383
+ // FTS shadow table name (dots replaced with underscores)
1384
+ const safeName = '_fts_' + collection.replace(/\./g, '_');
1385
+ const phaseErrors = [];
1386
+ const phasesUsed = [];
1387
+ // Phase 1: BM25 ranked search via SearchPort
1388
+ let bm25Results = [];
1389
+ const sp = getSearchPort();
1390
+ if (sp)
1391
+ try {
1392
+ const ftsQuery = stripStopWords(query);
1393
+ const ftsSearchColNames = getSearchColumns(collection);
1394
+ // Get ranked URIs from the search port
1395
+ const hits = await sp.search(safeName, ftsQuery, ftsSearchColNames, limit + 1, 0);
1396
+ if (hits.length > 0) {
1397
+ const uriList = hits.map((h) => h.uri);
1398
+ const scoreMap = new Map(hits.map((h) => [h.uri, h.score]));
1399
+ // Fetch full records for matched URIs
1400
+ const placeholders = uriList.map((_, i) => `$${i + 1}`).join(', ');
1401
+ const gate = spaceFilterSql('m', uriList.length + 1);
1402
+ const rows = await all(`SELECT m.* FROM ${schema.tableName} m
1403
+ LEFT JOIN _repos r ON m.did = r.did
1404
+ WHERE m.uri IN (${placeholders})
1405
+ AND ${gate.sql}
1406
+ AND (r.status IS NULL OR r.status NOT IN ${HIDDEN_REPO_STATUSES})`, [...uriList, ...gate.params]);
1407
+ // Re-attach scores and sort
1408
+ bm25Results = rows
1409
+ .map((r) => ({ ...r, score: scoreMap.get(r.uri) ?? 0 }))
1410
+ .sort((a, b) => b.score - a.score);
1411
+ }
1412
+ phasesUsed.push('bm25');
1413
+ }
1414
+ catch (err) {
1415
+ phaseErrors.push(`bm25: ${err.message}`);
1416
+ }
1417
+ const bm25Count = bm25Results.length;
1418
+ const hasMore = bm25Results.length > limit;
1419
+ if (hasMore)
1420
+ bm25Results.pop();
1421
+ const existingUris = new Set(bm25Results.map((r) => r.uri));
1422
+ // Phase 2: Fuzzy fallback for typo tolerance (if still under limit)
1423
+ // Only available on dialects with jaro_winkler_similarity (DuckDB)
1424
+ let fuzzyCount = 0;
1425
+ if (fuzzy && dialect.jaroWinklerSimilarity && bm25Results.length < limit) {
1426
+ const remaining = limit - bm25Results.length;
1427
+ const jwFn = dialect.jaroWinklerSimilarity;
1428
+ const simExprs = [
1429
+ ...textCols.map((c) => `${jwFn}(lower(t.${q(c.name)}), lower($1))`),
1430
+ `${jwFn}(lower(r.handle), lower($1))`,
1431
+ ];
1432
+ // Include child table TEXT columns via correlated subquery
1433
+ for (const child of schema.children) {
1434
+ for (const col of child.columns) {
1435
+ if (col.sqlType === 'TEXT') {
1436
+ simExprs.push(`COALESCE((SELECT MAX(${jwFn}(lower(c.${q(col.name)}), lower($1))) FROM ${child.tableName} c WHERE c.parent_uri = t.uri), 0)`);
1437
+ }
1438
+ }
1439
+ }
1440
+ const greatestExpr = dialect.greatest(simExprs);
1441
+ const fuzzyGate = spaceFilterSql('t', 3);
1442
+ const fuzzySQL = `SELECT t.*, ${greatestExpr} AS fuzzy_score
1443
+ FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did
1444
+ WHERE ${greatestExpr} >= 0.8
1445
+ AND ${fuzzyGate.sql}
1446
+ ORDER BY fuzzy_score DESC
1447
+ LIMIT $2`;
1448
+ try {
1449
+ const fuzzyRows = await all(fuzzySQL, [
1450
+ query,
1451
+ remaining + existingUris.size,
1452
+ ...fuzzyGate.params,
1453
+ ]);
1454
+ phasesUsed.push('fuzzy');
1455
+ for (const row of fuzzyRows) {
1456
+ if (bm25Results.length >= limit)
1457
+ break;
1458
+ if (!existingUris.has(row.uri)) {
1459
+ bm25Results.push(row);
1460
+ fuzzyCount++;
1461
+ }
1462
+ }
1463
+ }
1464
+ catch (err) {
1465
+ phaseErrors.push(`fuzzy: ${err.message}`);
1466
+ }
1467
+ }
1468
+ // Remove score columns and reshape into Row<T> with value
1469
+ const rawRecords = bm25Results.map(({ score: _score, fuzzy_score: _fuzzy_score, ...rest }) => rest);
1470
+ const records = rawRecords
1471
+ .map((r) => reshapeRow(r, r?.__childData, r?.__unionData))
1472
+ .filter((r) => r != null);
1473
+ const lastRow = bm25Results[bm25Results.length - 1];
1474
+ const nextCursor = hasMore && lastRow?.score != null ? packCursor(lastRow.score, lastRow.cid) : undefined;
1475
+ emit('search', 'query', {
1476
+ collection,
1477
+ query,
1478
+ bm25_count: bm25Count > limit ? bm25Count - 1 : bm25Count,
1479
+ fuzzy_count: fuzzyCount,
1480
+ total_results: records.length,
1481
+ duration_ms: elapsed(),
1482
+ phases_used: phasesUsed.join(','),
1483
+ error: phaseErrors.length > 0 ? phaseErrors.join('; ') : undefined,
1484
+ });
1485
+ return { records, cursor: nextCursor };
1486
+ }
1487
+ // Raw SQL for script feeds
1488
+ export async function querySQL(sql, params = []) {
1489
+ return all(sql, params);
1490
+ }
1491
+ export async function runSQL(sql, params = []) {
1492
+ return run(sql, params);
1493
+ }
1494
+ export async function createBulkInserterSQL(table, columns, options) {
1495
+ return port.createBulkInserter(table, columns, options);
1496
+ }
1497
+ export function getSchema(collection) {
1498
+ return schemas.get(collection);
1499
+ }
1500
+ /** Every registered collection's table schema. */
1501
+ export function listSchemas() {
1502
+ return [...schemas.values()];
1503
+ }
1504
+ export async function countByField(collection, field, value) {
1505
+ const schema = schemas.get(collection);
1506
+ if (!schema)
1507
+ return 0;
1508
+ const gate = spaceFilterSql('', 2);
1509
+ const rows = await all(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE ${field} = $1 AND ${gate.sql}`, [value, ...gate.params]);
1510
+ return Number(rows[0]?.count || 0);
1511
+ }
1512
+ export async function countByFieldBatch(collection, field, values) {
1513
+ if (values.length === 0)
1514
+ return new Map();
1515
+ const schema = schemas.get(collection);
1516
+ if (!schema)
1517
+ return new Map();
1518
+ const placeholders = values.map((_, i) => `$${i + 1}`).join(',');
1519
+ const gate = spaceFilterSql('', values.length + 1);
1520
+ const rows = await all(`SELECT ${field}, COUNT(*) as count FROM ${schema.tableName} WHERE ${field} IN (${placeholders}) AND ${gate.sql} GROUP BY ${field}`, [...values, ...gate.params]);
1521
+ const result = new Map();
1522
+ for (const row of rows) {
1523
+ result.set(row[field], Number(row.count));
1524
+ }
1525
+ return result;
1526
+ }
1527
+ export async function findByField(collection, field, value) {
1528
+ const schema = schemas.get(collection);
1529
+ if (!schema)
1530
+ return null;
1531
+ const gate = spaceFilterSql('', 2);
1532
+ const rows = await all(`SELECT * FROM ${schema.tableName} WHERE ${field} = $1 AND ${gate.sql} LIMIT 1`, [
1533
+ value,
1534
+ ...gate.params,
1535
+ ]);
1536
+ return rows[0] || null;
1537
+ }
1538
+ export async function findByFieldBatch(collection, field, values) {
1539
+ if (values.length === 0)
1540
+ return new Map();
1541
+ const schema = schemas.get(collection);
1542
+ if (!schema)
1543
+ return new Map();
1544
+ const placeholders = values.map((_, i) => `$${i + 1}`).join(',');
1545
+ const gate = spaceFilterSql('t', values.length + 1);
1546
+ const rows = await all(`SELECT t.*, r.handle FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did WHERE t.${field} IN (${placeholders}) AND ${gate.sql}`, [...values, ...gate.params]);
1547
+ // Attach child data if this collection has decomposed arrays
1548
+ if (schema.children.length > 0 && rows.length > 0) {
1549
+ const uris = rows.map((r) => r.uri);
1550
+ const childData = new Map();
1551
+ for (const child of schema.children) {
1552
+ const childRows = await getChildRows(child.tableName, uris);
1553
+ childData.set(child.fieldName, childRows);
1554
+ }
1555
+ for (const row of rows) {
1556
+ row.__childData = childData;
1557
+ }
1558
+ }
1559
+ const result = new Map();
1560
+ for (const row of rows) {
1561
+ const key = row[field];
1562
+ if (!result.has(key))
1563
+ result.set(key, []);
1564
+ result.get(key).push(row);
1565
+ }
1566
+ return result;
1567
+ }
1568
+ export async function lookupByFieldBatch(collection, field, values) {
1569
+ if (values.length === 0)
1570
+ return new Map();
1571
+ const results = await findByFieldBatch(collection, field, values);
1572
+ const map = new Map();
1573
+ for (const [key, records] of results) {
1574
+ const shaped = records.length > 0 ? reshapeRow(records[0], records[0]?.__childData) : null;
1575
+ if (shaped)
1576
+ map.set(key, shaped);
1577
+ }
1578
+ return map;
1579
+ }
1580
+ export async function findUriByFields(collection, conditions) {
1581
+ const schema = schemas.get(collection);
1582
+ if (!schema)
1583
+ return null;
1584
+ const where = conditions.map((c, i) => `${c.field} = $${i + 1}`).join(' AND ');
1585
+ const params = conditions.map((c) => c.value);
1586
+ const gate = spaceFilterSql('', conditions.length + 1);
1587
+ const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE ${where} AND ${gate.sql} LIMIT 1`, [...params, ...gate.params]);
1588
+ return rows[0]?.uri || null;
1589
+ }
1590
+ const ENVELOPE_KEYS = new Set(['uri', 'cid', 'did', 'space', 'handle', 'indexed_at']);
1591
+ const INTERNAL_KEYS = new Set(['__childData', '__unionData']);
1592
+ export function normalizeValue(v) {
1593
+ if (v && typeof v === 'object' && 'micros' in v)
1594
+ return new Date(Number(v.micros) / 1000).toISOString();
1595
+ if (typeof v === 'bigint')
1596
+ return Number(v);
1597
+ return v;
1598
+ }
1599
+ export async function getChildRows(childTableName, parentUris) {
1600
+ if (parentUris.length === 0)
1601
+ return new Map();
1602
+ const placeholders = parentUris.map((_, i) => `$${i + 1}`).join(',');
1603
+ const rows = await all(`SELECT * FROM ${childTableName} WHERE parent_uri IN (${placeholders})`, parentUris);
1604
+ const result = new Map();
1605
+ for (const row of rows) {
1606
+ const key = row.parent_uri;
1607
+ if (!result.has(key))
1608
+ result.set(key, []);
1609
+ result.get(key).push(row);
1610
+ }
1611
+ return result;
1612
+ }
1613
+ /**
1614
+ * Normalize one stored column back to its record value. JSON columns are
1615
+ * written with JSON.stringify on insert, so they have to be parsed on the way
1616
+ * out — on child and union-branch rows just as much as on the main table.
1617
+ */
1618
+ function decodeColumn(col, raw) {
1619
+ const val = normalizeValue(raw);
1620
+ if (!col.isJson || typeof val !== 'string')
1621
+ return val;
1622
+ try {
1623
+ return JSON.parse(val);
1624
+ }
1625
+ catch {
1626
+ return val;
1627
+ }
1628
+ }
1629
+ export function reshapeRow(row, childData, unionData) {
1630
+ if (!row)
1631
+ return null;
1632
+ // A space record's URI puts the collection in a different segment than a
1633
+ // repo record's. Reading it positionally found no schema for space rows,
1634
+ // which left their columns snake_cased and their JSON columns unparsed.
1635
+ const collection = row.uri ? collectionFromRecordUri(row.uri) : undefined;
1636
+ const schema = collection ? schemas.get(collection) : null;
1637
+ // Build snake→camel map and JSON column set from schema
1638
+ const nameMap = new Map();
1639
+ const jsonCols = new Set();
1640
+ if (schema) {
1641
+ for (const col of schema.columns) {
1642
+ nameMap.set(col.name, col.originalName);
1643
+ if (col.isJson)
1644
+ jsonCols.add(col.name);
1645
+ }
1646
+ }
1647
+ const value = {};
1648
+ const envelope = {};
1649
+ for (const [key, rawVal] of Object.entries(row)) {
1650
+ const val = normalizeValue(rawVal);
1651
+ if (INTERNAL_KEYS.has(key)) {
1652
+ continue;
1653
+ }
1654
+ else if (ENVELOPE_KEYS.has(key)) {
1655
+ envelope[key] = val;
1656
+ }
1657
+ else {
1658
+ const originalKey = nameMap.get(key) || key;
1659
+ if (jsonCols.has(key) && typeof val === 'string') {
1660
+ try {
1661
+ value[originalKey] = JSON.parse(val);
1662
+ }
1663
+ catch {
1664
+ value[originalKey] = val;
1665
+ }
1666
+ }
1667
+ else {
1668
+ value[originalKey] = val;
1669
+ }
1670
+ }
1671
+ }
1672
+ // A strongRef is stored as two columns so the uri can be joined on, and
1673
+ // reads back as the object the lexicon declares: `{ uri, cid }`. The
1674
+ // generated type for the field says so, and a handler written against it
1675
+ // must find it.
1676
+ if (schema) {
1677
+ for (const col of schema.columns) {
1678
+ if (!col.originalName.endsWith('__cid'))
1679
+ continue;
1680
+ const field = col.originalName.slice(0, -'__cid'.length);
1681
+ const uri = value[field];
1682
+ const cid = value[col.originalName];
1683
+ delete value[col.originalName];
1684
+ if (uri == null && cid == null) {
1685
+ delete value[field];
1686
+ continue;
1687
+ }
1688
+ value[field] = { ...(uri != null ? { uri } : {}), ...(cid != null ? { cid } : {}) };
1689
+ }
1690
+ }
1691
+ // Only space rows carry a space, and an explicit null on every other row
1692
+ // would put a field on the wire that means nothing there.
1693
+ if (envelope.space == null)
1694
+ delete envelope.space;
1695
+ // Reconstruct decomposed array fields from child data
1696
+ if (schema && childData) {
1697
+ for (const child of schema.children) {
1698
+ const childMap = childData.get(child.fieldName);
1699
+ const childRows = childMap?.get(row.uri) || [];
1700
+ value[child.fieldName] = childRows.map((cr) => {
1701
+ const item = {};
1702
+ for (const col of child.columns) {
1703
+ item[col.originalName] = decodeColumn(col, cr[col.name]);
1704
+ }
1705
+ return item;
1706
+ });
1707
+ }
1708
+ }
1709
+ // Reconstruct union fields from branch data
1710
+ const uData = unionData || row.__unionData;
1711
+ if (schema && uData) {
1712
+ for (const union of schema.unions) {
1713
+ const branchDataMap = uData.get(union.fieldName);
1714
+ if (!branchDataMap)
1715
+ continue;
1716
+ // Find which branch has rows for this URI (implicit discrimination)
1717
+ for (const branch of union.branches) {
1718
+ const branchMap = branchDataMap.get(branch.branchName);
1719
+ const branchRows = branchMap?.get(row.uri);
1720
+ if (!branchRows || branchRows.length === 0)
1721
+ continue;
1722
+ if (branch.isArray && branch.arrayField) {
1723
+ // Array branch: reconstruct { $type, arrayField: [...items] }
1724
+ const items = branchRows.map((br) => {
1725
+ const item = {};
1726
+ for (const col of branch.columns) {
1727
+ item[col.originalName] = decodeColumn(col, br[col.name]);
1728
+ }
1729
+ return item;
1730
+ });
1731
+ value[union.fieldName] = { $type: branch.type, [branch.arrayField]: items };
1732
+ }
1733
+ else {
1734
+ // Single-value branch: reconstruct { $type, ...properties }
1735
+ // If branchName matches a wrapper property pattern, nest under it
1736
+ const br = branchRows[0];
1737
+ const props = {};
1738
+ for (const col of branch.columns) {
1739
+ props[col.originalName] = decodeColumn(col, br[col.name]);
1740
+ }
1741
+ if (branch.wrapperField) {
1742
+ value[union.fieldName] = { $type: branch.type, [branch.wrapperField]: props };
1743
+ }
1744
+ else {
1745
+ value[union.fieldName] = { $type: branch.type, ...props };
1746
+ }
1747
+ }
1748
+ break; // Only one branch should match
1749
+ }
1750
+ }
1751
+ }
1752
+ return { ...envelope, value };
1753
+ }
1754
+ export function packCursor(sortVal, cid) {
1755
+ const primary = sortVal instanceof Date ? sortVal.toISOString() : String(sortVal);
1756
+ return Buffer.from(`${primary}::${cid}`).toString('base64url');
1757
+ }
1758
+ export function unpackCursor(cursor) {
1759
+ try {
1760
+ const decoded = Buffer.from(cursor, 'base64url').toString();
1761
+ const idx = decoded.lastIndexOf('::');
1762
+ if (idx === -1)
1763
+ return null;
1764
+ return { primary: decoded.substring(0, idx), cid: decoded.substring(idx + 2) };
1765
+ }
1766
+ catch {
1767
+ return null;
1768
+ }
1769
+ }
1770
+ export async function queryLabelsByDid(did) {
1771
+ return all(`SELECT * FROM _labels WHERE uri LIKE $1 AND neg = false AND (exp IS NULL OR exp > CURRENT_TIMESTAMP)`, [
1772
+ `at://${did}/%`,
1773
+ ]);
1774
+ }
1775
+ export async function searchAccounts(query, limit = 20) {
1776
+ return all(`SELECT did, handle, status FROM _repos WHERE did ${dialect.ilike} $1 OR handle ${dialect.ilike} $1 ORDER BY handle LIMIT $2`, [`%${query}%`, limit]);
1777
+ }
1778
+ export async function getAccountRecordCount(did) {
1779
+ let total = 0;
1780
+ for (const [, schema] of schemas) {
1781
+ const rows = await all(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE did = $1`, [did]);
1782
+ total += Number(rows[0]?.count || 0);
1783
+ }
1784
+ return total;
1785
+ }
1786
+ export async function getAllRecordUrisForDid(did) {
1787
+ const uris = [];
1788
+ for (const [, schema] of schemas) {
1789
+ const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE did = $1`, [did]);
1790
+ uris.push(...rows.map((r) => r.uri));
1791
+ }
1792
+ return uris;
1793
+ }
1794
+ export async function isTakendownDid(did) {
1795
+ const rows = await all(`SELECT 1 FROM _repos WHERE did = $1 AND status = 'takendown' LIMIT 1`, [did]);
1796
+ return rows.length > 0;
1797
+ }
1798
+ export async function getPreferences(did) {
1799
+ const rows = await all(`SELECT key, value FROM _preferences WHERE did = $1`, [did]);
1800
+ const prefs = {};
1801
+ for (const row of rows) {
1802
+ try {
1803
+ prefs[row.key] = typeof row.value === 'string' ? JSON.parse(row.value) : row.value;
1804
+ }
1805
+ catch {
1806
+ prefs[row.key] = row.value;
1807
+ }
1808
+ }
1809
+ return prefs;
1810
+ }
1811
+ export async function putPreference(did, key, value) {
1812
+ await run(`INSERT OR REPLACE INTO _preferences (did, key, value, updated_at) VALUES ($1, $2, $3, $4)`, [
1813
+ did,
1814
+ key,
1815
+ JSON.stringify(value),
1816
+ new Date().toISOString(),
1817
+ ]);
1818
+ }
1819
+ export async function resolveHandleToDid(handle) {
1820
+ if (handle.startsWith('did:'))
1821
+ return handle;
1822
+ const rows = await all(`SELECT did FROM _repos WHERE handle = $1 LIMIT 1`, [handle]);
1823
+ return rows[0]?.did ?? null;
1824
+ }
1825
+ export async function filterTakendownDids(dids) {
1826
+ if (dids.length === 0)
1827
+ return new Set();
1828
+ const placeholders = dids.map((_, i) => `$${i + 1}`).join(',');
1829
+ const rows = await all(`SELECT did FROM _repos WHERE did IN (${placeholders}) AND status = 'takendown'`, dids);
1830
+ return new Set(rows.map((r) => r.did));
1831
+ }
1832
+ export async function insertReport(report) {
1833
+ const createdAt = new Date().toISOString();
1834
+ const rows = await all(`INSERT INTO _reports (subject_uri, subject_did, label, reason, reported_by, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, [report.subjectUri, report.subjectDid, report.label, report.reason || null, report.reportedBy, createdAt]);
1835
+ return { id: rows[0].id };
1836
+ }
1837
+ export async function queryReports(opts) {
1838
+ const conditions = [];
1839
+ const params = [];
1840
+ let idx = 1;
1841
+ if (opts.status) {
1842
+ conditions.push(`r.status = $${idx++}`);
1843
+ params.push(opts.status);
1844
+ }
1845
+ if (opts.label) {
1846
+ conditions.push(`r.label = $${idx++}`);
1847
+ params.push(opts.label);
1848
+ }
1849
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
1850
+ const limit = opts.limit || 50;
1851
+ const offset = opts.offset || 0;
1852
+ const countRows = await all(`SELECT ${dialect.countAsInteger} as count FROM _reports r ${where}`, params);
1853
+ const total = Number(countRows[0]?.count || 0);
1854
+ const rows = await all(`SELECT r.*, rp.handle as reported_by_handle FROM _reports r LEFT JOIN _repos rp ON r.reported_by = rp.did ${where} ORDER BY r.created_at DESC LIMIT $${idx++} OFFSET $${idx++}`, [...params, limit, offset]);
1855
+ return { reports: rows, total };
1856
+ }
1857
+ export async function resolveReport(id, action, resolvedBy) {
1858
+ const rows = await all(`SELECT subject_uri, label, status FROM _reports WHERE id = $1`, [id]);
1859
+ if (!rows.length)
1860
+ return null;
1861
+ if (rows[0].status !== 'open')
1862
+ return null;
1863
+ await run(`UPDATE _reports SET status = $1, resolved_by = $2, resolved_at = $3 WHERE id = $4`, [
1864
+ action,
1865
+ resolvedBy,
1866
+ new Date().toISOString(),
1867
+ id,
1868
+ ]);
1869
+ return { subjectUri: rows[0].subject_uri, label: rows[0].label };
1870
+ }
1871
+ export async function getOpenReportCount() {
1872
+ const rows = await all(`SELECT ${dialect.countAsInteger} as count FROM _reports WHERE status = 'open'`);
1873
+ return Number(rows[0]?.count || 0);
1874
+ }
1875
+ /** Replace this subject's score for this classifier. */
1876
+ export async function upsertClassification(row) {
1877
+ await run(`DELETE FROM _classifications WHERE subject_uri = $1 AND classifier = $2`, [row.subjectUri, row.classifier]);
1878
+ await run(`INSERT INTO _classifications
1879
+ (subject_uri, subject_did, classifier, signals, state, top_signal, top_score, fingerprint, model, scanned_at)
1880
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [
1881
+ row.subjectUri,
1882
+ row.subjectDid,
1883
+ row.classifier,
1884
+ JSON.stringify(row.signals),
1885
+ JSON.stringify(row.state),
1886
+ row.topSignal,
1887
+ row.topScore,
1888
+ row.fingerprint,
1889
+ row.model,
1890
+ new Date().toISOString(),
1891
+ ]);
1892
+ }
1893
+ /**
1894
+ * Fingerprints of everything already scored by this classifier, so a rescan can
1895
+ * skip subjects whose state has not changed since.
1896
+ */
1897
+ export async function getClassificationFingerprints(classifier) {
1898
+ const rows = await all(`SELECT subject_uri, fingerprint FROM _classifications WHERE classifier = $1`, [classifier]);
1899
+ return new Map(rows.map((r) => [r.subject_uri, r.fingerprint]));
1900
+ }
1901
+ /** The stored fingerprint for one subject under one classifier, if it was scored. */
1902
+ export async function getClassificationFingerprint(classifier, subjectUri) {
1903
+ const rows = await all(`SELECT fingerprint FROM _classifications WHERE classifier = $1 AND subject_uri = $2`, [classifier, subjectUri]);
1904
+ return rows[0]?.fingerprint;
1905
+ }
1906
+ /** An open report already filed against this subject with this label, if any. */
1907
+ export async function findOpenReport(subjectUri, label) {
1908
+ const rows = await all(`SELECT id FROM _reports WHERE subject_uri = $1 AND label = $2 AND status = 'open' LIMIT 1`, [subjectUri, label]);
1909
+ return rows.length ? rows[0].id : null;
1910
+ }
1911
+ /**
1912
+ * The review queue: one entry per subject, not per classifier.
1913
+ *
1914
+ * A subject scored by three classifiers has three rows in `_classifications`,
1915
+ * and returning them separately puts the same account in the queue three times.
1916
+ * The signals are merged instead, each tagged with the classifier it came from,
1917
+ * so one card carries everything known about the account.
1918
+ */
1919
+ export async function queryClassifications(opts) {
1920
+ const conditions = [];
1921
+ const params = [];
1922
+ let idx = 1;
1923
+ if (opts.classifier) {
1924
+ conditions.push(`c.classifier = $${idx++}`);
1925
+ params.push(opts.classifier);
1926
+ }
1927
+ if (opts.signal) {
1928
+ conditions.push(`c.top_signal = $${idx++}`);
1929
+ params.push(opts.signal);
1930
+ }
1931
+ if (opts.minScore != null) {
1932
+ conditions.push(`c.top_score >= $${idx++}`);
1933
+ params.push(opts.minScore);
1934
+ }
1935
+ // Pending means a report is open against the subject: something crossed a
1936
+ // threshold and nobody has decided it. A high score on a question carrying no
1937
+ // threshold is context rather than queue — browsable under `all`.
1938
+ if (opts.state === 'pending') {
1939
+ conditions.push(`(rp.status IS NULL OR rp.status = 'active')`);
1940
+ conditions.push(`EXISTS (SELECT 1 FROM _reports x WHERE x.subject_uri = c.subject_uri AND x.status = 'open')`);
1941
+ }
1942
+ else if (opts.state === 'actioned') {
1943
+ conditions.push(`(rp.status = 'takendown'
1944
+ OR (EXISTS (SELECT 1 FROM _reports x WHERE x.subject_uri = c.subject_uri AND x.status <> 'open')
1945
+ AND NOT EXISTS (SELECT 1 FROM _reports x WHERE x.subject_uri = c.subject_uri AND x.status = 'open')))`);
1946
+ }
1947
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
1948
+ const from = `FROM _classifications c LEFT JOIN _repos rp ON c.subject_did = rp.did`;
1949
+ const limit = opts.limit ?? 50;
1950
+ const offset = opts.offset ?? 0;
1951
+ const countRows = await all(`SELECT ${dialect.countAsInteger} as count FROM (SELECT c.subject_uri ${from} ${where} GROUP BY c.subject_uri) g`, params);
1952
+ const page = await all(`SELECT c.subject_uri, c.subject_did, MAX(c.top_score) AS score
1953
+ ${from} ${where}
1954
+ GROUP BY c.subject_uri, c.subject_did
1955
+ ORDER BY score DESC LIMIT $${idx++} OFFSET $${idx++}`, [...params, limit, offset]);
1956
+ if (!page.length)
1957
+ return { rows: [], total: Number(countRows[0]?.count || 0) };
1958
+ const uris = page.map((p) => p.subject_uri);
1959
+ const placeholders = uris.map((_, i) => `$${i + 1}`).join(',');
1960
+ const detail = await all(`SELECT c.*, rp.handle, rp.status AS repo_status,
1961
+ (SELECT x.id FROM _reports x WHERE x.subject_uri = c.subject_uri AND x.status = 'open' LIMIT 1) AS report_id,
1962
+ (SELECT x.status FROM _reports x WHERE x.subject_uri = c.subject_uri ORDER BY x.created_at DESC LIMIT 1) AS report_status
1963
+ FROM _classifications c LEFT JOIN _repos rp ON c.subject_did = rp.did
1964
+ WHERE c.subject_uri IN (${placeholders})`, uris);
1965
+ const byUri = new Map();
1966
+ for (const row of detail) {
1967
+ for (const col of ['signals', 'state']) {
1968
+ if (typeof row[col] === 'string') {
1969
+ try {
1970
+ row[col] = JSON.parse(row[col]);
1971
+ }
1972
+ catch { }
1973
+ }
1974
+ }
1975
+ let entry = byUri.get(row.subject_uri);
1976
+ if (!entry) {
1977
+ entry = {
1978
+ subject_uri: row.subject_uri,
1979
+ subject_did: row.subject_did,
1980
+ handle: row.handle,
1981
+ repo_status: row.repo_status,
1982
+ report_id: row.report_id,
1983
+ report_status: row.report_status,
1984
+ classifiers: [],
1985
+ signals: {},
1986
+ state: {},
1987
+ top_signal: null,
1988
+ top_score: 0,
1989
+ scanned_at: row.scanned_at,
1990
+ };
1991
+ byUri.set(row.subject_uri, entry);
1992
+ }
1993
+ entry.classifiers.push(row.classifier);
1994
+ // Every classifier builds its own view of the subject; show the richest.
1995
+ if (Object.keys(row.state || {}).length > Object.keys(entry.state).length)
1996
+ entry.state = row.state;
1997
+ for (const [name, sig] of Object.entries(row.signals || {})) {
1998
+ entry.signals[name] = { ...sig, classifier: row.classifier };
1999
+ if (sig.score > entry.top_score) {
2000
+ entry.top_score = sig.score;
2001
+ entry.top_signal = name;
2002
+ }
2003
+ }
2004
+ }
2005
+ const rows = page.map((p) => byUri.get(p.subject_uri)).filter(Boolean);
2006
+ return { rows, total: Number(countRows[0]?.count || 0) };
2007
+ }
2008
+ /** Rollups for the review tab header. */
2009
+ export async function getClassificationStats() {
2010
+ const [totalRows, pendingRows, byRows, lastRows] = await Promise.all([
2011
+ all(`SELECT ${dialect.countAsInteger} as count FROM (SELECT subject_uri FROM _classifications GROUP BY subject_uri) g`),
2012
+ // Distinct subjects, not rows: the queue shows one card per account, so a
2013
+ // row count would overstate it by the number of classifiers.
2014
+ all(`SELECT ${dialect.countAsInteger} as count FROM (
2015
+ SELECT c.subject_uri FROM _classifications c
2016
+ WHERE EXISTS (SELECT 1 FROM _reports x WHERE x.subject_uri = c.subject_uri AND x.status = 'open')
2017
+ GROUP BY c.subject_uri
2018
+ ) g`),
2019
+ all(`SELECT classifier, ${dialect.countAsInteger} as count FROM _classifications GROUP BY classifier`),
2020
+ all(`SELECT scanned_at FROM _classifications ORDER BY scanned_at DESC LIMIT 1`),
2021
+ ]);
2022
+ const byClassifier = {};
2023
+ for (const r of byRows)
2024
+ byClassifier[r.classifier] = Number(r.count);
2025
+ return {
2026
+ total: Number(totalRows[0]?.count || 0),
2027
+ pending: Number(pendingRows[0]?.count || 0),
2028
+ byClassifier,
2029
+ lastScan: lastRows[0]?.scanned_at ?? null,
2030
+ };
2031
+ }
2032
+ /**
2033
+ * A sample of one repo's records across the given collections, newest first.
2034
+ *
2035
+ * Feeds the review queue's inline context: judging an account means seeing what
2036
+ * it actually posted, not just its DID.
2037
+ */
2038
+ export async function getRecordsByDid(did, collections, perCollection = 6) {
2039
+ const out = {};
2040
+ for (const collection of collections) {
2041
+ const schema = schemas.get(collection);
2042
+ if (!schema)
2043
+ continue;
2044
+ const rows = await all(`SELECT * FROM ${schema.tableName} WHERE did = $1 ORDER BY indexed_at DESC LIMIT $2`, [
2045
+ did,
2046
+ perCollection,
2047
+ ]);
2048
+ if (rows.length)
2049
+ out[collection] = rows.map((r) => reshapeRow(r)).filter(Boolean);
2050
+ }
2051
+ return out;
2052
+ }
2053
+ /** Row counts per collection for one repo. */
2054
+ export async function getRecordCountsByDid(did, collections) {
2055
+ const out = {};
2056
+ for (const collection of collections) {
2057
+ const schema = schemas.get(collection);
2058
+ if (!schema)
2059
+ continue;
2060
+ const rows = await all(`SELECT ${dialect.countAsInteger} as count FROM ${schema.tableName} WHERE did = $1`, [did]);
2061
+ const n = Number(rows[0]?.count || 0);
2062
+ if (n)
2063
+ out[collection] = n;
2064
+ }
2065
+ return out;
2066
+ }
2067
+ /** Open report ids filed against a subject, whatever the label. */
2068
+ export async function findOpenReportsForSubject(subjectUri) {
2069
+ const rows = await all(`SELECT id FROM _reports WHERE subject_uri = $1 AND status = 'open'`, [
2070
+ subjectUri,
2071
+ ]);
2072
+ return rows.map((r) => r.id);
2073
+ }