@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.
- package/dist/adapter.d.ts +19 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +107 -0
- package/dist/backfill.d.ts +60 -1
- package/dist/backfill.d.ts.map +1 -1
- package/dist/backfill.js +167 -33
- package/dist/car.d.ts +59 -1
- package/dist/car.d.ts.map +1 -1
- package/dist/car.js +179 -7
- package/dist/cbor.d.ts +37 -0
- package/dist/cbor.d.ts.map +1 -1
- package/dist/cbor.js +36 -3
- package/dist/cid.d.ts +37 -0
- package/dist/cid.d.ts.map +1 -1
- package/dist/cid.js +38 -3
- package/dist/cli.js +243 -996
- package/dist/config.d.ts +12 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +36 -9
- package/dist/database/adapter-factory.d.ts +6 -0
- package/dist/database/adapter-factory.d.ts.map +1 -0
- package/dist/database/adapter-factory.js +20 -0
- package/dist/database/adapters/duckdb-search.d.ts +12 -0
- package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
- package/dist/database/adapters/duckdb-search.js +27 -0
- package/dist/database/adapters/duckdb.d.ts +25 -0
- package/dist/database/adapters/duckdb.d.ts.map +1 -0
- package/dist/database/adapters/duckdb.js +161 -0
- package/dist/database/adapters/sqlite-search.d.ts +23 -0
- package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
- package/dist/database/adapters/sqlite-search.js +74 -0
- package/dist/database/adapters/sqlite.d.ts +18 -0
- package/dist/database/adapters/sqlite.d.ts.map +1 -0
- package/dist/database/adapters/sqlite.js +88 -0
- package/dist/{db.d.ts → database/db.d.ts} +56 -6
- package/dist/database/db.d.ts.map +1 -0
- package/dist/{db.js → database/db.js} +719 -549
- package/dist/database/dialect.d.ts +45 -0
- package/dist/database/dialect.d.ts.map +1 -0
- package/dist/database/dialect.js +72 -0
- package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
- package/dist/database/fts.d.ts.map +1 -0
- package/dist/{fts.js → database/fts.js} +116 -32
- package/dist/database/index.d.ts +7 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +6 -0
- package/dist/database/ports.d.ts +50 -0
- package/dist/database/ports.d.ts.map +1 -0
- package/dist/database/ports.js +1 -0
- package/dist/{schema.d.ts → database/schema.d.ts} +14 -3
- package/dist/database/schema.d.ts.map +1 -0
- package/dist/{schema.js → database/schema.js} +81 -41
- package/dist/dev-entry.d.ts +8 -0
- package/dist/dev-entry.d.ts.map +1 -0
- package/dist/dev-entry.js +111 -0
- package/dist/feeds.d.ts +12 -8
- package/dist/feeds.d.ts.map +1 -1
- package/dist/feeds.js +45 -6
- package/dist/hooks.d.ts +43 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +102 -0
- package/dist/hydrate.d.ts +6 -5
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/hydrate.js +4 -16
- package/dist/indexer.d.ts +22 -0
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +80 -8
- package/dist/labels.d.ts +36 -0
- package/dist/labels.d.ts.map +1 -1
- package/dist/labels.js +71 -6
- package/dist/lexicon-resolve.d.ts.map +1 -1
- package/dist/lexicon-resolve.js +27 -112
- package/dist/lexicons/com/atproto/label/defs.json +75 -0
- package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
- package/dist/lexicons/com/atproto/repo/strongRef.json +24 -0
- package/dist/lexicons/dev/hatk/createRecord.json +40 -0
- package/dist/lexicons/dev/hatk/createReport.json +48 -0
- package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
- package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
- package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
- package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
- package/dist/lexicons/dev/hatk/getFeed.json +30 -0
- package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
- package/dist/lexicons/dev/hatk/getRecord.json +26 -0
- package/dist/lexicons/dev/hatk/getRecords.json +32 -0
- package/dist/lexicons/dev/hatk/putPreference.json +28 -0
- package/dist/lexicons/dev/hatk/putRecord.json +41 -0
- package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
- package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
- package/dist/logger.d.ts +29 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +29 -0
- package/dist/main.js +126 -67
- package/dist/mst.d.ts +18 -1
- package/dist/mst.d.ts.map +1 -1
- package/dist/mst.js +19 -8
- package/dist/oauth/db.d.ts +3 -1
- package/dist/oauth/db.d.ts.map +1 -1
- package/dist/oauth/db.js +48 -19
- package/dist/oauth/server.d.ts +24 -0
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +198 -22
- package/dist/oauth/session.d.ts +11 -0
- package/dist/oauth/session.d.ts.map +1 -0
- package/dist/oauth/session.js +65 -0
- package/dist/opengraph.d.ts +10 -0
- package/dist/opengraph.d.ts.map +1 -1
- package/dist/opengraph.js +73 -39
- package/dist/pds-proxy.d.ts +42 -0
- package/dist/pds-proxy.d.ts.map +1 -0
- package/dist/pds-proxy.js +207 -0
- package/dist/renderer.d.ts +27 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +46 -0
- package/dist/resolve-hatk.d.ts +6 -0
- package/dist/resolve-hatk.d.ts.map +1 -0
- package/dist/resolve-hatk.js +20 -0
- package/dist/response.d.ts +16 -0
- package/dist/response.d.ts.map +1 -0
- package/dist/response.js +69 -0
- package/dist/scanner.d.ts +21 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +88 -0
- package/dist/seed.d.ts +19 -0
- package/dist/seed.d.ts.map +1 -1
- package/dist/seed.js +43 -4
- package/dist/server-init.d.ts +8 -0
- package/dist/server-init.d.ts.map +1 -0
- package/dist/server-init.js +62 -0
- package/dist/server.d.ts +26 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +601 -635
- package/dist/setup.d.ts +28 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +50 -3
- package/dist/templates/feed.tpl +14 -0
- package/dist/templates/hook.tpl +5 -0
- package/dist/templates/label.tpl +15 -0
- package/dist/templates/og.tpl +17 -0
- package/dist/templates/seed.tpl +11 -0
- package/dist/templates/setup.tpl +5 -0
- package/dist/templates/test-feed.tpl +19 -0
- package/dist/templates/test-xrpc.tpl +19 -0
- package/dist/templates/xrpc.tpl +41 -0
- package/dist/test.d.ts +1 -1
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +38 -32
- package/dist/views.js +1 -1
- package/dist/vite-plugin.d.ts +1 -1
- package/dist/vite-plugin.d.ts.map +1 -1
- package/dist/vite-plugin.js +254 -66
- package/dist/xrpc.d.ts +60 -10
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +155 -39
- package/package.json +15 -7
- package/public/admin.html +133 -54
- package/dist/db.d.ts.map +0 -1
- package/dist/fts.d.ts.map +0 -1
- package/dist/oauth/hooks.d.ts +0 -10
- package/dist/oauth/hooks.d.ts.map +0 -1
- package/dist/oauth/hooks.js +0 -40
- package/dist/schema.d.ts.map +0 -1
- package/dist/test-browser.d.ts +0 -14
- package/dist/test-browser.d.ts.map +0 -1
- package/dist/test-browser.js +0 -26
|
@@ -1,136 +1,59 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
let
|
|
7
|
-
let
|
|
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
|
|
11
|
-
|
|
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
|
|
39
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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,
|
|
80
|
-
return
|
|
18
|
+
async function run(sql, params = []) {
|
|
19
|
+
return port.execute(sql, params);
|
|
81
20
|
}
|
|
82
21
|
export async function runBatch(operations) {
|
|
83
|
-
|
|
84
|
-
|
|
22
|
+
await port.beginTransaction();
|
|
23
|
+
try {
|
|
85
24
|
for (const op of operations) {
|
|
86
25
|
try {
|
|
87
|
-
|
|
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
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
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,
|
|
114
|
-
return
|
|
38
|
+
async function all(sql, params = []) {
|
|
39
|
+
return port.query(sql, params);
|
|
115
40
|
}
|
|
116
|
-
export async function initDatabase(dbPath, tableSchemas, ddlStatements) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
167
|
-
updated_at
|
|
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
|
-
|
|
172
|
-
|
|
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`,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
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.
|
|
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,
|
|
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.
|
|
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(', ')})`,
|
|
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.
|
|
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(', ')})`,
|
|
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.
|
|
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(', ')})`,
|
|
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)`,
|
|
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)`,
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
464
|
-
await
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
...
|
|
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
|
|
540
|
-
await
|
|
541
|
-
await
|
|
542
|
-
const
|
|
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
|
|
545
|
-
if (!
|
|
831
|
+
const unionValue = rec.record[union.fieldName];
|
|
832
|
+
if (!unionValue || typeof unionValue !== 'object')
|
|
546
833
|
continue;
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
846
|
+
branchInserter.append(values);
|
|
668
847
|
}
|
|
669
848
|
catch {
|
|
670
|
-
// Skip bad
|
|
849
|
+
// Skip bad items
|
|
671
850
|
}
|
|
672
851
|
}
|
|
673
852
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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,
|
|
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')`,
|
|
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,
|
|
863
|
-
const textCols = schema.columns.filter((c) => c.
|
|
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
|
|
1110
|
+
// Phase 1: BM25 ranked search via SearchPort
|
|
875
1111
|
let bm25Results = [];
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
958
|
-
|
|
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) =>
|
|
996
|
-
|
|
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.
|
|
1002
|
-
simExprs.push(`COALESCE((SELECT MAX(
|
|
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 =
|
|
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
|
|
1029
|
-
const
|
|
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,
|
|
1204
|
+
return all(sql, params);
|
|
1205
|
+
}
|
|
1206
|
+
export async function runSQL(sql, params = []) {
|
|
1207
|
+
return run(sql, params);
|
|
1049
1208
|
}
|
|
1050
|
-
export async function
|
|
1051
|
-
return
|
|
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}`,
|
|
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})`,
|
|
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`,
|
|
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})`,
|
|
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.
|
|
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)`,
|
|
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
|
|
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)`,
|
|
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'`,
|
|
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
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
}
|