@hatk/hatk 0.0.1-alpha.8 → 0.0.1-alpha.81

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 (175) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +111 -0
  4. package/dist/backfill.d.ts +29 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +136 -51
  7. package/dist/car.d.ts +42 -10
  8. package/dist/car.d.ts.map +1 -1
  9. package/dist/car.js +154 -14
  10. package/dist/cli.js +243 -1043
  11. package/dist/config.d.ts +82 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +44 -9
  14. package/dist/database/adapter-factory.d.ts +6 -0
  15. package/dist/database/adapter-factory.d.ts.map +1 -0
  16. package/dist/database/adapter-factory.js +20 -0
  17. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  18. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  19. package/dist/database/adapters/duckdb-search.js +27 -0
  20. package/dist/database/adapters/duckdb.d.ts +25 -0
  21. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  22. package/dist/database/adapters/duckdb.js +161 -0
  23. package/dist/database/adapters/sqlite-search.d.ts +16 -0
  24. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  25. package/dist/database/adapters/sqlite-search.js +76 -0
  26. package/dist/database/adapters/sqlite.d.ts +18 -0
  27. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  28. package/dist/database/adapters/sqlite.js +88 -0
  29. package/dist/{db.d.ts → database/db.d.ts} +57 -6
  30. package/dist/database/db.d.ts.map +1 -0
  31. package/dist/{db.js → database/db.js} +755 -552
  32. package/dist/database/dialect.d.ts +45 -0
  33. package/dist/database/dialect.d.ts.map +1 -0
  34. package/dist/database/dialect.js +72 -0
  35. package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
  36. package/dist/database/fts.d.ts.map +1 -0
  37. package/dist/{fts.js → database/fts.js} +116 -32
  38. package/dist/database/index.d.ts +7 -0
  39. package/dist/database/index.d.ts.map +1 -0
  40. package/dist/database/index.js +6 -0
  41. package/dist/database/ports.d.ts +50 -0
  42. package/dist/database/ports.d.ts.map +1 -0
  43. package/dist/database/ports.js +1 -0
  44. package/dist/{schema.d.ts → database/schema.d.ts} +14 -3
  45. package/dist/database/schema.d.ts.map +1 -0
  46. package/dist/{schema.js → database/schema.js} +81 -41
  47. package/dist/dev-entry.d.ts +8 -0
  48. package/dist/dev-entry.d.ts.map +1 -0
  49. package/dist/dev-entry.js +125 -0
  50. package/dist/feeds.d.ts +12 -8
  51. package/dist/feeds.d.ts.map +1 -1
  52. package/dist/feeds.js +51 -6
  53. package/dist/hooks.d.ts +85 -0
  54. package/dist/hooks.d.ts.map +1 -0
  55. package/dist/hooks.js +163 -0
  56. package/dist/hydrate.d.ts +7 -6
  57. package/dist/hydrate.d.ts.map +1 -1
  58. package/dist/hydrate.js +4 -16
  59. package/dist/indexer.d.ts +133 -3
  60. package/dist/indexer.d.ts.map +1 -1
  61. package/dist/indexer.js +406 -66
  62. package/dist/jetstream.d.ts +118 -0
  63. package/dist/jetstream.d.ts.map +1 -0
  64. package/dist/jetstream.js +225 -0
  65. package/dist/labels.d.ts +36 -0
  66. package/dist/labels.d.ts.map +1 -1
  67. package/dist/labels.js +71 -6
  68. package/dist/lexicon-resolve.d.ts.map +1 -1
  69. package/dist/lexicon-resolve.js +27 -112
  70. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  71. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  72. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  73. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  74. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  75. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  76. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  77. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  78. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  79. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  80. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  81. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  82. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  83. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  84. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  85. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  86. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  87. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  88. package/dist/logger.d.ts +29 -0
  89. package/dist/logger.d.ts.map +1 -1
  90. package/dist/logger.js +29 -0
  91. package/dist/main.js +169 -74
  92. package/dist/mst.d.ts +18 -1
  93. package/dist/mst.d.ts.map +1 -1
  94. package/dist/mst.js +19 -8
  95. package/dist/oauth/db.d.ts +3 -1
  96. package/dist/oauth/db.d.ts.map +1 -1
  97. package/dist/oauth/db.js +56 -19
  98. package/dist/oauth/discovery.d.ts.map +1 -1
  99. package/dist/oauth/discovery.js +13 -1
  100. package/dist/oauth/server.d.ts +92 -1
  101. package/dist/oauth/server.d.ts.map +1 -1
  102. package/dist/oauth/server.js +352 -37
  103. package/dist/oauth/service-describe.d.ts +31 -0
  104. package/dist/oauth/service-describe.d.ts.map +1 -0
  105. package/dist/oauth/service-describe.js +61 -0
  106. package/dist/oauth/session.d.ts +32 -0
  107. package/dist/oauth/session.d.ts.map +1 -0
  108. package/dist/oauth/session.js +131 -0
  109. package/dist/opengraph.d.ts +10 -0
  110. package/dist/opengraph.d.ts.map +1 -1
  111. package/dist/opengraph.js +80 -40
  112. package/dist/pds-proxy.d.ts +82 -0
  113. package/dist/pds-proxy.d.ts.map +1 -0
  114. package/dist/pds-proxy.js +356 -0
  115. package/dist/private-collections.d.ts +8 -0
  116. package/dist/private-collections.d.ts.map +1 -0
  117. package/dist/private-collections.js +22 -0
  118. package/dist/push.d.ts +54 -0
  119. package/dist/push.d.ts.map +1 -0
  120. package/dist/push.js +367 -0
  121. package/dist/renderer.d.ts +27 -0
  122. package/dist/renderer.d.ts.map +1 -0
  123. package/dist/renderer.js +46 -0
  124. package/dist/resolve-hatk.d.ts +6 -0
  125. package/dist/resolve-hatk.d.ts.map +1 -0
  126. package/dist/resolve-hatk.js +20 -0
  127. package/dist/response.d.ts +16 -0
  128. package/dist/response.d.ts.map +1 -0
  129. package/dist/response.js +69 -0
  130. package/dist/scanner.d.ts +21 -0
  131. package/dist/scanner.d.ts.map +1 -0
  132. package/dist/scanner.js +88 -0
  133. package/dist/seed.d.ts +19 -0
  134. package/dist/seed.d.ts.map +1 -1
  135. package/dist/seed.js +43 -4
  136. package/dist/server-init.d.ts +8 -0
  137. package/dist/server-init.d.ts.map +1 -0
  138. package/dist/server-init.js +62 -0
  139. package/dist/server.d.ts +26 -3
  140. package/dist/server.d.ts.map +1 -1
  141. package/dist/server.js +858 -638
  142. package/dist/setup.d.ts +28 -1
  143. package/dist/setup.d.ts.map +1 -1
  144. package/dist/setup.js +50 -3
  145. package/dist/templates/feed.tpl +14 -0
  146. package/dist/templates/hook.tpl +5 -0
  147. package/dist/templates/label.tpl +15 -0
  148. package/dist/templates/og.tpl +17 -0
  149. package/dist/templates/seed.tpl +11 -0
  150. package/dist/templates/setup.tpl +5 -0
  151. package/dist/templates/test-feed.tpl +19 -0
  152. package/dist/templates/test-xrpc.tpl +19 -0
  153. package/dist/templates/xrpc.tpl +41 -0
  154. package/dist/test.d.ts +1 -1
  155. package/dist/test.d.ts.map +1 -1
  156. package/dist/test.js +41 -32
  157. package/dist/views.js +1 -1
  158. package/dist/vite-plugin.d.ts +1 -1
  159. package/dist/vite-plugin.d.ts.map +1 -1
  160. package/dist/vite-plugin.js +254 -66
  161. package/dist/xrpc.d.ts +84 -11
  162. package/dist/xrpc.d.ts.map +1 -1
  163. package/dist/xrpc.js +211 -41
  164. package/package.json +17 -9
  165. package/public/admin-auth.js +271 -306
  166. package/public/admin.html +177 -61
  167. package/dist/db.d.ts.map +0 -1
  168. package/dist/fts.d.ts.map +0 -1
  169. package/dist/oauth/hooks.d.ts +0 -10
  170. package/dist/oauth/hooks.d.ts.map +0 -1
  171. package/dist/oauth/hooks.js +0 -40
  172. package/dist/schema.d.ts.map +0 -1
  173. package/dist/test-browser.d.ts +0 -14
  174. package/dist/test-browser.d.ts.map +0 -1
  175. 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
