@hatk/hatk 0.0.1-alpha.9 → 0.0.1-alpha.92

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