@hatk/hatk 0.0.1-alpha.1 → 0.0.1-alpha.100

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