@@ -142,79 +65,350 @@ export async function initDatabase(dbPath, tableSchemas, ddlStatements) {
142
65
  catch { }
143
66
  // Re-queue repos with missing handles so backfill populates them
144
67
  await run(`UPDATE _repos SET status = 'pending' WHERE handle IS NULL`);
68
+ // Covering index for the status rollups the admin overview and backfill queue
69
+ // read. Skipped on DuckDB, which scans the column just as fast and would only
70
+ // pay the ART index's insert cost.
71
+ if (port.dialect !== 'duckdb') {
72
+ await run(`CREATE INDEX IF NOT EXISTS idx_repos_status ON _repos(status)`);
73
+ }
74
+ // resolveHandleToDid runs on every feed request that takes an `actor` handle,
75
+ // and without this it scans all of _repos. Not UNIQUE: during a handle change
76
+ // two rows can briefly claim the same handle, and a constraint violation there
77
+ // would fail the indexer write.
78
+ await run(`CREATE INDEX IF NOT EXISTS idx_repos_handle ON _repos(handle)`);
145
79
  await run(`CREATE TABLE IF NOT EXISTS _cursor (
146
80
  key TEXT PRIMARY KEY,
147
81
  value TEXT NOT NULL
148
82
  )`);
149
83
  // 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
- )`);
84
+ if (dialect.supportsSequences) {
85
+ await run(`CREATE SEQUENCE IF NOT EXISTS _labels_seq START 1`);
86
+ await run(`CREATE TABLE IF NOT EXISTS _labels (
87
+ id INTEGER PRIMARY KEY DEFAULT nextval('_labels_seq'),
88
+ src TEXT NOT NULL,
89
+ uri TEXT NOT NULL,
90
+ val TEXT NOT NULL,
91
+ neg ${dialect.typeMap.boolean} DEFAULT FALSE,
92
+ cts ${dialect.timestampType} NOT NULL,
93
+ exp ${dialect.timestampType}
94
+ )`);
95
+ }
96
+ else {
97
+ await run(`CREATE TABLE IF NOT EXISTS _labels (
98
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
99
+ src TEXT NOT NULL,
100
+ uri TEXT NOT NULL,
101
+ val TEXT NOT NULL,
102
+ neg INTEGER DEFAULT 0,
103
+ cts TEXT NOT NULL,
104
+ exp TEXT
105
+ )`);
106
+ }
160
107
  await run(`CREATE INDEX IF NOT EXISTS idx_labels_uri ON _labels(uri)`);
161
108
  await run(`CREATE INDEX IF NOT EXISTS idx_labels_src ON _labels(src)`);
162
109
  // Preferences table (generic key-value per user)
163
110
  await run(`CREATE TABLE IF NOT EXISTS _preferences (
164
111
  did TEXT NOT NULL,
165
112
  key TEXT NOT NULL,
166
- value JSON NOT NULL,
167
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
113
+ value ${dialect.jsonType} NOT NULL,
114
+ updated_at ${dialect.timestampType} DEFAULT ${dialect.currentTimestamp},
168
115
  PRIMARY KEY (did, key)
116
+ )`);
117
+ // Reports table (user-submitted moderation reports)
118
+ if (dialect.supportsSequences) {
119
+ await run(`CREATE SEQUENCE IF NOT EXISTS _reports_seq START 1`);
120
+ await run(`CREATE TABLE IF NOT EXISTS _reports (
121
+ id INTEGER PRIMARY KEY DEFAULT nextval('_reports_seq'),
122
+ subject_uri TEXT NOT NULL,
123
+ subject_did TEXT NOT NULL,
124
+ label TEXT NOT NULL,
125
+ reason TEXT,
126
+ reported_by TEXT NOT NULL,
127
+ status TEXT NOT NULL DEFAULT 'open',
128
+ resolved_by TEXT,
129
+ resolved_at ${dialect.timestampType},
130
+ created_at ${dialect.timestampType} NOT NULL
131
+ )`);
132
+ }
133
+ else {
134
+ await run(`CREATE TABLE IF NOT EXISTS _reports (
135
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
136
+ subject_uri TEXT NOT NULL,
137
+ subject_did TEXT NOT NULL,
138
+ label TEXT NOT NULL,
139
+ reason TEXT,
140
+ reported_by TEXT NOT NULL,
141
+ status TEXT NOT NULL DEFAULT 'open',
142
+ resolved_by TEXT,
143
+ resolved_at TEXT,
144
+ created_at TEXT NOT NULL
145
+ )`);
146
+ }
147
+ await run(`CREATE INDEX IF NOT EXISTS idx_reports_status ON _reports(status)`);
148
+ await run(`CREATE INDEX IF NOT EXISTS idx_reports_subject_uri ON _reports(subject_uri)`);
149
+ // Push notification tokens
150
+ await run(`CREATE TABLE IF NOT EXISTS _push_tokens (
151
+ did TEXT NOT NULL,
152
+ token TEXT NOT NULL,
153
+ platform TEXT NOT NULL,
154
+ created_at TEXT NOT NULL,
155
+ PRIMARY KEY (did, token)
169
156
  )`);
170
157
  // OAuth tables
171
- for (const statement of OAUTH_DDL.split(';').filter((s) => s.trim())) {
172
- await run(statement);
158
+ await port.executeMultiple(OAUTH_DDL);
159
+ // Migrations: add pds_auth_server to existing sessions tables
160
+ try {
161
+ await run(`ALTER TABLE _oauth_sessions ADD COLUMN pds_auth_server TEXT`);
162
+ }
163
+ catch { }
164
+ }
165
+ /** Normalize SQL type names to handle dialect differences (e.g. VARCHAR → TEXT) */
166
+ function normalizeType(type) {
167
+ const upper = type.toUpperCase();
168
+ if (upper === 'VARCHAR' || upper === 'CHARACTER VARYING')
169
+ return 'TEXT';
170
+ if (upper === 'TIMESTAMP WITH TIME ZONE')
171
+ return 'TIMESTAMPTZ';
172
+ if (upper === 'BOOLEAN' || upper === 'BOOL')
173
+ return 'BOOLEAN';
174
+ if (upper === 'INT' || upper === 'INT4' || upper === 'INT8' || upper === 'BIGINT' || upper === 'SMALLINT')
175
+ return 'INTEGER';
176
+ return upper;
177
+ }
178
+ async function getExistingColumns(tableName) {
179
+ if (!/^[a-zA-Z0-9._]+$/.test(tableName)) {
180
+ throw new Error(`Invalid table name for introspection: ${tableName}`);
181
+ }
182
+ const cols = new Map();
183
+ try {
184
+ const query = dialect.introspectColumnsQuery(tableName);
185
+ const rows = await all(query);
186
+ for (const row of rows) {
187
+ // SQLite PRAGMA returns { name, type }, DuckDB returns { column_name, data_type }
188
+ const name = (row.column_name || row.name);
189
+ const type = normalizeType((row.data_type || row.type || 'TEXT'));
190
+ cols.set(name, type);
191
+ }
192
+ }
193
+ catch {
194
+ // Table doesn't exist yet
195
+ }
196
+ return cols;
197
+ }
198
+ function diffColumns(tableName, existingCols, expectedCols, changes) {
199
+ for (const [colName, colType] of expectedCols) {
200
+ if (!existingCols.has(colName)) {
201
+ changes.push({ table: tableName, action: 'add', column: colName, type: colType });
202
+ }
203
+ }
204
+ for (const [colName] of existingCols) {
205
+ if (!expectedCols.has(colName)) {
206
+ changes.push({ table: tableName, action: 'drop', column: colName });
207
+ }
208
+ }
209
+ for (const [colName, colType] of expectedCols) {
210
+ const existingType = existingCols.get(colName);
211
+ if (existingType && normalizeType(existingType) !== normalizeType(colType)) {
212
+ changes.push({ table: tableName, action: 'retype', column: colName, type: colType });
213
+ }
214
+ }
215
+ }
216
+ /** Build expected columns map for a child/union table */
217
+ function buildChildExpectedCols(columns) {
218
+ const expected = new Map();
219
+ expected.set('parent_uri', 'TEXT');
220
+ expected.set('parent_did', 'TEXT');
221
+ for (const col of columns) {
222
+ expected.set(col.name, normalizeType(col.sqlType));
223
+ }
224
+ return expected;
225
+ }
226
+ export async function migrateSchema(tableSchemas) {
227
+ const changes = [];
228
+ const newCollections = new Set();
229
+ for (const schema of tableSchemas) {
230
+ if (schema.columns.length === 0)
231
+ continue; // generic JSON storage, skip
232
+ const tableName = schema.collection;
233
+ const existingCols = await getExistingColumns(tableName);
234
+ if (existingCols.size === 0) {
235
+ newCollections.add(schema.collection);
236
+ continue; // table just created, nothing to migrate
237
+ }
238
+ // Expected columns: base columns (uri, cid, did, indexed_at) + schema columns
239
+ const expectedCols = new Map();
240
+ expectedCols.set('uri', 'TEXT');
241
+ expectedCols.set('cid', 'TEXT');
242
+ expectedCols.set('did', 'TEXT');
243
+ expectedCols.set('indexed_at', normalizeType(dialect.timestampType));
244
+ for (const col of schema.columns) {
245
+ expectedCols.set(col.name, normalizeType(col.sqlType));
246
+ }
247
+ diffColumns(tableName, existingCols, expectedCols, changes);
248
+ // Diff child tables
249
+ for (const child of schema.children) {
250
+ const childTable = child.tableName.replace(/"/g, '');
251
+ const existingChildCols = await getExistingColumns(childTable);
252
+ if (existingChildCols.size === 0)
253
+ continue;
254
+ diffColumns(childTable, existingChildCols, buildChildExpectedCols(child.columns), changes);
255
+ }
256
+ // Diff union branch tables
257
+ for (const union of schema.unions) {
258
+ for (const branch of union.branches) {
259
+ const branchTable = branch.tableName.replace(/"/g, '');
260
+ const existingBranchCols = await getExistingColumns(branchTable);
261
+ if (existingBranchCols.size === 0)
262
+ continue;
263
+ diffColumns(branchTable, existingBranchCols, buildChildExpectedCols(branch.columns), changes);
264
+ }
265
+ }
266
+ }
267
+ // Detect and drop orphaned child/union tables (query table list once)
268
+ let allTableNames = null;
269
+ try {
270
+ const rows = await all(dialect.listTablesQuery);
271
+ allTableNames = rows.map((r) => r.table_name);
272
+ }
273
+ catch { }
274
+ if (allTableNames) {
275
+ for (const schema of tableSchemas) {
276
+ if (schema.columns.length === 0)
277
+ continue;
278
+ const expectedTables = new Set();
279
+ for (const child of schema.children) {
280
+ expectedTables.add(child.tableName.replace(/"/g, ''));
281
+ }
282
+ for (const union of schema.unions) {
283
+ for (const branch of union.branches) {
284
+ expectedTables.add(branch.tableName.replace(/"/g, ''));
285
+ }
286
+ }
287
+ for (const name of allTableNames) {
288
+ if (name.startsWith(schema.collection + '__') && !expectedTables.has(name)) {
289
+ await run(`DROP TABLE IF EXISTS "${name}"`);
290
+ emit('migration', 'drop_table', { table: name });
291
+ }
292
+ }
293
+ }
294
+ }
295
+ if (changes.length > 0) {
296
+ await applyMigrationChanges(changes);
297
+ }
298
+ // Trigger backfill only for genuinely new collections (tables created this startup)
299
+ // Previously this checked ALL empty tables, which caused infinite resync loops
300
+ // for collections that are legitimately empty (e.g. blocks when nobody has blocked)
301
+ if (newCollections.size > 0) {
302
+ const [hasRepos] = await all(`SELECT 1 FROM _repos LIMIT 1`);
303
+ if (hasRepos) {
304
+ await run(`UPDATE _repos SET status = 'pending' WHERE status = 'active'`);
305
+ for (const collection of newCollections) {
306
+ emit('migration', 'new_collection', { collection });
307
+ }
308
+ }
309
+ }
310
+ return changes;
311
+ }
312
+ async function applyMigrationChanges(changes) {
313
+ for (const change of changes) {
314
+ const quotedTable = `"${change.table}"`;
315
+ const quotedColumn = `"${change.column}"`;
316
+ try {
317
+ switch (change.action) {
318
+ case 'add': {
319
+ await run(`ALTER TABLE ${quotedTable} ADD COLUMN ${quotedColumn} ${change.type}`);
320
+ emit('migration', 'add_column', { table: change.table, column: change.column, type: change.type });
321
+ const schema = schemas.get(change.table);
322
+ if (schema?.refColumns.includes(change.column)) {
323
+ const prefix = change.table.replace(/\./g, '_');
324
+ await run(`CREATE INDEX IF NOT EXISTS idx_${prefix}_${change.column} ON ${quotedTable}(${quotedColumn})`);
325
+ }
326
+ break;
327
+ }
328
+ case 'drop':
329
+ await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${quotedColumn}`);
330
+ emit('migration', 'drop_column', { table: change.table, column: change.column });
331
+ break;
332
+ case 'retype':
333
+ await run(`ALTER TABLE ${quotedTable} DROP COLUMN ${quotedColumn}`);
334
+ await run(`ALTER TABLE ${quotedTable} ADD COLUMN ${quotedColumn} ${change.type}`);
335
+ emit('migration', 'retype_column', { table: change.table, column: change.column, type: change.type });
336
+ break;
337
+ }
338
+ }
339
+ catch (err) {
340
+ console.warn(`[migration] failed to ${change.action} column "${change.column}" on "${change.table}": ${err.message}`);
341
+ emit('migration', 'error', {
342
+ action: change.action,
343
+ table: change.table,
344
+ column: change.column,
345
+ error: err.message,
346
+ });
347
+ }
173
348
  }
