@hatk/hatk 0.0.1-alpha.4 → 0.0.1-alpha.40

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 (150) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +107 -0
  4. package/dist/backfill.d.ts +60 -1
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +167 -33
  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 +417 -133
  17. package/dist/cloudflare/container.d.ts +73 -0
  18. package/dist/cloudflare/container.d.ts.map +1 -0
  19. package/dist/cloudflare/container.js +232 -0
  20. package/dist/cloudflare/hooks.d.ts +33 -0
  21. package/dist/cloudflare/hooks.d.ts.map +1 -0
  22. package/dist/cloudflare/hooks.js +40 -0
  23. package/dist/cloudflare/init.d.ts +27 -0
  24. package/dist/cloudflare/init.d.ts.map +1 -0
  25. package/dist/cloudflare/init.js +103 -0
  26. package/dist/cloudflare/worker.d.ts +27 -0
  27. package/dist/cloudflare/worker.d.ts.map +1 -0
  28. package/dist/cloudflare/worker.js +54 -0
  29. package/dist/config.d.ts +12 -1
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +36 -9
  32. package/dist/database/adapter-factory.d.ts +6 -0
  33. package/dist/database/adapter-factory.d.ts.map +1 -0
  34. package/dist/database/adapter-factory.js +20 -0
  35. package/dist/database/adapters/d1.d.ts +56 -0
  36. package/dist/database/adapters/d1.d.ts.map +1 -0
  37. package/dist/database/adapters/d1.js +108 -0
  38. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  39. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  40. package/dist/database/adapters/duckdb-search.js +27 -0
  41. package/dist/database/adapters/duckdb.d.ts +25 -0
  42. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  43. package/dist/database/adapters/duckdb.js +161 -0
  44. package/dist/database/adapters/sqlite-search.d.ts +23 -0
  45. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  46. package/dist/database/adapters/sqlite-search.js +74 -0
  47. package/dist/database/adapters/sqlite.d.ts +18 -0
  48. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  49. package/dist/database/adapters/sqlite.js +87 -0
  50. package/dist/database/db.d.ts +159 -0
  51. package/dist/database/db.d.ts.map +1 -0
  52. package/dist/database/db.js +1445 -0
  53. package/dist/database/dialect.d.ts +45 -0
  54. package/dist/database/dialect.d.ts.map +1 -0
  55. package/dist/database/dialect.js +72 -0
  56. package/dist/database/fts.d.ts +27 -0
  57. package/dist/database/fts.d.ts.map +1 -0
  58. package/dist/database/fts.js +846 -0
  59. package/dist/database/index.d.ts +7 -0
  60. package/dist/database/index.d.ts.map +1 -0
  61. package/dist/database/index.js +6 -0
  62. package/dist/database/ports.d.ts +50 -0
  63. package/dist/database/ports.d.ts.map +1 -0
  64. package/dist/database/ports.js +1 -0
  65. package/dist/database/schema.d.ts +61 -0
  66. package/dist/database/schema.d.ts.map +1 -0
  67. package/dist/database/schema.js +394 -0
  68. package/dist/db.d.ts +1 -1
  69. package/dist/db.d.ts.map +1 -1
  70. package/dist/db.js +4 -38
  71. package/dist/dev-entry.d.ts +8 -0
  72. package/dist/dev-entry.d.ts.map +1 -0
  73. package/dist/dev-entry.js +110 -0
  74. package/dist/feeds.d.ts +12 -8
  75. package/dist/feeds.d.ts.map +1 -1
  76. package/dist/feeds.js +45 -6
  77. package/dist/fts.d.ts.map +1 -1
  78. package/dist/fts.js +5 -0
  79. package/dist/hooks.d.ts +22 -0
  80. package/dist/hooks.d.ts.map +1 -0
  81. package/dist/hooks.js +75 -0
  82. package/dist/hydrate.d.ts +6 -5
  83. package/dist/hydrate.d.ts.map +1 -1
  84. package/dist/hydrate.js +4 -16
  85. package/dist/indexer.d.ts +20 -0
  86. package/dist/indexer.d.ts.map +1 -1
  87. package/dist/indexer.js +53 -7
  88. package/dist/labels.d.ts +34 -0
  89. package/dist/labels.d.ts.map +1 -1
  90. package/dist/labels.js +66 -6
  91. package/dist/logger.d.ts +29 -0
  92. package/dist/logger.d.ts.map +1 -1
  93. package/dist/logger.js +29 -0
  94. package/dist/main.js +134 -67
  95. package/dist/mst.d.ts +18 -1
  96. package/dist/mst.d.ts.map +1 -1
  97. package/dist/mst.js +19 -8
  98. package/dist/oauth/db.d.ts.map +1 -1
  99. package/dist/oauth/db.js +43 -17
  100. package/dist/oauth/server.d.ts +2 -0
  101. package/dist/oauth/server.d.ts.map +1 -1
  102. package/dist/oauth/server.js +102 -7
  103. package/dist/oauth/session.d.ts +11 -0
  104. package/dist/oauth/session.d.ts.map +1 -0
  105. package/dist/oauth/session.js +65 -0
  106. package/dist/opengraph.d.ts +10 -0
  107. package/dist/opengraph.d.ts.map +1 -1
  108. package/dist/opengraph.js +73 -39
  109. package/dist/pds-proxy.d.ts +42 -0
  110. package/dist/pds-proxy.d.ts.map +1 -0
  111. package/dist/pds-proxy.js +189 -0
  112. package/dist/renderer.d.ts +27 -0
  113. package/dist/renderer.d.ts.map +1 -0
  114. package/dist/renderer.js +46 -0
  115. package/dist/resolve-hatk.d.ts +6 -0
  116. package/dist/resolve-hatk.d.ts.map +1 -0
  117. package/dist/resolve-hatk.js +20 -0
  118. package/dist/response.d.ts +16 -0
  119. package/dist/response.d.ts.map +1 -0
  120. package/dist/response.js +69 -0
  121. package/dist/scanner.d.ts +21 -0
  122. package/dist/scanner.d.ts.map +1 -0
  123. package/dist/scanner.js +88 -0
  124. package/dist/schema.d.ts +8 -0
  125. package/dist/schema.d.ts.map +1 -1
  126. package/dist/schema.js +29 -0
  127. package/dist/seed.d.ts +19 -0
  128. package/dist/seed.d.ts.map +1 -1
  129. package/dist/seed.js +43 -4
  130. package/dist/server-init.d.ts +8 -0
  131. package/dist/server-init.d.ts.map +1 -0
  132. package/dist/server-init.js +61 -0
  133. package/dist/server.d.ts +26 -3
  134. package/dist/server.d.ts.map +1 -1
  135. package/dist/server.js +528 -635
  136. package/dist/setup.d.ts +28 -1
  137. package/dist/setup.d.ts.map +1 -1
  138. package/dist/setup.js +50 -3
  139. package/dist/test.d.ts +1 -1
  140. package/dist/test.d.ts.map +1 -1
  141. package/dist/test.js +38 -32
  142. package/dist/views.js +1 -1
  143. package/dist/vite-plugin.d.ts +1 -1
  144. package/dist/vite-plugin.d.ts.map +1 -1
  145. package/dist/vite-plugin.js +254 -66
  146. package/dist/xrpc.d.ts +46 -10
  147. package/dist/xrpc.d.ts.map +1 -1
  148. package/dist/xrpc.js +128 -39
  149. package/package.json +13 -6
  150. package/public/admin.html +0 -54
