@hatk/hatk 0.0.1-alpha.5 → 0.0.1-alpha.50

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