174
349
  }
175
350
  export async function getCursor(key) {
176
- const rows = await all(`SELECT value FROM _cursor WHERE key = $1`, key);
351
+ const rows = await all(`SELECT value FROM _cursor WHERE key = $1`, [key]);
177
352
  return rows[0]?.value || null;
178
353
  }
179
354
  export async function setCursor(key, value) {
180
- await run(`INSERT OR REPLACE INTO _cursor (key, value) VALUES ($1, $2)`, key, value);
355
+ await run(`INSERT OR REPLACE INTO _cursor (key, value) VALUES ($1, $2)`, [key, value]);
181
356
  }
182
357
  export async function getRepoStatus(did) {
183
- const rows = await all(`SELECT status FROM _repos WHERE did = $1`, did);
358
+ const rows = await all(`SELECT status FROM _repos WHERE did = $1`, [did]);
184
359
  return rows[0]?.status || null;
185
360
  }
186
361
  export async function setRepoStatus(did, status, rev, opts) {
187
362
  if (status === 'active') {
188
363
  // 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);
364
+ 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
365
  // 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);
366
+ 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
367
  }
193
368
  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);
369
+ 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
370
  // 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);
371
+ 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
372
  }
198
373
  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);
374
+ await run(`UPDATE _repos SET status = $1 WHERE did = $2`, [status, did]);
375
+ await run(`INSERT OR IGNORE INTO _repos (did, status) VALUES ($1, $2)`, [did, status]);
201
376
  }
202
377
  }
378
+ /** Update the handle for a DID if it exists in _repos. */
379
+ export async function updateRepoHandle(did, handle) {
380
+ await run(`UPDATE _repos SET handle = $1 WHERE did = $2`, [handle, did]);
381
+ }
382
+ export async function getRepoRev(did) {
383
+ const rows = await all(`SELECT rev FROM _repos WHERE did = $1`, [did]);
384
+ return rows[0]?.rev ?? null;
385
+ }
203
386
  export async function getRepoRetryInfo(did) {
204
- const rows = await all(`SELECT retry_count, retry_after FROM _repos WHERE did = $1`, did);
387
+ const rows = await all(`SELECT retry_count, retry_after FROM _repos WHERE did = $1`, [did]);
205
388
  if (rows.length === 0)
206
389
  return null;
207
390
  return { retryCount: Number(rows[0].retry_count), retryAfter: Number(rows[0].retry_after) };
208
391
  }
209
392
  export async function listRetryEligibleRepos(maxRetries) {
210
393
  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);
394
+ const rows = await all(`SELECT did FROM _repos WHERE status = 'failed' AND retry_after <= $1 AND retry_count < $2`, [now, maxRetries]);
212
395
  return rows.map((r) => r.did);
213
396
  }
214
397
  export async function listPendingRepos() {
215
398
  const rows = await all(`SELECT did FROM _repos WHERE status = 'pending'`);
216
399
  return rows.map((r) => r.did);
217
400
  }
401
+ export async function listActiveRepoDids() {
402
+ const rows = await all(`SELECT did FROM _repos WHERE status = 'active'`);
403
+ return rows.map((r) => r.did);
404
+ }
405
+ export async function removeRepo(did) {
406
+ await run(`DELETE FROM _repos WHERE did = $1`, [did]);
407
+ }
408
+ export async function getRepoHandle(did) {
409
+ const rows = await all(`SELECT handle FROM _repos WHERE did = $1`, [did]);
410
+ return rows[0]?.handle ?? null;
411
+ }
218
412
  export async function listAllRepoStatuses() {
219
413
  return (await all(`SELECT did, status FROM _repos`));
220
414
  }
@@ -228,27 +422,108 @@ export async function listReposPaginated(opts = {}) {
228
422
  params.push(status);
229
423
  }