@@ -0,0 +1,1445 @@
1
+ import { toSnakeCase } 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
+ let port;
7
+ let dialect;
8
+ const schemas = new Map();
9
+ export function getDatabasePort() {
10
+ return port;
11
+ }
12
+ export function getSqlDialect() {
13
+ return dialect;
14
+ }
15
+ export function closeDatabase() {
16
+ port?.close();
17
+ }
18
+ async function run(sql, params = []) {
19
+ return port.execute(sql, params);
20
+ }
21
+ export async function runBatch(operations) {
22
+ await port.beginTransaction();
23
+ try {
24
+ for (const op of operations) {
25
+ try {
26
+ await port.execute(op.sql, op.params);
27
+ }
28
+ catch {
29
+ // Skip bad records, continue with rest of batch
30
+ }
31
+ }
32
+ await port.commit();
33
+ }
34
+ catch {
35
+ await port.rollback();
36
+ }
37
+ }
38
+ async function all(sql, params = []) {
39
+ return port.query(sql, params);
40
+ }
41
+ export async function initDatabase(adapter, dbPath, tableSchemas, ddlStatements) {
42
+ port = adapter;
43
+ dialect = getDialect(adapter.dialect);
44
+ await port.open(dbPath);
45
+ for (const schema of tableSchemas) {
46
+ schemas.set(schema.collection, schema);
47
+ }
48
+ for (const ddl of ddlStatements) {
49
+ await port.executeMultiple(ddl);
50
+ }
51
+ // Internal tables for backfill state
52
+ await run(`CREATE TABLE IF NOT EXISTS _repos (
53
+ did TEXT PRIMARY KEY,
54
+ status TEXT NOT NULL DEFAULT 'pending',
55
+ handle TEXT,
56
+ backfilled_at ${dialect.timestampType},
57
+ rev TEXT,
58
+ retry_count INTEGER NOT NULL DEFAULT 0,
59
+ retry_after INTEGER NOT NULL DEFAULT 0
60
+ )`);
61
+ // Migration: add handle column to existing _repos tables
62
+ try {
63
+ await run(`ALTER TABLE _repos ADD COLUMN handle TEXT`);
64
+ }
65
+ catch { }
66
+ // Re-queue repos with missing handles so backfill populates them
67
+ await run(`UPDATE _repos SET status = 'pending' WHERE handle IS NULL`);
68
+ await run(`CREATE TABLE IF NOT EXISTS _cursor (
69
+ key TEXT PRIMARY KEY,
70
+ value TEXT NOT NULL
71
+ )`);
72
+ // Labels table (atproto-compatible)
73
+ if (dialect.supportsSequences) {
74
+ await run(`CREATE SEQUENCE IF NOT EXISTS _labels_seq START 1`);
75
+ await run(`CREATE TABLE IF NOT EXISTS _labels (
76
+ id INTEGER PRIMARY KEY DEFAULT nextval('_labels_seq'),
77
+ src TEXT NOT NULL,
78
+ uri TEXT NOT NULL,
79
+ val TEXT NOT NULL,
80
+ neg ${dialect.typeMap.boolean} DEFAULT FALSE,
81
+ cts ${dialect.timestampType} NOT NULL,
82
+ exp ${dialect.timestampType}
83
+ )`);
84
+ }
85
+ else {
86
+ await run(`CREATE TABLE IF NOT EXISTS _labels (
87
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
88
+ src TEXT NOT NULL,
89
+ uri TEXT NOT NULL,
90
+ val TEXT NOT NULL,
91
+ neg INTEGER DEFAULT 0,
92
+ cts TEXT NOT NULL,
93
+ exp TEXT
94
+ )`);
95
+ }
96
+ await run(`CREATE INDEX IF NOT EXISTS idx_labels_uri ON _labels(uri)`);
97
+ await run(`CREATE INDEX IF NOT EXISTS idx_labels_src ON _labels(src)`);
98
+ // Preferences table (generic key-value per user)
99
+ await run(`CREATE TABLE IF NOT EXISTS _preferences (
100
+ did TEXT NOT NULL,
101
+ key TEXT NOT NULL,
102
+ value ${dialect.jsonType} NOT NULL,
103
+ updated_at ${dialect.timestampType} DEFAULT ${dialect.currentTimestamp},
104
+ PRIMARY KEY (did, key)
105
+ )`);
106
+ // OAuth tables
107
+ await port.executeMultiple(OAUTH_DDL);
108
+ }
109
+ /** Normalize SQL type names to handle dialect differences (e.g. VARCHAR → TEXT) */
110
+ function normalizeType(type) {
111
+ const upper = type.toUpperCase();
112
+ if (upper === 'VARCHAR' || upper === 'CHARACTER VARYING')
113
+ return 'TEXT';
114
+ if (upper === 'TIMESTAMP WITH TIME ZONE')
115
+ return 'TIMESTAMPTZ';
116
+ if (upper === 'BOOLEAN' || upper === 'BOOL')
117
+ return 'BOOLEAN';
118
+ if (upper === 'INT' || upper === 'INT4' || upper === 'INT8' || upper === 'BIGINT' || upper === 'SMALLINT')
119
+ return 'INTEGER';
120
+ return upper;
121
+ }
122
+ async function getExistingColumns(tableName) {
123
+ if (!/^[a-zA-Z0-9._]+$/.test(tableName)) {
124
+ throw new Error(`Invalid table name for introspection: ${tableName}`);
125
+ }
126
+ const cols = new Map();
127
+ try {
128
+ const query = dialect.introspectColumnsQuery(tableName);
129
+ const rows = await all(query);
130
+ for (const row of rows) {
131
+ // SQLite PRAGMA returns { name, type }, DuckDB returns { column_name, data_type }
132
+ const name = (row.column_name || row.name);
133
+ const type = normalizeType((row.data_type || row.type || 'TEXT'));
134
+ cols.set(name, type);
135
+ }
136
+ }
137
+ catch {
138
+ // Table doesn't exist yet
139
+ }
140
+ return cols;
141
+ }
142
+ function diffColumns(tableName, existingCols, expectedCols, changes) {
143
+ for (const [colName, colType] of expectedCols) {
144
+ if (!existingCols.has(colName)) {
145
+ changes.push({ table: tableName, action: 'add', column: colName, type: colType });
146
+ }
147
+ }
148
+ for (const [colName] of existingCols) {
149
+ if (!expectedCols.has(colName)) {
150
+ changes.push({ table: tableName, action: 'drop', column: colName });
151
+ }
152
+ }
153
+ for (const [colName, colType] of expectedCols) {
154
+ const existingType = existingCols.get(colName);
155
+ if (existingType && normalizeType(existingType) !== normalizeType(colType)) {
156
+ changes.push({ table: tableName, action: 'retype', column: colName, type: colType });
157
+ }
158
+ }
159
+ }
160
+ /** Build expected columns map for a child/union table */
161
+ function buildChildExpectedCols(columns) {
162
+ const expected = new Map();
163
+ expected.set('parent_uri', 'TEXT');
164
+ expected.set('parent_did', 'TEXT');
165
+ for (const col of columns) {
166
+ expected.set(col.name, normalizeType(col.sqlType));
167
+ }
168
+ return expected;
169
+ }
170
+ export async function migrateSchema(tableSchemas) {
171
+ const changes = [];
172
+ for (const schema of tableSchemas) {
173
+ if (schema.columns.length === 0)
174
+ continue; // generic JSON storage, skip
175
+ const tableName = schema.collection;
176
+ const existingCols = await getExistingColumns(tableName);
177
+ if (existingCols.size === 0)
178
+ continue; // table just created, nothing to migrate
179
+ // Expected columns: base columns (uri, cid, did, indexed_at) + schema columns
180
+ const expectedCols = new Map();
181
+ expectedCols.set('uri', 'TEXT');
182
+ expectedCols.set('cid', 'TEXT');
183
+ expectedCols.set('did', 'TEXT');
184
+ expectedCols.set('indexed_at', normalizeType(dialect.timestampType));
185
+ for (const col of schema.columns) {
186
+ expectedCols.set(col.name, normalizeType(col.sqlType));
187
+ }
188
+ diffColumns(tableName, existingCols, expectedCols, changes);
189
+ // Diff child tables
190
+ for (const child of schema.children) {
191
+ const childTable = child.tableName.replace(/"/g, '');
192
+ const existingChildCols = await getExistingColumns(childTable);
193
+ if (existingChildCols.size === 0)
194
+ continue;
195
+ diffColumns(childTable, existingChildCols, buildChildExpectedCols(child.columns), changes);
196
+ }
197
+ // Diff union branch tables
198
+ for (const union of schema.unions) {
199
+ for (const branch of union.branches) {
200
+ const branchTable = branch.tableName.replace(/"/g, '');
201
+ const existingBranchCols = await getExistingColumns(branchTable);
202
+ if (existingBranchCols.size === 0)
203
+ continue;
204
+ diffColumns(branchTable, existingBranchCols, buildChildExpectedCols(branch.columns), changes);
205
+ }
206
+ }
207
+ }
208
+ // Detect and drop orphaned child/union tables (query table list once)
209
+ let allTableNames = null;
210
+ try {
211
+ const rows = await all(dialect.listTablesQuery);
212
+ allTableNames = rows.map((r) => r.table_name);
213
+ }
214
+ catch { }
215
+ if (allTableNames) {
216
+ for (const schema of tableSchemas) {
217
+ if (schema.columns.length === 0)
218
+ continue;
219
+ const expectedTables = new Set();
220
+ for (const child of schema.children) {
221
+ expectedTables.add(child.tableName.replace(/"/g, ''));
222
+ }
223
+ for (const union of schema.unions) {
224
+ for (const branch of union.branches) {
225
+ expectedTables.add(branch.tableName.replace(/"/g, ''));
226
+ }
227
+ }
228
+ for (const name of allTableNames) {
229
+ if (name.startsWith(schema.collection + '__') && !expectedTables.has(name)) {
230
+ await run(`DROP TABLE IF EXISTS "${name}"`);
231
+ emit('migration', 'drop_table', { table: name });
232
+ }
233
+ }
234
+ }
235
+ }
236
+ if (changes.length > 0) {
237
+ await applyMigrationChanges(changes);
238
+ }
239
+ // Check for empty collection tables — these are newly added and need backfill
240
+ // Skip on fresh DB (no repos yet) since backfill runs naturally
241
+ const [hasRepos] = await all(`SELECT 1 FROM _repos LIMIT 1`);
242
+ if (hasRepos) {
243
+ for (const schema of tableSchemas) {
244
+ if (schema.columns.length === 0)
245
+ continue;
246
+ try {
247
+ const [row] = await all(`SELECT 1 FROM ${schema.tableName} LIMIT 1`);
248
+ if (!row) {
249
+ await run(`UPDATE _repos SET status = 'pending' WHERE status = 'active'`);
250
+ emit('migration', 'new_collection', { collection: schema.collection });
251
+ break; // only need to mark once
252
+ }
253
+ }
254
+ catch { }
255
+ }
256
+ }
257
+ return changes;
258
+ }
259
+ async function applyMigrationChanges(changes) {
260
+ for (const change of changes) {
261
+ const quotedTable = `"${change.table}"`;
262
+ const quotedColumn = `"${change.column}"`;
263
+ try {
264
+ switch (change.action) {
265
+ case 'add': {
266
+ await run(`ALTER TABLE ${quotedTable} ADD COLUMN ${quotedColumn} ${change.type}`);
267
+ emit('migration', 'add_column', { table: change.table, column: change.column, type: change.type });
268
+ const schema = schemas.get(change.table);
269
+ if (schema?.refColumns.includes(change.column)) {
270
+ const prefix = change.table.replace(/\./g, '_');
271
+ await run(`CREATE INDEX IF NOT EXISTS idx_${prefix}_${change.column} ON ${quotedTable}(${quotedColumn})`);
272
+ }
273
+ break;
274
+ }
275
+ case 'drop':
276
+ await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${quotedColumn}`);
277
+ emit('migration', 'drop_column', { table: change.table, column: change.column });
278
+ break;
279
+ case 'retype':
280
+ await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${quotedColumn}`);
281
+ await run(`ALTER TABLE ${quotedTable} ADD COLUMN ${quotedColumn} ${change.type}`);
282
+ emit('migration', 'retype_column', { table: change.table, column: change.column, type: change.type });
283
+ break;
284
+ }
285
+ }
286
+ catch (err) {
287
+ console.warn(`[migration] failed to ${change.action} column "${change.column}" on "${change.table}": ${err.message}`);
288
+ emit('migration', 'error', {
289
+ action: change.action,
290
+ table: change.table,
291
+ column: change.column,
292
+ error: err.message,
293
+ });
294
+ }
295
+ }
296
+ }
297
+ export async function getCursor(key) {
298
+ const rows = await all(`SELECT value FROM _cursor WHERE key = $1`, [key]);
299
+ return rows[0]?.value || null;
300
+ }
301
+ export async function setCursor(key, value) {
302
+ await run(`INSERT OR REPLACE INTO _cursor (key, value) VALUES ($1, $2)`, [key, value]);
303
+ }
304
+ export async function getRepoStatus(did) {
305
+ const rows = await all(`SELECT status FROM _repos WHERE did = $1`, [did]);
306
+ return rows[0]?.status || null;
307
+ }
308
+ export async function setRepoStatus(did, status, rev, opts) {
309
+ if (status === 'active') {
310
+ // Update existing row preserving handle if not provided
311
+ 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]);
312
+ // Insert if row didn't exist yet
313
+ 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]);
314
+ }
315
+ else if (status === 'failed' && opts) {
316
+ 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]);
317
+ // If row didn't exist yet, insert it
318
+ 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]);
319
+ }
320
+ else {
321
+ await run(`UPDATE _repos SET status = $1 WHERE did = $2`, [status, did]);
322
+ await run(`INSERT OR IGNORE INTO _repos (did, status) VALUES ($1, $2)`, [did, status]);
323
+ }
324
+ }
325
+ export async function getRepoRev(did) {
326
+ const rows = await all(`SELECT rev FROM _repos WHERE did = $1`, [did]);
327
+ return rows[0]?.rev ?? null;
328
+ }
329
+ export async function getRepoRetryInfo(did) {
330
+ const rows = await all(`SELECT retry_count, retry_after FROM _repos WHERE did = $1`, [did]);
331
+ if (rows.length === 0)
332
+ return null;
333
+ return { retryCount: Number(rows[0].retry_count), retryAfter: Number(rows[0].retry_after) };
334
+ }
335
+ export async function listRetryEligibleRepos(maxRetries) {
336
+ const now = Math.floor(Date.now() / 1000);
337
+ const rows = await all(`SELECT did FROM _repos WHERE status = 'failed' AND retry_after <= $1 AND retry_count < $2`, [
338
+ now,
339
+ maxRetries,
340
+ ]);
341
+ return rows.map((r) => r.did);
342
+ }
343
+ export async function listPendingRepos() {
344
+ const rows = await all(`SELECT did FROM _repos WHERE status = 'pending'`);
345
+ return rows.map((r) => r.did);
346
+ }
347
+ export async function listActiveRepoDids() {
348
+ const rows = await all(`SELECT did FROM _repos WHERE status = 'active'`);
349
+ return rows.map((r) => r.did);
350
+ }
351
+ export async function removeRepo(did) {
352
+ await run(`DELETE FROM _repos WHERE did = $1`, [did]);
353
+ }
354
+ export async function getRepoHandle(did) {
355
+ const rows = await all(`SELECT handle FROM _repos WHERE did = $1`, [did]);
356
+ return rows[0]?.handle ?? null;
357
+ }
358
+ export async function listAllRepoStatuses() {
359
+ return (await all(`SELECT did, status FROM _repos`));
360
+ }
361
+ export async function listReposPaginated(opts = {}) {
362
+ const { limit = 50, offset = 0, status, q } = opts;
363
+ const conditions = [];
364
+ const params = [];
365
+ let paramIdx = 1;
366
+ if (status) {
367
+ conditions.push(`status = $${paramIdx++}`);
368
+ params.push(status);
369
+ }
370
+ if (q) {
371
+ conditions.push(`(did ${dialect.ilike} $${paramIdx} OR handle ${dialect.ilike} $${paramIdx})`);
372
+ params.push(`%${q}%`);
373
+ paramIdx++;
374
+ }
375
+ const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
376
+ const countRows = await all(`SELECT ${dialect.countAsInteger} as total FROM _repos${where}`, params);
377
+ const total = Number(countRows[0]?.total || 0);
378
+ 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]);
379
+ return { repos: rows, total };
380
+ }
381
+ export async function getCollectionCounts() {
382
+ const counts = {};
383
+ for (const [collection, schema] of schemas) {
384
+ const rows = await all(`SELECT ${dialect.countAsInteger} as count FROM ${schema.tableName}`);
385
+ counts[collection] = Number(rows[0]?.count || 0);
386
+ }
387
+ return counts;
388
+ }
389
+ export async function getRepoStatusCounts() {
390
+ const rows = await all(`SELECT status, ${dialect.countAsInteger} as count FROM _repos GROUP BY status`);
391
+ const counts = {};
392
+ for (const row of rows)
393
+ counts[row.status] = Number(row.count);
394
+ return counts;
395
+ }
396
+ export async function getDatabaseSize() {
397
+ if (dialect.supportsSequences) {
398
+ // DuckDB: pragma_database_size returns pre-formatted strings
399
+ const rows = await all('SELECT database_size, memory_usage, memory_limit FROM pragma_database_size()');
400
+ return rows[0] ?? {};
401
+ }
402
+ // SQLite: compute from page_count * page_size
403
+ const pages = await all('SELECT page_count FROM pragma_page_count()');
404
+ const sizes = await all('SELECT page_size FROM pragma_page_size()');
405
+ const pageCount = Number(pages[0]?.page_count ?? 0);
406
+ const pageSize = Number(sizes[0]?.page_size ?? 0);
407
+ const bytes = pageCount * pageSize;
408
+ const mib = (bytes / 1024 / 1024).toFixed(1);
409
+ return { database_size: `${mib} MiB`, memory_usage: 'N/A', memory_limit: 'N/A' };
410
+ }
411
+ export async function getLabelCount(val) {
412
+ const rows = await all(`SELECT ${dialect.countAsInteger} as count FROM _labels WHERE val = $1`, [val]);
413
+ return Number(rows[0]?.count || 0);
414
+ }
415
+ export async function deleteLabels(val) {
416
+ const count = await getLabelCount(val);
417
+ await run(`DELETE FROM _labels WHERE val = $1`, [val]);
418
+ return count;
419
+ }
420
+ export async function getRecentRecords(collection, limit) {
421
+ const schema = schemas.get(collection);
422
+ if (!schema)
423
+ return [];
424
+ 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 ORDER BY t.indexed_at DESC LIMIT $1`, [limit]);
425
+ return rows;
426
+ }
427
+ export async function getSchemaDump() {
428
+ let rows;
429
+ if (dialect.supportsSequences) {
430
+ // DuckDB: use duckdb_tables() for full DDL
431
+ rows = await all(`SELECT sql FROM duckdb_tables() ORDER BY table_name`);
432
+ }
433
+ else {
434
+ // SQLite: use sqlite_master, skip FTS shadow/internal tables
435
+ 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`);
436
+ }
437
+ // Normalize indentation and formatting
438
+ return rows
439
+ .map((r) => {
440
+ let sql = r.sql.trim();
441
+ // Remove quotes around column names (SQLite adds them for some columns)
442
+ sql = sql.replace(/\n\s*"(\w+)"/g, '\n$1');
443
+ // Ensure closing paren is on its own line
444
+ sql = sql.replace(/([^(\s])\)$/, '$1\n)');
445
+ // Split into lines and re-indent consistently
446
+ const lines = sql.split('\n').map((l) => l.trim());
447
+ sql = lines
448
+ .map((line, i) => {
449
+ if (i === 0)
450
+ return line; // CREATE TABLE line
451
+ if (line.startsWith(')'))
452
+ return ')'; // closing paren at top level
453
+ return ' ' + line; // indent columns
454
+ })
455
+ .join('\n');
456
+ return sql + ';';
457
+ })
458
+ .join('\n\n');
459
+ }
460
+ export function buildInsertOp(collection, uri, cid, authorDid, record) {
461
+ const schema = schemas.get(collection);
462
+ if (!schema)
463
+ throw new Error(`Unknown collection: ${collection}`);
464
+ const colNames = ['uri', 'cid', 'did', 'indexed_at'];
465
+ const placeholders = ['$1', '$2', '$3', '$4'];
466
+ const values = [uri, cid, authorDid, new Date().toISOString()];
467
+ let paramIdx = 5;
468
+ for (const col of schema.columns) {
469
+ let rawValue = record[col.originalName];
470
+ // Handle strongRef expansion: subject_uri reads record.subject.uri, subject__cid reads record.subject.cid
471
+ if (rawValue && typeof rawValue === 'object' && col.name.endsWith('_uri') && col.isRef) {
472
+ rawValue = rawValue.uri;
473
+ }
474
+ else if (col.originalName.endsWith('__cid') && record[col.originalName.replace('__cid', '')]) {
475
+ rawValue = record[col.originalName.replace('__cid', '')].cid;
476
+ }
477
+ colNames.push(col.name);
478
+ placeholders.push(`$${paramIdx++}`);
479
+ if (rawValue === undefined || rawValue === null) {
480
+ values.push(null);
481
+ }
482
+ else if (col.isJson) {
483
+ values.push(JSON.stringify(rawValue));
484
+ }
485
+ else {
486
+ values.push(rawValue);
487
+ }
488
+ }
489
+ const sql = `INSERT OR REPLACE INTO ${schema.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`;
490
+ return { sql, params: values };
491
+ }
492
+ export async function insertRecord(collection, uri, cid, authorDid, record) {
493
+ const schema = schemas.get(collection);
494
+ if (!schema)
495
+ throw new Error(`Unknown collection: ${collection}`);
496
+ const { sql, params } = buildInsertOp(collection, uri, cid, authorDid, record);
497
+ await run(sql, params);
498
+ // Insert child table rows
499
+ for (const child of schema.children) {
500
+ const items = record[child.fieldName];
501
+ if (!Array.isArray(items))
502
+ continue;
503
+ // Delete existing child rows (handles INSERT OR REPLACE on main table)
504
+ await run(`DELETE FROM ${child.tableName} WHERE parent_uri = $1`, [uri]);
505
+ for (const item of items) {
506
+ const colNames = ['parent_uri', 'parent_did'];
507
+ const placeholders = ['$1', '$2'];
508
+ const values = [uri, authorDid];
509
+ let idx = 3;
510
+ for (const col of child.columns) {
511
+ colNames.push(col.name);
512
+ placeholders.push(`$${idx++}`);
513
+ const raw = item[col.originalName];
514
+ if (raw === undefined || raw === null) {
515
+ values.push(null);
516
+ }
517
+ else if (col.isJson) {
518
+ values.push(JSON.stringify(raw));
519
+ }
520
+ else {
521
+ values.push(raw);
522
+ }
523
+ }
524
+ await run(`INSERT INTO ${child.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`, values);
525
+ }
526
+ }
527
+ // Insert union branch rows
528
+ for (const union of schema.unions) {
529
+ const unionValue = record[union.fieldName];
530
+ if (!unionValue || !unionValue.$type)
531
+ continue;
532
+ const branch = union.branches.find((b) => b.type === unionValue.$type);
533
+ if (!branch)
534
+ continue;
535
+ // Delete existing branch rows (handles INSERT OR REPLACE)
536
+ for (const b of union.branches) {
537
+ await run(`DELETE FROM ${b.tableName} WHERE parent_uri = $1`, [uri]);
538
+ }
539
+ if (branch.isArray && branch.arrayField) {
540
+ // Array branch (e.g., embed.images) — insert one row per array item
541
+ const items = unionValue[branch.arrayField];
542
+ if (!Array.isArray(items))
543
+ continue;
544
+ for (const item of items) {
545
+ const colNames = ['parent_uri', 'parent_did'];
546
+ const placeholders = ['$1', '$2'];
547
+ const values = [uri, authorDid];
548
+ let idx = 3;
549
+ for (const col of branch.columns) {
550
+ colNames.push(col.name);
551
+ placeholders.push(`$${idx++}`);
552
+ const raw = item[col.originalName];
553
+ if (raw === undefined || raw === null) {
554
+ values.push(null);
555
+ }
556
+ else if (col.isJson) {
557
+ values.push(JSON.stringify(raw));
558
+ }
559
+ else {
560
+ values.push(raw);
561
+ }
562
+ }
563
+ await run(`INSERT INTO ${branch.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`, values);
564
+ }
565
+ }
566
+ else {
567
+ // Single-value branch — extract data from wrapper or direct properties
568
+ const branchData = resolveBranchData(unionValue, branch);
569
+ const colNames = ['parent_uri', 'parent_did'];
570
+ const placeholders = ['$1', '$2'];
571
+ const values = [uri, authorDid];
572
+ let idx = 3;
573
+ for (const col of branch.columns) {
574
+ colNames.push(col.name);
575
+ placeholders.push(`$${idx++}`);
576
+ const raw = branchData[col.originalName];
577
+ if (raw === undefined || raw === null) {
578
+ values.push(null);
579
+ }
580
+ else if (col.isJson) {
581
+ values.push(JSON.stringify(raw));
582
+ }
583
+ else {
584
+ values.push(raw);
585
+ }
586
+ }
587
+ await run(`INSERT INTO ${branch.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`, values);
588
+ }
589
+ }
590
+ // Incrementally update FTS index for this record
591
+ await updateFtsRecord(collection, uri);
592
+ }
593
+ /** Extract branch data from a union value, handling wrapper properties */
594
+ function resolveBranchData(unionValue, branch) {
595
+ if (branch.wrapperField) {
596
+ const wrapper = unionValue[branch.wrapperField];
597
+ if (wrapper && typeof wrapper === 'object')
598
+ return wrapper;
599
+ }
600
+ return unionValue;
601
+ }
602
+ export async function deleteRecord(collection, uri) {
603
+ const schema = schemas.get(collection);
604
+ if (!schema)
605
+ return;
606
+ // Remove from FTS index before deleting the record data
607
+ await deleteFtsRecord(collection, uri);
608
+ for (const child of schema.children) {
609
+ await run(`DELETE FROM ${child.tableName} WHERE parent_uri = $1`, [uri]);
610
+ }
611
+ for (const union of schema.unions) {
612
+ for (const branch of union.branches) {
613
+ await run(`DELETE FROM ${branch.tableName} WHERE parent_uri = $1`, [uri]);
614
+ }
615
+ }
616
+ await run(`DELETE FROM ${schema.tableName} WHERE uri = $1`, [uri]);
617
+ }
618
+ export async function insertLabels(labels) {
619
+ if (labels.length === 0)
620
+ return;
621
+ for (const label of labels) {
622
+ // Skip if an active (non-negated, non-expired, not-superseded-by-negation) label already exists
623
+ 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]);
624
+ if (!label.neg && existing.length > 0)
625
+ continue;
626
+ await run(`INSERT INTO _labels (src, uri, val, neg, cts, exp) VALUES ($1, $2, $3, $4, $5, $6)`, [
627
+ label.src,
628
+ label.uri,
629
+ label.val,
630
+ label.neg || false,
631
+ label.cts || new Date().toISOString(),
632
+ label.exp || null,
633
+ ]);
634
+ }
635
+ }
636
+ export async function queryLabelsForUris(uris) {
637
+ if (uris.length === 0)
638
+ return new Map();
639
+ const placeholders = uris.map((_, i) => `$${i + 1}`).join(',');
640
+ 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)`, uris);
641
+ const result = new Map();
642
+ for (const row of rows) {
643
+ const key = row.uri;
644
+ if (!result.has(key))
645
+ result.set(key, []);
646
+ result.get(key).push({
647
+ src: row.src,
648
+ uri: row.uri,
649
+ val: row.val,
650
+ neg: row.neg,
651
+ cts: normalizeValue(row.cts),
652
+ exp: row.exp ? String(row.exp) : null,
653
+ });
654
+ }
655
+ return result;
656
+ }
657
+ export async function bulkInsertRecords(records) {
658
+ if (records.length === 0)
659
+ return 0;
660
+ // Group records by collection
661
+ const byCollection = new Map();
662
+ for (const rec of records) {
663
+ const list = byCollection.get(rec.collection) || [];
664
+ list.push(rec);
665
+ byCollection.set(rec.collection, list);
666
+ }
667
+ let inserted = 0;
668
+ for (const [collection, recs] of byCollection) {
669
+ const schema = schemas.get(collection);
670
+ if (!schema)
671
+ continue;
672
+ const stagingTable = `_staging_${collection.replace(/\./g, '_')}`;
673
+ const allCols = ['uri', 'cid', 'did', 'indexed_at', ...schema.columns.map((c) => c.name)];
674
+ const colDefs = [
675
+ 'uri TEXT',
676
+ 'cid TEXT',
677
+ 'did TEXT',
678
+ 'indexed_at TEXT',
679
+ ...schema.columns.map((c) => {
680
+ const t = c.sqlType;
681
+ // Use TEXT for timestamp columns in staging (will cast on merge)
682
+ return `${c.name} ${t === 'TIMESTAMP' || t === 'TIMESTAMPTZ' ? 'TEXT' : t}`;
683
+ }),
684
+ ];
685
+ await port.execute(`DROP TABLE IF EXISTS ${stagingTable}`, []);
686
+ await port.execute(`CREATE TABLE ${stagingTable} (${colDefs.join(', ')})`, []);
687
+ const inserter = await port.createBulkInserter(stagingTable, allCols);
688
+ const now = new Date().toISOString();
689
+ for (const rec of recs) {
690
+ try {
691
+ const values = [rec.uri, rec.cid, rec.did, now];
692
+ for (const col of schema.columns) {
693
+ values.push(resolveColumnValue(col, rec.record));
694
+ }
695
+ inserter.append(values);
696
+ inserted++;
697
+ }
698
+ catch {
699
+ // Skip bad records
700
+ }
701
+ }
702
+ await inserter.close();
703
+ // Merge into target, filtering rows that would violate NOT NULL
704
+ const selectCols = allCols.map((name) => {
705
+ const col = schema.columns.find((c) => c.name === name);
706
+ if (name === 'indexed_at' || (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ'))) {
707
+ return `${dialect.tryCastTimestamp(name)} AS ${name}`;
708
+ }
709
+ return name;
710
+ });
711
+ const notNullChecks = ['uri IS NOT NULL', 'did IS NOT NULL'];
712
+ for (const col of schema.columns) {
713
+ if (col.notNull) {
714
+ if (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ') {
715
+ notNullChecks.push(`${dialect.tryCastTimestamp(col.name)} IS NOT NULL`);
716
+ }
717
+ else {
718
+ notNullChecks.push(`${col.name} IS NOT NULL`);
719
+ }
720
+ }
721
+ }
722
+ const whereClause = notNullChecks.length ? ` WHERE ${notNullChecks.join(' AND ')}` : '';
723
+ await port.execute(`INSERT OR REPLACE INTO ${schema.tableName} (${allCols.join(', ')}) SELECT ${selectCols.join(', ')} FROM ${stagingTable}${whereClause}`, []);
724
+ await port.execute(`DROP TABLE ${stagingTable}`, []);
725
+ // Populate child tables
726
+ for (const child of schema.children) {
727
+ const childStagingTable = `_staging_${collection.replace(/\./g, '_')}__${child.fieldName}`;
728
+ const childColDefs = [
729
+ 'parent_uri TEXT',
730
+ 'parent_did TEXT',
731
+ ...child.columns.map((c) => {
732
+ const t = c.sqlType;
733
+ return `${c.name} ${t === 'TIMESTAMP' || t === 'TIMESTAMPTZ' ? 'TEXT' : t}`;
734
+ }),
735
+ ];
736
+ const childAllCols = ['parent_uri', 'parent_did', ...child.columns.map((c) => c.name)];
737
+ await port.execute(`DROP TABLE IF EXISTS ${childStagingTable}`, []);
738
+ await port.execute(`CREATE TABLE ${childStagingTable} (${childColDefs.join(', ')})`, []);
739
+ const childInserter = await port.createBulkInserter(childStagingTable, childAllCols);
740
+ for (const rec of recs) {
741
+ const items = rec.record[child.fieldName];
742
+ if (!Array.isArray(items))
743
+ continue;
744
+ for (const item of items) {
745
+ try {
746
+ const values = [rec.uri, rec.did];
747
+ for (const col of child.columns) {
748
+ values.push(resolveRawColumnValue(col, item));
749
+ }
750
+ childInserter.append(values);
751
+ }
752
+ catch {
753
+ // Skip bad items
754
+ }
755
+ }
756
+ }
757
+ await childInserter.close();
758
+ // Delete existing child rows for these URIs, then merge staging
759
+ const uriPlaceholders = recs.map((_, i) => `$${i + 1}`).join(',');
760
+ await port.execute(`DELETE FROM ${child.tableName} WHERE parent_uri IN (${uriPlaceholders})`, recs.map((r) => r.uri));
761
+ const childSelectCols = childAllCols.map((name) => {
762
+ const col = child.columns.find((c) => c.name === name);
763
+ if (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ')) {
764
+ return `${dialect.tryCastTimestamp(name)} AS ${name}`;
765
+ }
766
+ return name;
767
+ });
768
+ await port.execute(`INSERT INTO ${child.tableName} (${childAllCols.join(', ')}) SELECT ${childSelectCols.join(', ')} FROM ${childStagingTable} WHERE parent_uri IS NOT NULL`, []);
769
+ await port.execute(`DROP TABLE ${childStagingTable}`, []);
770
+ }
771
+ // Populate union branch tables
772
+ for (const union of schema.unions) {
773
+ for (const branch of union.branches) {
774
+ const branchStagingTable = `_staging_${collection.replace(/\./g, '_')}__${toSnakeCase(union.fieldName)}_${branch.branchName}`;
775
+ const branchColDefs = [
776
+ 'parent_uri TEXT',
777
+ 'parent_did TEXT',
778
+ ...branch.columns.map((c) => {
779
+ const t = c.sqlType;
780
+ return `${c.name} ${t === 'TIMESTAMP' || t === 'TIMESTAMPTZ' ? 'TEXT' : t}`;
781
+ }),
782
+ ];
783
+ const branchAllCols = ['parent_uri', 'parent_did', ...branch.columns.map((c) => c.name)];
784
+ await port.execute(`DROP TABLE IF EXISTS ${branchStagingTable}`, []);
785
+ await port.execute(`CREATE TABLE ${branchStagingTable} (${branchColDefs.join(', ')})`, []);
786
+ const branchInserter = await port.createBulkInserter(branchStagingTable, branchAllCols);
787
+ for (const rec of recs) {
788
+ const unionValue = rec.record[union.fieldName];
789
+ if (!unionValue || typeof unionValue !== 'object')
790
+ continue;
791
+ if (unionValue.$type !== branch.type)
792
+ continue;
793
+ if (branch.isArray && branch.arrayField) {
794
+ const items = resolveBranchData(unionValue, branch)[branch.arrayField];
795
+ if (!Array.isArray(items))
796
+ continue;
797
+ for (const item of items) {
798
+ try {
799
+ const values = [rec.uri, rec.did];
800
+ for (const col of branch.columns) {
801
+ values.push(resolveRawColumnValue(col, item));
802
+ }
803
+ branchInserter.append(values);
804
+ }
805
+ catch {
806
+ // Skip bad items
807
+ }
808
+ }
809
+ }
810
+ else {
811
+ try {
812
+ const branchData = resolveBranchData(unionValue, branch);
813
+ const values = [rec.uri, rec.did];
814
+ for (const col of branch.columns) {
815
+ values.push(resolveRawColumnValue(col, branchData));
816
+ }
817
+ branchInserter.append(values);
818
+ }
819
+ catch {
820
+ // Skip bad records
821
+ }
822
+ }
823
+ }
824
+ await branchInserter.close();
825
+ // Delete existing branch rows for these URIs, then merge staging
826
+ const uriPlaceholders = recs.map((_, i) => `$${i + 1}`).join(',');
827
+ await port.execute(`DELETE FROM ${branch.tableName} WHERE parent_uri IN (${uriPlaceholders})`, recs.map((r) => r.uri));
828
+ const branchSelectCols = branchAllCols.map((name) => {
829
+ const col = branch.columns.find((c) => c.name === name);
830
+ if (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ')) {
831
+ return `${dialect.tryCastTimestamp(name)} AS ${name}`;
832
+ }
833
+ return name;
834
+ });
835
+ await port.execute(`INSERT INTO ${branch.tableName} (${branchAllCols.join(', ')}) SELECT ${branchSelectCols.join(', ')} FROM ${branchStagingTable} WHERE parent_uri IS NOT NULL`, []);
836
+ await port.execute(`DROP TABLE ${branchStagingTable}`, []);
837
+ }
838
+ }
839
+ }
840
+ return inserted;
841
+ }
842
+ /** Extract a column value from a record, handling strongRef expansion and type coercion for bulk insert */
843
+ function resolveColumnValue(col, record) {
844
+ let rawValue = record[col.originalName];
845
+ if (rawValue && typeof rawValue === 'object' && col.name.endsWith('_uri') && col.isRef) {
846
+ rawValue = rawValue.uri;
847
+ }
848
+ else if (col.originalName.endsWith('__cid') && record[col.originalName.replace('__cid', '')]) {
849
+ rawValue = record[col.originalName.replace('__cid', '')].cid;
850
+ }
851
+ return coerceValue(col.sqlType, rawValue);
852
+ }
853
+ /** Extract a raw column value from a data object and coerce for bulk insert */
854
+ function resolveRawColumnValue(col, data) {
855
+ return coerceValue(col.sqlType, data[col.originalName]);
856
+ }
857
+ /** Coerce a value to the appropriate type for insertion */
858
+ function coerceValue(sqlType, rawValue) {
859
+ if (rawValue === undefined || rawValue === null)
860
+ return null;
861
+ // Objects and arrays always need JSON stringification regardless of sqlType
862
+ // (on SQLite, JSON columns map to TEXT but still need stringification)
863
+ if (typeof rawValue === 'object' && !(rawValue instanceof Uint8Array)) {
864
+ return JSON.stringify(rawValue);
865
+ }
866
+ if (sqlType === 'JSON' || sqlType === 'TEXT') {
867
+ return String(rawValue);
868
+ }
869
+ if (sqlType === 'INTEGER' || sqlType === 'BIGINT') {
870
+ return typeof rawValue === 'number' ? rawValue : parseInt(rawValue);
871
+ }
872
+ if (sqlType === 'BOOLEAN')
873
+ return !!rawValue;
874
+ return String(rawValue);
875
+ }
876
+ export async function queryRecords(collection, opts = {}) {
877
+ const schema = schemas.get(collection);
878
+ if (!schema)
879
+ throw new Error(`Unknown collection: ${collection}`);
880
+ const { limit = 20, cursor, filters, sort = 'indexed_at', order = 'desc' } = opts;
881
+ // Validate sort field exists
882
+ const sortCol = sort === 'indexed_at' ? 'indexed_at' : schema.columns.find((c) => c.originalName === sort || c.name === sort);
883
+ const sortName = typeof sortCol === 'string' ? sortCol : sortCol?.name;
884
+ if (!sortName)
885
+ throw new Error(`Invalid sort field: ${sort}`);
886
+ const conditions = [];
887
+ const params = [];
888
+ let paramIdx = 1;
889
+ // Cursor pagination — compound keyset (sortCol, cid)
890
+ if (cursor) {
891
+ const parsed = unpackCursor(cursor);
892
+ if (parsed) {
893
+ const op = order === 'desc' ? '<' : '>';
894
+ const pSort1 = `$${paramIdx++}`;
895
+ const pSort2 = `$${paramIdx++}`;
896
+ const pCid = `$${paramIdx++}`;
897
+ conditions.push(`(t.${sortName} ${op} ${pSort1} OR (t.${sortName} = ${pSort2} AND t.cid ${op} ${pCid}))`);
898
+ params.push(parsed.primary, parsed.primary, parsed.cid);
899
+ }
900
+ }
901
+ // Field filters — validate each against schema
902
+ if (filters) {
903
+ const validColumns = new Set(schema.columns.map((c) => c.name));
904
+ validColumns.add('did');
905
+ for (const [key, value] of Object.entries(filters)) {
906
+ const colName = toSnakeCase(key);
907
+ if (!validColumns.has(colName))
908
+ continue; // silently skip invalid filters
909
+ conditions.push(`t.${colName} = $${paramIdx++}`);
910
+ params.push(value);
911
+ }
912
+ }
913
+ 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')`;
914
+ if (conditions.length)
915
+ sql += ' AND ' + conditions.join(' AND ');
916
+ sql += ` ORDER BY t.${sortName} ${order.toUpperCase()}, t.cid ${order.toUpperCase()} LIMIT $${paramIdx++}`;
917
+ params.push(limit + 1); // fetch one extra for cursor
918
+ const rows = await all(sql, params);
919
+ const hasMore = rows.length > limit;
920
+ if (hasMore)
921
+ rows.pop();
922
+ // Attach child data if this collection has decomposed arrays
923
+ if (schema.children.length > 0 && rows.length > 0) {
924
+ const uris = rows.map((r) => r.uri);
925
+ const childData = new Map();
926
+ for (const child of schema.children) {
927
+ const childRows = await getChildRows(child.tableName, uris);
928
+ childData.set(child.fieldName, childRows);
929
+ }
930
+ for (const row of rows) {
931
+ ;
932
+ row.__childData = childData;
933
+ }
934
+ }
935
+ // Attach union branch data
936
+ if (schema.unions.length > 0 && rows.length > 0) {
937
+ const uris = rows.map((r) => r.uri);
938
+ const unionData = new Map();
939
+ for (const union of schema.unions) {
940
+ const branchData = new Map();
941
+ for (const branch of union.branches) {
942
+ const branchRows = await getChildRows(branch.tableName, uris);
943
+ branchData.set(branch.branchName, branchRows);
944
+ }
945
+ unionData.set(union.fieldName, branchData);
946
+ }
947
+ for (const row of rows) {
948
+ ;
949
+ row.__unionData = unionData;
950
+ }
951
+ }
952
+ const lastRow = rows[rows.length - 1];
953
+ const nextCursor = hasMore && lastRow ? packCursor(lastRow[sortName], lastRow.cid) : undefined;
954
+ return { records: rows, cursor: nextCursor };
955
+ }
956
+ export async function getRecordByUri(uri) {
957
+ for (const [_collection, schema] of schemas) {
958
+ 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 (r.status IS NULL OR r.status != 'takendown')`, [uri]);
959
+ if (rows.length > 0) {
960
+ const row = rows[0];
961
+ if (schema.children.length > 0) {
962
+ const childData = new Map();
963
+ for (const child of schema.children) {
964
+ const childRows = await getChildRows(child.tableName, [uri]);
965
+ childData.set(child.fieldName, childRows);
966
+ }
967
+ ;
968
+ row.__childData = childData;
969
+ }
970
+ if (schema.unions.length > 0) {
971
+ const unionData = new Map();
972
+ for (const union of schema.unions) {
973
+ const branchData = new Map();
974
+ for (const branch of union.branches) {
975
+ const branchRows = await getChildRows(branch.tableName, [uri]);
976
+ branchData.set(branch.branchName, branchRows);
977
+ }
978
+ unionData.set(union.fieldName, branchData);
979
+ }
980
+ ;
981
+ row.__unionData = unionData;
982
+ }
983
+ return row;
984
+ }
985
+ }
986
+ return null;
987
+ }
988
+ export async function getRecordsByUris(collection, uris) {
989
+ if (uris.length === 0)
990
+ return [];
991
+ const schema = schemas.get(collection);
992
+ if (!schema)
993
+ return [];
994
+ const placeholders = uris.map((_, i) => `$${i + 1}`).join(',');
995
+ 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 (r.status IS NULL OR r.status != 'takendown')`, uris);
996
+ // Batch-fetch child rows for all URIs
997
+ const childData = new Map();
998
+ for (const child of schema.children) {
999
+ const childRows = await getChildRows(child.tableName, uris);
1000
+ childData.set(child.fieldName, childRows);
1001
+ }
1002
+ // Batch-fetch union branch rows for all URIs
1003
+ const unionData = new Map();
1004
+ for (const union of schema.unions) {
1005
+ const branchData = new Map();
1006
+ for (const branch of union.branches) {
1007
+ const branchRows = await getChildRows(branch.tableName, uris);
1008
+ branchData.set(branch.branchName, branchRows);
1009
+ }
1010
+ unionData.set(union.fieldName, branchData);
1011
+ }
1012
+ // Attach child data to rows for reshapeRow
1013
+ for (const row of rows) {
1014
+ ;
1015
+ row.__childData = childData;
1016
+ if (unionData.size > 0)
1017
+ row.__unionData = unionData;
1018
+ }
1019
+ // Preserve ordering
1020
+ const byUri = new Map(rows.map((r) => [r.uri, r]));
1021
+ return uris.map((u) => byUri.get(u)).filter(Boolean);
1022
+ }
1023
+ /** Fetch records by URIs and return as a shaped Map keyed by URI. */
1024
+ export async function getRecordsMap(collection, uris) {
1025
+ if (uris.length === 0)
1026
+ return new Map();
1027
+ const records = await getRecordsByUris(collection, uris);
1028
+ const map = new Map();
1029
+ for (const r of records) {
1030
+ const shaped = reshapeRow(r, r?.__childData, r?.__unionData);
1031
+ if (shaped)
1032
+ map.set(shaped.uri, shaped);
1033
+ }
1034
+ return map;
1035
+ }
1036
+ /**
1037
+ * Multi-phase search across any collection's records.
1038
+ *
1039
+ * 1. **BM25** — Full-text search via DuckDB FTS. Multi-word queries use conjunctive
1040
+ * mode (ALL terms required) to avoid spurious single-token matches.
1041
+ * 2. **Exact substring** — ILIKE scan on all TEXT/JSON columns catches phrase matches
1042
+ * that BM25 missed or ranked low (e.g. "bad bunny"). Results are prepended to BM25.
1043
+ * 3. **Recent rows** — ILIKE scan of rows ingested since the last FTS rebuild, so newly
1044
+ * written records are immediately searchable before the index catches up.
1045
+ * 4. **Fuzzy** — Jaro-Winkler similarity fallback for typo tolerance when earlier phases
1046
+ * return fewer than `limit` results.
1047
+ *
1048
+ * All phases derive searchable columns generically from the collection schema — no
1049
+ * column names are hardcoded.
1050
+ */
1051
+ export async function searchRecords(collection, query, opts = {}) {
1052
+ const schema = schemas.get(collection);
1053
+ if (!schema)
1054
+ throw new Error(`Unknown collection: ${collection}`);
1055
+ const elapsed = timer();
1056
+ const { limit = 20, fuzzy = true } = opts;
1057
+ const textCols = schema.columns.filter((c) => c.sqlType === 'TEXT');
1058
+ // Also check if FTS has indexed any columns (including derived JSON columns)
1059
+ const ftsSearchCols = getSearchColumns(collection);
1060
+ if (textCols.length === 0 && ftsSearchCols.length === 0) {
1061
+ throw new Error(`No searchable columns in ${collection}`);
1062
+ }
1063
+ // FTS shadow table name (dots replaced with underscores)
1064
+ const safeName = '_fts_' + collection.replace(/\./g, '_');
1065
+ const phaseErrors = [];
1066
+ const phasesUsed = [];
1067
+ // Phase 1: BM25 ranked search via SearchPort
1068
+ let bm25Results = [];
1069
+ const sp = getSearchPort();
1070
+ if (sp)
1071
+ try {
1072
+ const ftsQuery = stripStopWords(query);
1073
+ const ftsSearchColNames = getSearchColumns(collection);
1074
+ // Get ranked URIs from the search port
1075
+ const hits = await sp.search(safeName, ftsQuery, ftsSearchColNames, limit + 1, 0);
1076
+ if (hits.length > 0) {
1077
+ const uriList = hits.map((h) => h.uri);
1078
+ const scoreMap = new Map(hits.map((h) => [h.uri, h.score]));
1079
+ // Fetch full records for matched URIs
1080
+ const placeholders = uriList.map((_, i) => `$${i + 1}`).join(', ');
1081
+ const rows = await all(`SELECT m.* FROM ${schema.tableName} m
1082
+ LEFT JOIN _repos r ON m.did = r.did
1083
+ WHERE m.uri IN (${placeholders})
1084
+ AND (r.status IS NULL OR r.status != 'takendown')`, uriList);
1085
+ // Re-attach scores and sort
1086
+ bm25Results = rows
1087
+ .map((r) => ({ ...r, score: scoreMap.get(r.uri) ?? 0 }))
1088
+ .sort((a, b) => b.score - a.score);
1089
+ }
1090
+ phasesUsed.push('bm25');
1091
+ }
1092
+ catch (err) {
1093
+ phaseErrors.push(`bm25: ${err.message}`);
1094
+ }
1095
+ const bm25Count = bm25Results.length;
1096
+ const hasMore = bm25Results.length > limit;
1097
+ if (hasMore)
1098
+ bm25Results.pop();
1099
+ const existingUris = new Set(bm25Results.map((r) => r.uri));
1100
+ // Phase 2: Fuzzy fallback for typo tolerance (if still under limit)
1101
+ // Only available on dialects with jaro_winkler_similarity (DuckDB)
1102
+ let fuzzyCount = 0;
1103
+ if (fuzzy && dialect.jaroWinklerSimilarity && bm25Results.length < limit) {
1104
+ const remaining = limit - bm25Results.length;
1105
+ const jwFn = dialect.jaroWinklerSimilarity;
1106
+ const simExprs = [
1107
+ ...textCols.map((c) => `${jwFn}(lower(t.${c.name}), lower($1))`),
1108
+ `${jwFn}(lower(r.handle), lower($1))`,
1109
+ ];
1110
+ // Include child table TEXT columns via correlated subquery
1111
+ for (const child of schema.children) {
1112
+ for (const col of child.columns) {
1113
+ if (col.sqlType === 'TEXT') {
1114
+ simExprs.push(`COALESCE((SELECT MAX(${jwFn}(lower(c.${col.name}), lower($1))) FROM ${child.tableName} c WHERE c.parent_uri = t.uri), 0)`);
1115
+ }
1116
+ }
1117
+ }
1118
+ const greatestExpr = dialect.greatest(simExprs);
1119
+ const fuzzySQL = `SELECT t.*, ${greatestExpr} AS fuzzy_score
1120
+ FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did
1121
+ WHERE ${greatestExpr} >= 0.8
1122
+ ORDER BY fuzzy_score DESC
1123
+ LIMIT $2`;
1124
+ try {
1125
+ const fuzzyRows = await all(fuzzySQL, [query, remaining + existingUris.size]);
1126
+ phasesUsed.push('fuzzy');
1127
+ for (const row of fuzzyRows) {
1128
+ if (bm25Results.length >= limit)
1129
+ break;
1130
+ if (!existingUris.has(row.uri)) {
1131
+ bm25Results.push(row);
1132
+ fuzzyCount++;
1133
+ }
1134
+ }
1135
+ }
1136
+ catch (err) {
1137
+ phaseErrors.push(`fuzzy: ${err.message}`);
1138
+ }
1139
+ }
1140
+ // Remove score columns and reshape into Row<T> with value
1141
+ const rawRecords = bm25Results.map(({ score: _score, fuzzy_score: _fuzzy_score, ...rest }) => rest);
1142
+ const records = rawRecords
1143
+ .map((r) => reshapeRow(r, r?.__childData, r?.__unionData))
1144
+ .filter((r) => r != null);
1145
+ const lastRow = bm25Results[bm25Results.length - 1];
1146
+ const nextCursor = hasMore && lastRow?.score != null ? packCursor(lastRow.score, lastRow.cid) : undefined;
1147
+ emit('search', 'query', {
1148
+ collection,
1149
+ query,
1150
+ bm25_count: bm25Count > limit ? bm25Count - 1 : bm25Count,
1151
+ fuzzy_count: fuzzyCount,
1152
+ total_results: records.length,
1153
+ duration_ms: elapsed(),
1154
+ phases_used: phasesUsed.join(','),
1155
+ error: phaseErrors.length > 0 ? phaseErrors.join('; ') : undefined,
1156
+ });
1157
+ return { records, cursor: nextCursor };
1158
+ }
1159
+ // Raw SQL for script feeds
1160
+ export async function querySQL(sql, params = []) {
1161
+ return all(sql, params);
1162
+ }
1163
+ export async function runSQL(sql, params = []) {
1164
+ return run(sql, params);
1165
+ }
1166
+ export async function createBulkInserterSQL(table, columns, options) {
1167
+ return port.createBulkInserter(table, columns, options);
1168
+ }
1169
+ export function getSchema(collection) {
1170
+ return schemas.get(collection);
1171
+ }
1172
+ export async function countByField(collection, field, value) {
1173
+ const schema = schemas.get(collection);
1174
+ if (!schema)
1175
+ return 0;
1176
+ const rows = await all(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE ${field} = $1`, [value]);
1177
+ return Number(rows[0]?.count || 0);
1178
+ }
1179
+ export async function countByFieldBatch(collection, field, values) {
1180
+ if (values.length === 0)
1181
+ return new Map();
1182
+ const schema = schemas.get(collection);
1183
+ if (!schema)
1184
+ return new Map();
1185
+ const placeholders = values.map((_, i) => `$${i + 1}`).join(',');
1186
+ const rows = await all(`SELECT ${field}, COUNT(*) as count FROM ${schema.tableName} WHERE ${field} IN (${placeholders}) GROUP BY ${field}`, values);
1187
+ const result = new Map();
1188
+ for (const row of rows) {
1189
+ result.set(row[field], Number(row.count));
1190
+ }
1191
+ return result;
1192
+ }
1193
+ export async function findByField(collection, field, value) {
1194
+ const schema = schemas.get(collection);
1195
+ if (!schema)
1196
+ return null;
1197
+ const rows = await all(`SELECT * FROM ${schema.tableName} WHERE ${field} = $1 LIMIT 1`, [value]);
1198
+ return rows[0] || null;
1199
+ }
1200
+ export async function findByFieldBatch(collection, field, values) {
1201
+ if (values.length === 0)
1202
+ return new Map();
1203
+ const schema = schemas.get(collection);
1204
+ if (!schema)
1205
+ return new Map();
1206
+ const placeholders = values.map((_, i) => `$${i + 1}`).join(',');
1207
+ 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})`, values);
1208
+ // Attach child data if this collection has decomposed arrays
1209
+ if (schema.children.length > 0 && rows.length > 0) {
1210
+ const uris = rows.map((r) => r.uri);
1211
+ const childData = new Map();
1212
+ for (const child of schema.children) {
1213
+ const childRows = await getChildRows(child.tableName, uris);
1214
+ childData.set(child.fieldName, childRows);
1215
+ }
1216
+ for (const row of rows) {
1217
+ row.__childData = childData;
1218
+ }
1219
+ }
1220
+ const result = new Map();
1221
+ for (const row of rows) {
1222
+ const key = row[field];
1223
+ if (!result.has(key))
1224
+ result.set(key, []);
1225
+ result.get(key).push(row);
1226
+ }
1227
+ return result;
1228
+ }
1229
+ export async function lookupByFieldBatch(collection, field, values) {
1230
+ if (values.length === 0)
1231
+ return new Map();
1232
+ const results = await findByFieldBatch(collection, field, values);
1233
+ const map = new Map();
1234
+ for (const [key, records] of results) {
1235
+ const shaped = records.length > 0 ? reshapeRow(records[0], records[0]?.__childData) : null;
1236
+ if (shaped)
1237
+ map.set(key, shaped);
1238
+ }
1239
+ return map;
1240
+ }
1241
+ export async function findUriByFields(collection, conditions) {
1242
+ const schema = schemas.get(collection);
1243
+ if (!schema)
1244
+ return null;
1245
+ const where = conditions.map((c, i) => `${c.field} = $${i + 1}`).join(' AND ');
1246
+ const params = conditions.map((c) => c.value);
1247
+ const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE ${where} LIMIT 1`, params);
1248
+ return rows[0]?.uri || null;
1249
+ }
1250
+ const ENVELOPE_KEYS = new Set(['uri', 'cid', 'did', 'handle', 'indexed_at']);
1251
+ const INTERNAL_KEYS = new Set(['__childData', '__unionData']);
1252
+ export function normalizeValue(v) {
1253
+ if (v && typeof v === 'object' && 'micros' in v)
1254
+ return new Date(Number(v.micros) / 1000).toISOString();
1255
+ if (typeof v === 'bigint')
1256
+ return Number(v);
1257
+ return v;
1258
+ }
1259
+ export async function getChildRows(childTableName, parentUris) {
1260
+ if (parentUris.length === 0)
1261
+ return new Map();
1262
+ const placeholders = parentUris.map((_, i) => `$${i + 1}`).join(',');
1263
+ const rows = await all(`SELECT * FROM ${childTableName} WHERE parent_uri IN (${placeholders})`, parentUris);
1264
+ const result = new Map();
1265
+ for (const row of rows) {
1266
+ const key = row.parent_uri;
1267
+ if (!result.has(key))
1268
+ result.set(key, []);
1269
+ result.get(key).push(row);
1270
+ }
1271
+ return result;
1272
+ }
1273
+ export function reshapeRow(row, childData, unionData) {
1274
+ if (!row)
1275
+ return null;
1276
+ // Derive collection from URI (at://did/collection/rkey)
1277
+ const collection = row.uri?.split('/')?.[3];
1278
+ const schema = collection ? schemas.get(collection) : null;
1279
+ // Build snake→camel map and JSON column set from schema
1280
+ const nameMap = new Map();
1281
+ const jsonCols = new Set();
1282
+ if (schema) {
1283
+ for (const col of schema.columns) {
1284
+ nameMap.set(col.name, col.originalName);
1285
+ if (col.isJson)
1286
+ jsonCols.add(col.name);
1287
+ }
1288
+ }
1289
+ const value = {};
1290
+ const envelope = {};
1291
+ for (const [key, rawVal] of Object.entries(row)) {
1292
+ const val = normalizeValue(rawVal);
1293
+ if (INTERNAL_KEYS.has(key)) {
1294
+ continue;
1295
+ }
1296
+ else if (ENVELOPE_KEYS.has(key)) {
1297
+ envelope[key] = val;
1298
+ }
1299
+ else {
1300
+ const originalKey = nameMap.get(key) || key;
1301
+ if (jsonCols.has(key) && typeof val === 'string') {
1302
+ try {
1303
+ value[originalKey] = JSON.parse(val);
1304
+ }
1305
+ catch {
1306
+ value[originalKey] = val;
1307
+ }
1308
+ }
1309
+ else {
1310
+ value[originalKey] = val;
1311
+ }
1312
+ }
1313
+ }
1314
+ // Reconstruct decomposed array fields from child data
1315
+ if (schema && childData) {
1316
+ for (const child of schema.children) {
1317
+ const childMap = childData.get(child.fieldName);
1318
+ const childRows = childMap?.get(row.uri) || [];
1319
+ value[child.fieldName] = childRows.map((cr) => {
1320
+ const item = {};
1321
+ for (const col of child.columns) {
1322
+ const raw = cr[col.name];
1323
+ item[col.originalName] = normalizeValue(raw);
1324
+ }
1325
+ return item;
1326
+ });
1327
+ }
1328
+ }
1329
+ // Reconstruct union fields from branch data
1330
+ const uData = unionData || row.__unionData;
1331
+ if (schema && uData) {
1332
+ for (const union of schema.unions) {
1333
+ const branchDataMap = uData.get(union.fieldName);
1334
+ if (!branchDataMap)
1335
+ continue;
1336
+ // Find which branch has rows for this URI (implicit discrimination)
1337
+ for (const branch of union.branches) {
1338
+ const branchMap = branchDataMap.get(branch.branchName);
1339
+ const branchRows = branchMap?.get(row.uri);
1340
+ if (!branchRows || branchRows.length === 0)
1341
+ continue;
1342
+ if (branch.isArray && branch.arrayField) {
1343
+ // Array branch: reconstruct { $type, arrayField: [...items] }
1344
+ const items = branchRows.map((br) => {
1345
+ const item = {};
1346
+ for (const col of branch.columns) {
1347
+ item[col.originalName] = normalizeValue(br[col.name]);
1348
+ }
1349
+ return item;
1350
+ });
1351
+ value[union.fieldName] = { $type: branch.type, [branch.arrayField]: items };
1352
+ }
1353
+ else {
1354
+ // Single-value branch: reconstruct { $type, ...properties }
1355
+ // If branchName matches a wrapper property pattern, nest under it
1356
+ const br = branchRows[0];
1357
+ const props = {};
1358
+ for (const col of branch.columns) {
1359
+ props[col.originalName] = normalizeValue(br[col.name]);
1360
+ }
1361
+ if (branch.wrapperField) {
1362
+ value[union.fieldName] = { $type: branch.type, [branch.wrapperField]: props };
1363
+ }
1364
+ else {
1365
+ value[union.fieldName] = { $type: branch.type, ...props };
1366
+ }
1367
+ }
1368
+ break; // Only one branch should match
1369
+ }
1370
+ }
1371
+ }
1372
+ return { ...envelope, value };
1373
+ }
1374
+ export function packCursor(sortVal, cid) {
1375
+ const primary = sortVal instanceof Date ? sortVal.toISOString() : String(sortVal);
1376
+ return Buffer.from(`${primary}::${cid}`).toString('base64url');
1377
+ }
1378
+ export function unpackCursor(cursor) {
1379
+ try {
1380
+ const decoded = Buffer.from(cursor, 'base64url').toString();
1381
+ const idx = decoded.lastIndexOf('::');
1382
+ if (idx === -1)
1383
+ return null;
1384
+ return { primary: decoded.substring(0, idx), cid: decoded.substring(idx + 2) };
1385
+ }
1386
+ catch {
1387
+ return null;
1388
+ }
1389
+ }
1390
+ export async function queryLabelsByDid(did) {
1391
+ return all(`SELECT * FROM _labels WHERE uri LIKE $1 AND neg = false AND (exp IS NULL OR exp > CURRENT_TIMESTAMP)`, [
1392
+ `at://${did}/%`,
1393
+ ]);
1394
+ }
1395
+ export async function searchAccounts(query, limit = 20) {
1396
+ 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]);
1397
+ }
1398
+ export async function getAccountRecordCount(did) {
1399
+ let total = 0;
1400
+ for (const [, schema] of schemas) {
1401
+ const rows = await all(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE did = $1`, [did]);
1402
+ total += Number(rows[0]?.count || 0);
1403
+ }
1404
+ return total;
1405
+ }
1406
+ export async function getAllRecordUrisForDid(did) {
1407
+ const uris = [];
1408
+ for (const [, schema] of schemas) {
1409
+ const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE did = $1`, [did]);
1410
+ uris.push(...rows.map((r) => r.uri));
1411
+ }
1412
+ return uris;
1413
+ }
1414
+ export async function isTakendownDid(did) {
1415
+ const rows = await all(`SELECT 1 FROM _repos WHERE did = $1 AND status = 'takendown' LIMIT 1`, [did]);
1416
+ return rows.length > 0;
1417
+ }
1418
+ export async function getPreferences(did) {
1419
+ const rows = await all(`SELECT key, value FROM _preferences WHERE did = $1`, [did]);
1420
+ const prefs = {};
1421
+ for (const row of rows) {
1422
+ try {
1423
+ prefs[row.key] = typeof row.value === 'string' ? JSON.parse(row.value) : row.value;
1424
+ }
1425
+ catch {
1426
+ prefs[row.key] = row.value;
1427
+ }
1428
+ }
1429
+ return prefs;
1430
+ }
1431
+ export async function putPreference(did, key, value) {
1432
+ await run(`INSERT OR REPLACE INTO _preferences (did, key, value, updated_at) VALUES ($1, $2, $3, $4)`, [
1433
+ did,
1434
+ key,
1435
+ JSON.stringify(value),
1436
+ new Date().toISOString(),
1437
+ ]);
1438
+ }
1439
+ export async function filterTakendownDids(dids) {
1440
+ if (dids.length === 0)
1441
+ return new Set();
1442
+ const placeholders = dids.map((_, i) => `$${i + 1}`).join(',');
1443
+ const rows = await all(`SELECT did FROM _repos WHERE did IN (${placeholders}) AND status = 'takendown'`, dids);
1444
+ return new Set(rows.map((r) => r.did));
1445
+ }