230
424
  if (q) {
231
- conditions.push(`(did ILIKE $${paramIdx} OR handle ILIKE $${paramIdx})`);
425
+ conditions.push(`(did ${dialect.ilike} $${paramIdx} OR handle ${dialect.ilike} $${paramIdx})`);
232
426
  params.push(`%${q}%`);
233
427
  paramIdx++;
234
428
  }
235
429
  const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
236
- const countRows = await all(`SELECT COUNT(*)::INTEGER as total FROM _repos${where}`, ...params);
430
+ const countRows = await all(`SELECT ${dialect.countAsInteger} as total FROM _repos${where}`, params);
237
431
  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);
432
+ 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
433
  return { repos: rows, total };
240
434
  }
241
435
  export async function getCollectionCounts() {
436
+ const entries = [...schemas];
242
437
  const counts = {};
243
- for (const [collection, schema] of schemas) {
244
- const rows = await all(`SELECT COUNT(*)::INTEGER as count FROM ${schema.tableName}`);
245
- counts[collection] = Number(rows[0]?.count || 0);
246
- }
438
+ for (const [collection] of entries)
439
+ counts[collection] = 0;
440
+ if (entries.length === 0)
441
+ return counts;
442
+ // One round trip instead of one per collection — reads are serialized on a
443
+ // single connection, so N queries means N waits behind whatever else is
444
+ // reading.
445
+ const sql = entries
446
+ .map(([collection, schema]) => `SELECT '${collection.replace(/'/g, "''")}' as collection, ${dialect.countAsInteger} as count FROM ${schema.tableName}`)
447
+ .join(' UNION ALL ');
448
+ const rows = await all(sql);
449
+ for (const row of rows)
450
+ counts[row.collection] = Number(row.count || 0);
451
+ return counts;
452
+ }
453
+ export async function getRepoStatusCounts() {
454
+ const rows = await all(`SELECT status, ${dialect.countAsInteger} as count FROM _repos GROUP BY status`);
455
+ const counts = {};
456
+ for (const row of rows)
457
+ counts[row.status] = Number(row.count);
247
458
  return counts;
248
459
  }
460
+ export async function getDatabaseSize() {
461
+ if (dialect.supportsSequences) {
462
+ // DuckDB: pragma_database_size returns pre-formatted strings
463
+ const rows = await all('SELECT database_size, memory_usage, memory_limit FROM pragma_database_size()');
464
+ return rows[0] ?? {};
465
+ }
466
+ // SQLite: compute from page_count * page_size
467
+ const pages = await all('SELECT page_count FROM pragma_page_count()');
468
+ const sizes = await all('SELECT page_size FROM pragma_page_size()');
469
+ const pageCount = Number(pages[0]?.page_count ?? 0);
470
+ const pageSize = Number(sizes[0]?.page_size ?? 0);
471
+ const bytes = pageCount * pageSize;
472
+ const mib = (bytes / 1024 / 1024).toFixed(1);
473
+ return { database_size: `${mib} MiB`, memory_usage: 'N/A', memory_limit: 'N/A' };
474
+ }
475
+ export async function getLabelCount(val) {
476
+ const rows = await all(`SELECT ${dialect.countAsInteger} as count FROM _labels WHERE val = $1`, [
477
+ val,
478
+ ]);
479
+ return Number(rows[0]?.count || 0);
480
+ }
481
+ export async function deleteLabels(val) {
482
+ const count = await getLabelCount(val);
483
+ await run(`DELETE FROM _labels WHERE val = $1`, [val]);
484
+ return count;
485
+ }
486
+ export async function getRecentRecords(collection, limit) {
487
+ const schema = schemas.get(collection);
488
+ if (!schema)
489
+ return [];
490
+ 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]);
491
+ return rows;
492
+ }
249
493
  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');
494
+ let rows;
495
+ if (dialect.supportsSequences) {
496
+ // DuckDB: use duckdb_tables() for full DDL
497
+ rows = await all(`SELECT sql FROM duckdb_tables() ORDER BY table_name`);
498
+ }
499
+ else {
500
+ // SQLite: use sqlite_master, skip FTS shadow/internal tables
501
+ 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`);
502
+ }
503
+ // Normalize indentation and formatting
504
+ return rows
505
+ .map((r) => {
506
+ let sql = r.sql.trim();
507
+ // Remove quotes around column names (SQLite adds them for some columns)
508
+ sql = sql.replace(/\n\s*"(\w+)"/g, '\n$1');
509
+ // Ensure closing paren is on its own line
510
+ sql = sql.replace(/([^(\s])\)$/, '$1\n)');
511
+ // Normalize leading-comma columns added by ALTER TABLE into trailing commas
512
+ sql = sql.replace(/\n\s*,\s*/g, ',\n');
513
+ // Split into lines and re-indent consistently
514
+ const lines = sql.split('\n').map((l) => l.trim());
515
+ sql = lines
516
+ .map((line, i) => {
517
+ if (i === 0)
518
+ return line; // CREATE TABLE line
519
+ if (line.startsWith(')'))
520
+ return ')'; // closing paren at top level
521
+ return ' ' + line; // indent columns
522
+ })
523
+ .join('\n');
524
+ return sql + ';';
525
+ })
526
+ .join('\n\n');
252
527
  }
253
528
  export function buildInsertOp(collection, uri, cid, authorDid, record) {
254
529
  const schema = schemas.get(collection);
@@ -267,12 +542,12 @@ export function buildInsertOp(collection, uri, cid, authorDid, record) {
267
542
  else if (col.originalName.endsWith('__cid') && record[col.originalName.replace('__cid', '')]) {
268
543
  rawValue = record[col.originalName.replace('__cid', '')].cid;
269
544
  }
270
- colNames.push(col.name);
545
+ colNames.push(q(col.name));
271
546
  placeholders.push(`$${paramIdx++}`);
272
547
  if (rawValue === undefined || rawValue === null) {
273
548
  values.push(null);
274
549
  }
275
- else if (col.duckdbType === 'JSON') {
550
+ else if (col.isJson) {
276
551
  values.push(JSON.stringify(rawValue));
277
552
  }
278
553
  else {
@@ -287,34 +562,34 @@ export async function insertRecord(collection, uri, cid, authorDid, record) {
287
562
  if (!schema)
288
563
  throw new Error(`Unknown collection: ${collection}`);
289
564
  const { sql, params } = buildInsertOp(collection, uri, cid, authorDid, record);
290
- await run(sql, ...params);
565
+ await run(sql, params);
291
566
  // Insert child table rows
292
567
  for (const child of schema.children) {
293
568
  const items = record[child.fieldName];
294
569
  if (!Array.isArray(items))
295
570
  continue;
296
571
  // Delete existing child rows (handles INSERT OR REPLACE on main table)
297
- await run(`DELETE FROM ${child.tableName} WHERE parent_uri = $1`, uri);
572
+ await run(`DELETE FROM ${child.tableName} WHERE parent_uri = $1`, [uri]);
298
573
  for (const item of items) {
299
574
  const colNames = ['parent_uri', 'parent_did'];
300
575
  const placeholders = ['$1', '$2'];
301
576
  const values = [uri, authorDid];
302
577
  let idx = 3;
303
578
  for (const col of child.columns) {
304
- colNames.push(col.name);
579
+ colNames.push(q(col.name));
305
580
  placeholders.push(`$${idx++}`);
306
581
  const raw = item[col.originalName];
307
582
  if (raw === undefined || raw === null) {
308
583
  values.push(null);
309
584
  }
310
- else if (col.duckdbType === 'JSON') {
585
+ else if (col.isJson) {
311
586
  values.push(JSON.stringify(raw));
312
587
  }
313
588
  else {
314
589
  values.push(raw);
315
590
  }
316
591
  }
317
- await run(`INSERT INTO ${child.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`, ...values);
592
+ await run(`INSERT INTO ${child.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`, values);
318
593
  }
319
594
  }
320
595
  // Insert union branch rows
@@ -327,7 +602,7 @@ export async function insertRecord(collection, uri, cid, authorDid, record) {
327
602
  continue;
328
603
  // Delete existing branch rows (handles INSERT OR REPLACE)
329
604
  for (const b of union.branches) {
330
- await run(`DELETE FROM ${b.tableName} WHERE parent_uri = $1`, uri);
605
+ await run(`DELETE FROM ${b.tableName} WHERE parent_uri = $1`, [uri]);
331
606
  }
332
607
  if (branch.isArray && branch.arrayField) {
333
608
  // Array branch (e.g., embed.images) — insert one row per array item
@@ -340,20 +615,20 @@ export async function insertRecord(collection, uri, cid, authorDid, record) {
340
615
  const values = [uri, authorDid];
341
616
  let idx = 3;
342
617
  for (const col of branch.columns) {
343
- colNames.push(col.name);
618
+ colNames.push(q(col.name));
344
619
  placeholders.push(`$${idx++}`);
345
620
  const raw = item[col.originalName];
346
621
  if (raw === undefined || raw === null) {
347
622
  values.push(null);
348
623
  }
349
- else if (col.duckdbType === 'JSON') {
624
+ else if (col.isJson) {
350
625
  values.push(JSON.stringify(raw));
351
626
  }
352
627
  else {
353
628
  values.push(raw);
354
629
  }
355
630
  }
356
- await run(`INSERT INTO ${branch.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`, ...values);
631
+ await run(`INSERT INTO ${branch.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`, values);
357
632
  }
358
633
  }
359
634
  else {
@@ -364,22 +639,24 @@ export async function insertRecord(collection, uri, cid, authorDid, record) {
364
639
  const values = [uri, authorDid];
365
640
  let idx = 3;
366
641
  for (const col of branch.columns) {
367
- colNames.push(col.name);
642
+ colNames.push(q(col.name));
368
643
  placeholders.push(`$${idx++}`);
369
644
  const raw = branchData[col.originalName];
370
645
  if (raw === undefined || raw === null) {
371
646
  values.push(null);
372
647
  }
373
- else if (col.duckdbType === 'JSON') {
648
+ else if (col.isJson) {
374
649
  values.push(JSON.stringify(raw));
375
650
  }
376
651
  else {
377
652
  values.push(raw);
378
653
  }
379
654
  }
380
- await run(`INSERT INTO ${branch.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`, ...values);
655
+ await run(`INSERT INTO ${branch.tableName} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`, values);
381
656
  }
382
657
  }
658
+ // Incrementally update FTS index for this record
659
+ await updateFtsRecord(collection, uri);
383
660
  }
384
661
  /** Extract branch data from a union value, handling wrapper properties */
385
662
  function resolveBranchData(unionValue, branch) {
@@ -394,32 +671,41 @@ export async function deleteRecord(collection, uri) {
394
671
  const schema = schemas.get(collection);
395
672
  if (!schema)
396
673
  return;
674
+ // Remove from FTS index before deleting the record data
675
+ await deleteFtsRecord(collection, uri);
397
676
  for (const child of schema.children) {
398
- await run(`DELETE FROM ${child.tableName} WHERE parent_uri = $1`, uri);
677
+ await run(`DELETE FROM ${child.tableName} WHERE parent_uri = $1`, [uri]);
399
678
  }
400
679
  for (const union of schema.unions) {
401
680
  for (const branch of union.branches) {
402
- await run(`DELETE FROM ${branch.tableName} WHERE parent_uri = $1`, uri);
681
+ await run(`DELETE FROM ${branch.tableName} WHERE parent_uri = $1`, [uri]);
403
682
  }
404
683
  }
405
- await run(`DELETE FROM ${schema.tableName} WHERE uri = $1`, uri);
684
+ await run(`DELETE FROM ${schema.tableName} WHERE uri = $1`, [uri]);
406
685
  }
407
686
  export async function insertLabels(labels) {
408
687
  if (labels.length === 0)
409
688
  return;
410
689
  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);
690
+ // Skip if an active (non-negated, non-expired, not-superseded-by-negation) label already exists for this src+uri+val
691
+ 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
692
  if (!label.neg && existing.length > 0)
414
693
  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);
694
+ await run(`INSERT INTO _labels (src, uri, val, neg, cts, exp) VALUES ($1, $2, $3, $4, $5, $6)`, [
695
+ label.src,
696
+ label.uri,
697
+ label.val,
698
+ label.neg || false,
699
+ label.cts || new Date().toISOString(),
700
+ label.exp || null,
701
+ ]);
416
702
  }
417
703
  }
418
704
  export async function queryLabelsForUris(uris) {
419
705
  if (uris.length === 0)
420
706
  return new Map();
421
707
  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);
708
+ 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
709
  const result = new Map();
424
710
  for (const row of rows) {
425
711
  const key = row.uri;
@@ -429,9 +715,9 @@ export async function queryLabelsForUris(uris) {
429
715
  src: row.src,
430
716
  uri: row.uri,
431
717
  val: row.val,
432
- neg: row.neg,
718
+ neg: !!row.neg,
433
719
  cts: normalizeValue(row.cts),
434
- exp: row.exp ? String(row.exp) : null,
720
+ ...(row.exp ? { exp: String(row.exp) } : {}),
435
721
  });
436
722
  }
437
723
  return result;
@@ -452,246 +738,209 @@ export async function bulkInsertRecords(records) {
452
738
  if (!schema)
453
739
  continue;
454
740
  const stagingTable = `_staging_${collection.replace(/\./g, '_')}`;
455
- const allCols = ['uri', 'cid', 'did', 'indexed_at', ...schema.columns.map((c) => c.name)];
741
+ const allCols = ['uri', 'cid', 'did', 'indexed_at', ...schema.columns.map((c) => q(c.name))];
456
742
  const colDefs = [
457
743
  'uri TEXT',
458
744
  'cid TEXT',
459
745
  'did TEXT',
460
746
  'indexed_at TEXT',
461
- ...schema.columns.map((c) => `${c.name} ${c.duckdbType === 'TIMESTAMP' ? 'TEXT' : c.duckdbType}`),
747
+ ...schema.columns.map((c) => {
748
+ const t = c.sqlType;
749
+ // Use TEXT for timestamp columns in staging (will cast on merge)
750
+ return `${q(c.name)} ${t === 'TIMESTAMP' || t === 'TIMESTAMPTZ' ? 'TEXT' : t}`;
751
+ }),
462
752
  ];
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();
753
+ await port.execute(`DROP TABLE IF EXISTS ${stagingTable}`, []);
754
+ await port.execute(`CREATE TABLE ${stagingTable} (${colDefs.join(', ')})`, []);
755
+ const inserter = await port.createBulkInserter(stagingTable, allCols);
756
+ const now = new Date().toISOString();
757
+ for (const rec of recs) {
758
+ try {
759
+ const values = [rec.uri, rec.cid, rec.did, now];
760
+ for (const col of schema.columns) {
761
+ values.push(resolveColumnValue(col, rec.record));
762
+ }
763
+ inserter.append(values);
764
+ inserted++;
765
+ }
766
+ catch {
767
+ // Skip bad records
768
+ }
769
+ }
770
+ await inserter.close();
771
+ // Merge into target, filtering rows that would violate NOT NULL
772
+ const selectCols = allCols.map((name) => {
773
+ const col = schema.columns.find((c) => q(c.name) === name);
774
+ if (name === 'indexed_at' || (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ'))) {
775
+ return `${dialect.tryCastTimestamp(name)} AS ${name}`;
776
+ }
777
+ return name;
778
+ });
779
+ const notNullChecks = ['uri IS NOT NULL', 'did IS NOT NULL'];
780
+ for (const col of schema.columns) {
781
+ if (col.notNull) {
782
+ if (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ') {
783
+ notNullChecks.push(`${dialect.tryCastTimestamp(q(col.name))} IS NOT NULL`);
784
+ }
785
+ else {
786
+ notNullChecks.push(`${q(col.name)} IS NOT NULL`);
787
+ }
788
+ }
789
+ }
790
+ const whereClause = notNullChecks.length ? ` WHERE ${notNullChecks.join(' AND ')}` : '';
791
+ await port.execute(`INSERT OR REPLACE INTO ${schema.tableName} (${allCols.join(', ')}) SELECT ${selectCols.join(', ')} FROM ${stagingTable}${whereClause}`, []);
792
+ await port.execute(`DROP TABLE ${stagingTable}`, []);
793
+ // Populate child tables
794
+ for (const child of schema.children) {
795
+ const childStagingTable = `_staging_${collection.replace(/\./g, '_')}__${child.fieldName}`;
796
+ const childColDefs = [
797
+ 'parent_uri TEXT',
798
+ 'parent_did TEXT',
799
+ ...child.columns.map((c) => {
800
+ const t = c.sqlType;
801
+ return `${q(c.name)} ${t === 'TIMESTAMP' || t === 'TIMESTAMPTZ' ? 'TEXT' : t}`;
802
+ }),
803
+ ];
804
+ const childAllCols = ['parent_uri', 'parent_did', ...child.columns.map((c) => q(c.name))];
805
+ await port.execute(`DROP TABLE IF EXISTS ${childStagingTable}`, []);
806
+ await port.execute(`CREATE TABLE ${childStagingTable} (${childColDefs.join(', ')})`, []);
807
+ const childInserter = await port.createBulkInserter(childStagingTable, childAllCols);
469
808
  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));
809
+ const items = rec.record[child.fieldName];
810
+ if (!Array.isArray(items))
811
+ continue;
812
+ for (const item of items) {
813
+ try {
814
+ const values = [rec.uri, rec.did];
815
+ for (const col of child.columns) {
816
+ values.push(resolveRawColumnValue(col, item));
497
817
  }
818
+ childInserter.append(values);
819
+ }
820
+ catch {
821
+ // Skip bad items
498
822
  }
499
- appender.endRow();
500
- inserted++;
501
- }
502
- catch {
503
- // Skip bad records
504
823
  }
505
824
  }
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}`;
825
+ await childInserter.close();
826
+ // Delete existing child rows for these URIs, then merge staging
827
+ const uriPlaceholders = recs.map((_, i) => `$${i + 1}`).join(',');
828
+ await port.execute(`DELETE FROM ${child.tableName} WHERE parent_uri IN (${uriPlaceholders})`, recs.map((r) => r.uri));
829
+ const childSelectCols = childAllCols.map((name) => {
830
+ const col = child.columns.find((c) => q(c.name) === name);
831
+ if (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ')) {
832
+ return `${dialect.tryCastTimestamp(name)} AS ${name}`;
513
833
  }
514
834
  return name;
515
835
  });
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 = [
836
+ await port.execute(`INSERT INTO ${child.tableName} (${childAllCols.join(', ')}) SELECT ${childSelectCols.join(', ')} FROM ${childStagingTable} WHERE parent_uri IS NOT NULL`, []);
837
+ await port.execute(`DROP TABLE ${childStagingTable}`, []);
838
+ }
839
+ // Populate union branch tables
840
+ for (const union of schema.unions) {
841
+ for (const branch of union.branches) {
842
+ const branchStagingTable = `_staging_${collection.replace(/\./g, '_')}__${toSnakeCase(union.fieldName)}_${branch.branchName}`;
843
+ const branchColDefs = [
535
844
  'parent_uri TEXT',
536
845
  'parent_did TEXT',
537
- ...child.columns.map((c) => `${c.name} ${c.duckdbType === 'TIMESTAMP' ? 'TEXT' : c.duckdbType}`),
846
+ ...branch.columns.map((c) => {
847
+ const t = c.sqlType;
848
+ return `${q(c.name)} ${t === 'TIMESTAMP' || t === 'TIMESTAMPTZ' ? 'TEXT' : t}`;
849
+ }),
538
850
  ];
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);
851
+ const branchAllCols = ['parent_uri', 'parent_did', ...branch.columns.map((c) => q(c.name))];
852
+ await port.execute(`DROP TABLE IF EXISTS ${branchStagingTable}`, []);
853
+ await port.execute(`CREATE TABLE ${branchStagingTable} (${branchColDefs.join(', ')})`, []);
854
+ const branchInserter = await port.createBulkInserter(branchStagingTable, branchAllCols);
543
855
  for (const rec of recs) {
544
- const items = rec.record[child.fieldName];
545
- if (!Array.isArray(items))
856
+ const unionValue = rec.record[union.fieldName];
857
+ if (!unionValue || typeof unionValue !== 'object')
546
858
  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)
859
+ if (unionValue.$type !== branch.type)
860
+ continue;
861
+ if (branch.isArray && branch.arrayField) {
862
+ const items = resolveBranchData(unionValue, branch)[branch.arrayField];
863
+ if (!Array.isArray(items))
610
864
  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 {
865
+ for (const item of items) {
645
866
  try {
646
- const branchData = resolveBranchData(unionValue, branch);
647
- branchAppender.appendVarchar(rec.uri);
648
- branchAppender.appendVarchar(rec.did);
867
+ const values = [rec.uri, rec.did];
649
868
  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
- }
869
+ values.push(resolveRawColumnValue(col, item));
666
870
  }
667
- branchAppender.endRow();
871
+ branchInserter.append(values);
668
872
  }
669
873
  catch {
670
- // Skip bad records
874
+ // Skip bad items
671
875
  }
672
876
  }
673
877
  }
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}`);
878
+ else {
879
+ try {
880
+ const branchData = resolveBranchData(unionValue, branch);
881
+ const values = [rec.uri, rec.did];
882
+ for (const col of branch.columns) {
883
+ values.push(resolveRawColumnValue(col, branchData));
884
+ }
885
+ branchInserter.append(values);
886
+ }
887
+ catch {
888
+ // Skip bad records
889
+ }
890
+ }
689
891
  }
892
+ await branchInserter.close();
893
+ // Delete existing branch rows for these URIs, then merge staging
894
+ const uriPlaceholders = recs.map((_, i) => `$${i + 1}`).join(',');
895
+ await port.execute(`DELETE FROM ${branch.tableName} WHERE parent_uri IN (${uriPlaceholders})`, recs.map((r) => r.uri));
896
+ const branchSelectCols = branchAllCols.map((name) => {
897
+ const col = branch.columns.find((c) => q(c.name) === name);
898
+ if (col && (col.sqlType === 'TIMESTAMP' || col.sqlType === 'TIMESTAMPTZ')) {
899
+ return `${dialect.tryCastTimestamp(name)} AS ${name}`;
900
+ }
901
+ return name;
902
+ });
903
+ await port.execute(`INSERT INTO ${branch.tableName} (${branchAllCols.join(', ')}) SELECT ${branchSelectCols.join(', ')} FROM ${branchStagingTable} WHERE parent_uri IS NOT NULL`, []);
904
+ await port.execute(`DROP TABLE ${branchStagingTable}`, []);
690
905
  }
691
- });
906
+ }
692
907
  }
693
908
  return inserted;
694
909
  }
910
+ /** Extract a column value from a record, handling strongRef expansion and type coercion for bulk insert */
911
+ function resolveColumnValue(col, record) {
912
+ let rawValue = record[col.originalName];
913
+ if (rawValue && typeof rawValue === 'object' && col.name.endsWith('_uri') && col.isRef) {
914
+ rawValue = rawValue.uri;
915
+ }
916
+ else if (col.originalName.endsWith('__cid') && record[col.originalName.replace('__cid', '')]) {
917
+ rawValue = record[col.originalName.replace('__cid', '')].cid;
918
+ }
919
+ return coerceValue(col.sqlType, rawValue);
920
+ }
921
+ /** Extract a raw column value from a data object and coerce for bulk insert */
922
+ function resolveRawColumnValue(col, data) {
923
+ return coerceValue(col.sqlType, data[col.originalName]);
924
+ }
925
+ /** Coerce a value to the appropriate type for insertion */
926
+ function coerceValue(sqlType, rawValue) {
927
+ if (rawValue === undefined || rawValue === null)
928
+ return null;
929
+ // Objects and arrays always need JSON stringification regardless of sqlType
930
+ // (on SQLite, JSON columns map to TEXT but still need stringification)
931
+ if (typeof rawValue === 'object' && !(rawValue instanceof Uint8Array)) {
932
+ return JSON.stringify(rawValue);
933
+ }
934
+ if (sqlType === 'JSON' || sqlType === 'TEXT') {
935
+ return String(rawValue);
936
+ }
937
+ if (sqlType === 'INTEGER' || sqlType === 'BIGINT') {
938
+ return typeof rawValue === 'number' ? rawValue : parseInt(rawValue);
939
+ }
940
+ if (sqlType === 'BOOLEAN')
941
+ return !!rawValue;
942
+ return String(rawValue);
943
+ }
695
944
  export async function queryRecords(collection, opts = {}) {
696
945
  const schema = schemas.get(collection);
697
946
  if (!schema)
@@ -734,7 +983,7 @@ export async function queryRecords(collection, opts = {}) {
734
983
  sql += ' AND ' + conditions.join(' AND ');
735
984
  sql += ` ORDER BY t.${sortName} ${order.toUpperCase()}, t.cid ${order.toUpperCase()} LIMIT $${paramIdx++}`;
736
985
  params.push(limit + 1); // fetch one extra for cursor
737
- const rows = await all(sql, ...params);
986
+ const rows = await all(sql, params);
738
987
  const hasMore = rows.length > limit;
739
988
  if (hasMore)
740
989
  rows.pop();
@@ -774,7 +1023,7 @@ export async function queryRecords(collection, opts = {}) {
774
1023
  }
775
1024
  export async function getRecordByUri(uri) {
776
1025
  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);
1026
+ 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
1027
  if (rows.length > 0) {
779
1028
  const row = rows[0];
780
1029
  if (schema.children.length > 0) {
@@ -811,7 +1060,7 @@ export async function getRecordsByUris(collection, uris) {
811
1060
  if (!schema)
812
1061
  return [];
813
1062
  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);
1063
+ 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
1064
  // Batch-fetch child rows for all URIs
816
1065
  const childData = new Map();
817
1066
  for (const child of schema.children) {
@@ -839,6 +1088,19 @@ export async function getRecordsByUris(collection, uris) {
839
1088
  const byUri = new Map(rows.map((r) => [r.uri, r]));
840
1089
  return uris.map((u) => byUri.get(u)).filter(Boolean);
841
1090
  }
1091
+ /** Fetch records by URIs and return as a shaped Map keyed by URI. */
1092
+ export async function getRecordsMap(collection, uris) {
1093
+ if (uris.length === 0)
1094
+ return new Map();
1095
+ const records = await getRecordsByUris(collection, uris);
1096
+ const map = new Map();
1097
+ for (const r of records) {
1098
+ const shaped = reshapeRow(r, r?.__childData, r?.__unionData);
1099
+ if (shaped)
1100
+ map.set(shaped.uri, shaped);
1101
+ }
1102
+ return map;
1103
+ }
842
1104
  /**
843
1105
  * Multi-phase search across any collection's records.
844
1106
  *
@@ -859,8 +1121,8 @@ export async function searchRecords(collection, query, opts = {}) {
859
1121
  if (!schema)
860
1122
  throw new Error(`Unknown collection: ${collection}`);
861
1123
  const elapsed = timer();
862
- const { limit = 20, cursor, fuzzy = true } = opts;
863
- const textCols = schema.columns.filter((c) => c.duckdbType === 'TEXT');
1124
+ const { limit = 20, fuzzy = true } = opts;
1125
+ const textCols = schema.columns.filter((c) => c.sqlType === 'TEXT');
864
1126
  // Also check if FTS has indexed any columns (including derived JSON columns)
865
1127
  const ftsSearchCols = getSearchColumns(collection);
866
1128
  if (textCols.length === 0 && ftsSearchCols.length === 0) {
@@ -868,149 +1130,67 @@ export async function searchRecords(collection, query, opts = {}) {
868
1130
  }
869
1131
  // FTS shadow table name (dots replaced with underscores)
870
1132
  const safeName = '_fts_' + collection.replace(/\./g, '_');
871
- const ftsSchema = `fts_main_${safeName}`;
872
1133
  const phaseErrors = [];
873
1134
  const phasesUsed = [];
874
- // Phase 1: BM25 ranked search on FTS shadow table
1135
+ // Phase 1: BM25 ranked search via SearchPort
875
1136
  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);
1137
+ const sp = getSearchPort();
1138
+ if (sp)
1139
+ try {
1140
+ const ftsQuery = stripStopWords(query);
1141
+ const ftsSearchColNames = getSearchColumns(collection);
1142
+ // Get ranked URIs from the search port
1143
+ const hits = await sp.search(safeName, ftsQuery, ftsSearchColNames, limit + 1, 0);
1144
+ if (hits.length > 0) {
1145
+ const uriList = hits.map((h) => h.uri);
1146
+ const scoreMap = new Map(hits.map((h) => [h.uri, h.score]));
1147
+ // Fetch full records for matched URIs
1148
+ const placeholders = uriList.map((_, i) => `$${i + 1}`).join(', ');
1149
+ const rows = await all(`SELECT m.* FROM ${schema.tableName} m
1150
+ LEFT JOIN _repos r ON m.did = r.did
1151
+ WHERE m.uri IN (${placeholders})
1152
+ AND (r.status IS NULL OR r.status != 'takendown')`, uriList);
1153
+ // Re-attach scores and sort
1154
+ bm25Results = rows
1155
+ .map((r) => ({ ...r, score: scoreMap.get(r.uri) ?? 0 }))
1156
+ .sort((a, b) => b.score - a.score);
896
1157
  }
1158
+ phasesUsed.push('bm25');
1159
+ }
1160
+ catch (err) {
1161
+ phaseErrors.push(`bm25: ${err.message}`);
897
1162
  }
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
1163
  const bm25Count = bm25Results.length;
907
1164
  const hasMore = bm25Results.length > limit;
908
1165
  if (hasMore)
909
1166
  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
1167
  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)
1168
+ // Phase 2: Fuzzy fallback for typo tolerance (if still under limit)
1169
+ // Only available on dialects with jaro_winkler_similarity (DuckDB)
991
1170
  let fuzzyCount = 0;
992
- if (fuzzy && bm25Results.length < limit) {
1171
+ if (fuzzy && dialect.jaroWinklerSimilarity && bm25Results.length < limit) {
993
1172
  const remaining = limit - bm25Results.length;
1173
+ const jwFn = dialect.jaroWinklerSimilarity;
994
1174
  const simExprs = [
995
- ...textCols.map((c) => `jaro_winkler_similarity(lower(t.${c.name}), lower($1))`),
996
- `jaro_winkler_similarity(lower(r.handle), lower($1))`,
1175
+ ...textCols.map((c) => `${jwFn}(lower(t.${q(c.name)}), lower($1))`),
1176
+ `${jwFn}(lower(r.handle), lower($1))`,
997
1177
  ];
998
1178
  // Include child table TEXT columns via correlated subquery
999
1179
  for (const child of schema.children) {
1000
1180
  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)`);
1181
+ if (col.sqlType === 'TEXT') {
1182
+ 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
1183
  }
1004
1184
  }
1005
1185
  }
1006
- const greatestExpr = `GREATEST(${simExprs.join(', ')})`;
1186
+ const greatestExpr = dialect.greatest(simExprs);
1007
1187
  const fuzzySQL = `SELECT t.*, ${greatestExpr} AS fuzzy_score
1008
1188
  FROM ${schema.tableName} t LEFT JOIN _repos r ON t.did = r.did
1009
1189
  WHERE ${greatestExpr} >= 0.8
1010
1190
  ORDER BY fuzzy_score DESC
1011
1191
  LIMIT $2`;
1012
1192
  try {
1013
- const fuzzyRows = await all(fuzzySQL, query, remaining + existingUris.size);
1193
+ const fuzzyRows = await all(fuzzySQL, [query, remaining + existingUris.size]);
1014
1194
  phasesUsed.push('fuzzy');
1015
1195
  for (const row of fuzzyRows) {
1016
1196
  if (bm25Results.length >= limit)
@@ -1025,16 +1205,17 @@ export async function searchRecords(collection, query, opts = {}) {
1025
1205
  phaseErrors.push(`fuzzy: ${err.message}`);
1026
1206
  }
1027
1207
  }
1028
- // Remove score columns from results
1029
- const records = bm25Results.map(({ score: _score, fuzzy_score: _fuzzy_score, ...rest }) => rest);
1208
+ // Remove score columns and reshape into Row<T> with value
1209
+ const rawRecords = bm25Results.map(({ score: _score, fuzzy_score: _fuzzy_score, ...rest }) => rest);
1210
+ const records = rawRecords
1211
+ .map((r) => reshapeRow(r, r?.__childData, r?.__unionData))
1212
+ .filter((r) => r != null);
1030
1213
  const lastRow = bm25Results[bm25Results.length - 1];
1031
1214
  const nextCursor = hasMore && lastRow?.score != null ? packCursor(lastRow.score, lastRow.cid) : undefined;
1032
1215
  emit('search', 'query', {
1033
1216
  collection,
1034
1217
  query,
1035
1218
  bm25_count: bm25Count > limit ? bm25Count - 1 : bm25Count,
1036
- exact_count: exactMatchResults.length,
1037
- recent_count: recentCount,
1038
1219
  fuzzy_count: fuzzyCount,
1039
1220
  total_results: records.length,
1040
1221
  duration_ms: elapsed(),
@@ -1045,10 +1226,13 @@ export async function searchRecords(collection, query, opts = {}) {
1045
1226
  }
1046
1227
  // Raw SQL for script feeds
1047
1228
  export async function querySQL(sql, params = []) {
1048
- return all(sql, ...params);
1229
+ return all(sql, params);
1049
1230
  }
1050
- export async function runSQL(sql, ...params) {
1051
- return run(sql, ...params);
1231
+ export async function runSQL(sql, params = []) {
1232
+ return run(sql, params);
1233
+ }
1234
+ export async function createBulkInserterSQL(table, columns, options) {
1235
+ return port.createBulkInserter(table, columns, options);
1052
1236
  }
1053
1237
  export function getSchema(collection) {
1054
1238
  return schemas.get(collection);
@@ -1057,7 +1241,9 @@ export async function countByField(collection, field, value) {
1057
1241
  const schema = schemas.get(collection);
1058
1242
  if (!schema)
1059
1243
  return 0;
1060
- const rows = await all(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE ${field} = $1`, value);
1244
+ const rows = await all(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE ${field} = $1`, [
1245
+ value,
1246
+ ]);
1061
1247
  return Number(rows[0]?.count || 0);
1062
1248
  }
1063
1249
  export async function countByFieldBatch(collection, field, values) {
@@ -1067,7 +1253,7 @@ export async function countByFieldBatch(collection, field, values) {
1067
1253
  if (!schema)
1068
1254
  return new Map();
1069
1255
  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);
1256
+ const rows = await all(`SELECT ${field}, COUNT(*) as count FROM ${schema.tableName} WHERE ${field} IN (${placeholders}) GROUP BY ${field}`, values);
1071
1257
  const result = new Map();
1072
1258
  for (const row of rows) {
1073
1259
  result.set(row[field], Number(row.count));
@@ -1078,7 +1264,7 @@ export async function findByField(collection, field, value) {
1078
1264
  const schema = schemas.get(collection);
1079
1265
  if (!schema)
1080
1266
  return null;
1081
- const rows = await all(`SELECT * FROM ${schema.tableName} WHERE ${field} = $1 LIMIT 1`, value);
1267
+ const rows = await all(`SELECT * FROM ${schema.tableName} WHERE ${field} = $1 LIMIT 1`, [value]);
1082
1268
  return rows[0] || null;
1083
1269
  }
1084
1270
  export async function findByFieldBatch(collection, field, values) {
@@ -1088,7 +1274,7 @@ export async function findByFieldBatch(collection, field, values) {
1088
1274
  if (!schema)
1089
1275
  return new Map();
1090
1276
  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);
1277
+ 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
1278
  // Attach child data if this collection has decomposed arrays
1093
1279
  if (schema.children.length > 0 && rows.length > 0) {
1094
1280
  const uris = rows.map((r) => r.uri);
@@ -1098,7 +1284,6 @@ export async function findByFieldBatch(collection, field, values) {
1098
1284
  childData.set(child.fieldName, childRows);
1099
1285
  }
1100
1286
  for (const row of rows) {
1101
- ;
1102
1287
  row.__childData = childData;
1103
1288
  }
1104
1289
  }
@@ -1129,7 +1314,7 @@ export async function findUriByFields(collection, conditions) {
1129
1314
  return null;
1130
1315
  const where = conditions.map((c, i) => `${c.field} = $${i + 1}`).join(' AND ');
1131
1316
  const params = conditions.map((c) => c.value);
1132
- const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE ${where} LIMIT 1`, ...params);
1317
+ const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE ${where} LIMIT 1`, params);
1133
1318
  return rows[0]?.uri || null;
1134
1319
  }
1135
1320
  const ENVELOPE_KEYS = new Set(['uri', 'cid', 'did', 'handle', 'indexed_at']);
@@ -1145,7 +1330,7 @@ export async function getChildRows(childTableName, parentUris) {
1145
1330
  if (parentUris.length === 0)
1146
1331
  return new Map();
1147
1332
  const placeholders = parentUris.map((_, i) => `$${i + 1}`).join(',');
1148
- const rows = await all(`SELECT * FROM ${childTableName} WHERE parent_uri IN (${placeholders})`, ...parentUris);
1333
+ const rows = await all(`SELECT * FROM ${childTableName} WHERE parent_uri IN (${placeholders})`, parentUris);
1149
1334
  const result = new Map();
1150
1335
  for (const row of rows) {
1151
1336
  const key = row.parent_uri;
@@ -1167,7 +1352,7 @@ export function reshapeRow(row, childData, unionData) {
1167
1352
  if (schema) {
1168
1353
  for (const col of schema.columns) {
1169
1354
  nameMap.set(col.name, col.originalName);
1170
- if (col.duckdbType === 'JSON')
1355
+ if (col.isJson)
1171
1356
  jsonCols.add(col.name);
1172
1357
  }
1173
1358
  }
@@ -1273,15 +1458,17 @@ export function unpackCursor(cursor) {
1273
1458
  }
1274
1459
  }
1275
1460
  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}/%`);
1461
+ return all(`SELECT * FROM _labels WHERE uri LIKE $1 AND neg = false AND (exp IS NULL OR exp > CURRENT_TIMESTAMP)`, [
1462
+ `at://${did}/%`,
1463
+ ]);
1277
1464
  }
1278
1465
  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);
1466
+ 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
1467
  }
1281
1468
  export async function getAccountRecordCount(did) {
1282
1469
  let total = 0;
1283
1470
  for (const [, schema] of schemas) {
1284
- const rows = await all(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE did = $1`, did);
1471
+ const rows = await all(`SELECT COUNT(*) as count FROM ${schema.tableName} WHERE did = $1`, [did]);
1285
1472
  total += Number(rows[0]?.count || 0);
1286
1473
  }
1287
1474
  return total;
@@ -1289,17 +1476,17 @@ export async function getAccountRecordCount(did) {
1289
1476
  export async function getAllRecordUrisForDid(did) {
1290
1477
  const uris = [];
1291
1478
  for (const [, schema] of schemas) {
1292
- const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE did = $1`, did);
1479
+ const rows = await all(`SELECT uri FROM ${schema.tableName} WHERE did = $1`, [did]);
1293
1480
  uris.push(...rows.map((r) => r.uri));
1294
1481
  }
1295
1482
  return uris;
1296
1483
  }
1297
1484
  export async function isTakendownDid(did) {
1298
- const rows = await all(`SELECT 1 FROM _repos WHERE did = $1 AND status = 'takendown' LIMIT 1`, did);
1485
+ const rows = await all(`SELECT 1 FROM _repos WHERE did = $1 AND status = 'takendown' LIMIT 1`, [did]);
1299
1486
  return rows.length > 0;
1300
1487
  }
1301
1488
  export async function getPreferences(did) {
1302
- const rows = await all(`SELECT key, value FROM _preferences WHERE did = $1`, did);
1489
+ const rows = await all(`SELECT key, value FROM _preferences WHERE did = $1`, [did]);
1303
1490
  const prefs = {};
1304
1491
  for (const row of rows) {
1305
1492
  try {
@@ -1312,50 +1499,66 @@ export async function getPreferences(did) {
1312
1499
  return prefs;
1313
1500
  }
1314
1501
  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());
1502
+ await run(`INSERT OR REPLACE INTO _preferences (did, key, value, updated_at) VALUES ($1, $2, $3, $4)`, [
1503
+ did,
1504
+ key,
1505
+ JSON.stringify(value),
1506
+ new Date().toISOString(),
1507
+ ]);
1508
+ }
1509
+ export async function resolveHandleToDid(handle) {
1510
+ if (handle.startsWith('did:'))
1511
+ return handle;
1512
+ const rows = await all(`SELECT did FROM _repos WHERE handle = $1 LIMIT 1`, [handle]);
1513
+ return rows[0]?.did ?? null;
1316
1514
  }
1317
1515
  export async function filterTakendownDids(dids) {
1318
1516
  if (dids.length === 0)
1319
1517
  return new Set();
1320
1518
  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);
1519
+ const rows = await all(`SELECT did FROM _repos WHERE did IN (${placeholders}) AND status = 'takendown'`, dids);
1322
1520
  return new Set(rows.map((r) => r.did));
1323
1521
  }
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
- }
1522
+ export async function insertReport(report) {
1523
+ const createdAt = new Date().toISOString();
1524
+ 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]);
1525
+ return { id: rows[0].id };
1526
+ }
1527
+ export async function queryReports(opts) {
1528
+ const conditions = [];
1529
+ const params = [];
1530
+ let idx = 1;
1531
+ if (opts.status) {
1532
+ conditions.push(`r.status = $${idx++}`);
1533
+ params.push(opts.status);
1360
1534
  }
1535
+ if (opts.label) {
1536
+ conditions.push(`r.label = $${idx++}`);
1537
+ params.push(opts.label);
1538
+ }
1539
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
1540
+ const limit = opts.limit || 50;
1541
+ const offset = opts.offset || 0;
1542
+ const countRows = await all(`SELECT ${dialect.countAsInteger} as count FROM _reports r ${where}`, params);
1543
+ const total = Number(countRows[0]?.count || 0);
1544
+ 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]);
1545
+ return { reports: rows, total };
1546
+ }
1547
+ export async function resolveReport(id, action, resolvedBy) {
1548
+ const rows = await all(`SELECT subject_uri, label, status FROM _reports WHERE id = $1`, [id]);
1549
+ if (!rows.length)
1550
+ return null;
1551
+ if (rows[0].status !== 'open')
1552
+ return null;
1553
+ await run(`UPDATE _reports SET status = $1, resolved_by = $2, resolved_at = $3 WHERE id = $4`, [
1554
+ action,
1555
+ resolvedBy,
1556
+ new Date().toISOString(),
1557
+ id,
1558
+ ]);
1559
+ return { subjectUri: rows[0].subject_uri, label: rows[0].label };
1560
+ }
1561
+ export async function getOpenReportCount() {
1562
+ const rows = await all(`SELECT ${dialect.countAsInteger} as count FROM _reports WHERE status = 'open'`);
1563
+ return Number(rows[0]?.count || 0);
1361
1564
  }