@aigne/afs-index 1.12.0-beta.5
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/LICENSE.md +26 -0
- package/dist/_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.cjs +11 -0
- package/dist/_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.mjs +10 -0
- package/dist/backend/d1-index-backend.cjs +656 -0
- package/dist/backend/d1-index-backend.d.cts +149 -0
- package/dist/backend/d1-index-backend.d.cts.map +1 -0
- package/dist/backend/d1-index-backend.d.mts +149 -0
- package/dist/backend/d1-index-backend.d.mts.map +1 -0
- package/dist/backend/d1-index-backend.mjs +657 -0
- package/dist/backend/d1-index-backend.mjs.map +1 -0
- package/dist/backend/d1-vector-backend.cjs +83 -0
- package/dist/backend/d1-vector-backend.d.cts +21 -0
- package/dist/backend/d1-vector-backend.d.cts.map +1 -0
- package/dist/backend/d1-vector-backend.d.mts +21 -0
- package/dist/backend/d1-vector-backend.d.mts.map +1 -0
- package/dist/backend/d1-vector-backend.mjs +84 -0
- package/dist/backend/d1-vector-backend.mjs.map +1 -0
- package/dist/backend/memory-backend.cjs +304 -0
- package/dist/backend/memory-backend.d.cts +66 -0
- package/dist/backend/memory-backend.d.cts.map +1 -0
- package/dist/backend/memory-backend.d.mts +66 -0
- package/dist/backend/memory-backend.d.mts.map +1 -0
- package/dist/backend/memory-backend.mjs +305 -0
- package/dist/backend/memory-backend.mjs.map +1 -0
- package/dist/backend/prefix-range.cjs +25 -0
- package/dist/backend/prefix-range.mjs +25 -0
- package/dist/backend/prefix-range.mjs.map +1 -0
- package/dist/backend/schema.cjs +187 -0
- package/dist/backend/schema.mjs +188 -0
- package/dist/backend/schema.mjs.map +1 -0
- package/dist/backend/sqlite-backend.cjs +889 -0
- package/dist/backend/sqlite-backend.d.cts +96 -0
- package/dist/backend/sqlite-backend.d.cts.map +1 -0
- package/dist/backend/sqlite-backend.d.mts +96 -0
- package/dist/backend/sqlite-backend.d.mts.map +1 -0
- package/dist/backend/sqlite-backend.mjs +890 -0
- package/dist/backend/sqlite-backend.mjs.map +1 -0
- package/dist/backend/types.d.cts +209 -0
- package/dist/backend/types.d.cts.map +1 -0
- package/dist/backend/types.d.mts +209 -0
- package/dist/backend/types.d.mts.map +1 -0
- package/dist/backend/upstash-vector-backend.cjs +98 -0
- package/dist/backend/upstash-vector-backend.d.cts +21 -0
- package/dist/backend/upstash-vector-backend.d.cts.map +1 -0
- package/dist/backend/upstash-vector-backend.d.mts +21 -0
- package/dist/backend/upstash-vector-backend.d.mts.map +1 -0
- package/dist/backend/upstash-vector-backend.mjs +98 -0
- package/dist/backend/upstash-vector-backend.mjs.map +1 -0
- package/dist/backend/vector-backend.d.cts +29 -0
- package/dist/backend/vector-backend.d.cts.map +1 -0
- package/dist/backend/vector-backend.d.mts +29 -0
- package/dist/backend/vector-backend.d.mts.map +1 -0
- package/dist/dirty-queue.d.cts +41 -0
- package/dist/dirty-queue.d.cts.map +1 -0
- package/dist/dirty-queue.d.mts +41 -0
- package/dist/dirty-queue.d.mts.map +1 -0
- package/dist/embed.cjs +45 -0
- package/dist/embed.d.cts +20 -0
- package/dist/embed.d.cts.map +1 -0
- package/dist/embed.d.mts +20 -0
- package/dist/embed.d.mts.map +1 -0
- package/dist/embed.mjs +46 -0
- package/dist/embed.mjs.map +1 -0
- package/dist/extraction/defaults.cjs +242 -0
- package/dist/extraction/defaults.d.cts +14 -0
- package/dist/extraction/defaults.d.cts.map +1 -0
- package/dist/extraction/defaults.d.mts +14 -0
- package/dist/extraction/defaults.d.mts.map +1 -0
- package/dist/extraction/defaults.mjs +242 -0
- package/dist/extraction/defaults.mjs.map +1 -0
- package/dist/extraction/field-map.cjs +162 -0
- package/dist/extraction/field-map.d.cts +70 -0
- package/dist/extraction/field-map.d.cts.map +1 -0
- package/dist/extraction/field-map.d.mts +70 -0
- package/dist/extraction/field-map.d.mts.map +1 -0
- package/dist/extraction/field-map.mjs +157 -0
- package/dist/extraction/field-map.mjs.map +1 -0
- package/dist/extraction/manager.cjs +74 -0
- package/dist/extraction/manager.d.cts +47 -0
- package/dist/extraction/manager.d.cts.map +1 -0
- package/dist/extraction/manager.d.mts +47 -0
- package/dist/extraction/manager.d.mts.map +1 -0
- package/dist/extraction/manager.mjs +74 -0
- package/dist/extraction/manager.mjs.map +1 -0
- package/dist/extraction/path-anchors.cjs +86 -0
- package/dist/extraction/path-anchors.d.cts +72 -0
- package/dist/extraction/path-anchors.d.cts.map +1 -0
- package/dist/extraction/path-anchors.d.mts +72 -0
- package/dist/extraction/path-anchors.d.mts.map +1 -0
- package/dist/extraction/path-anchors.mjs +86 -0
- package/dist/extraction/path-anchors.mjs.map +1 -0
- package/dist/follower-scheduler.cjs +92 -0
- package/dist/follower-scheduler.d.cts +58 -0
- package/dist/follower-scheduler.d.cts.map +1 -0
- package/dist/follower-scheduler.d.mts +58 -0
- package/dist/follower-scheduler.d.mts.map +1 -0
- package/dist/follower-scheduler.mjs +92 -0
- package/dist/follower-scheduler.mjs.map +1 -0
- package/dist/follower.cjs +577 -0
- package/dist/follower.d.cts +241 -0
- package/dist/follower.d.cts.map +1 -0
- package/dist/follower.d.mts +241 -0
- package/dist/follower.d.mts.map +1 -0
- package/dist/follower.mjs +571 -0
- package/dist/follower.mjs.map +1 -0
- package/dist/index-provider.cjs +1166 -0
- package/dist/index-provider.d.cts +175 -0
- package/dist/index-provider.d.cts.map +1 -0
- package/dist/index-provider.d.mts +175 -0
- package/dist/index-provider.d.mts.map +1 -0
- package/dist/index-provider.mjs +1167 -0
- package/dist/index-provider.mjs.map +1 -0
- package/dist/index.cjs +53 -0
- package/dist/index.d.cts +22 -0
- package/dist/index.d.mts +22 -0
- package/dist/index.mjs +20 -0
- package/dist/keys/exact.cjs +20 -0
- package/dist/keys/exact.mjs +20 -0
- package/dist/keys/exact.mjs.map +1 -0
- package/dist/keys/location.cjs +13 -0
- package/dist/keys/location.mjs +13 -0
- package/dist/keys/location.mjs.map +1 -0
- package/dist/keys/normalized.cjs +62 -0
- package/dist/keys/normalized.mjs +63 -0
- package/dist/keys/normalized.mjs.map +1 -0
- package/dist/keys/person.cjs +15 -0
- package/dist/keys/person.mjs +15 -0
- package/dist/keys/person.mjs.map +1 -0
- package/dist/keys/registry.cjs +45 -0
- package/dist/keys/registry.d.cts +15 -0
- package/dist/keys/registry.d.cts.map +1 -0
- package/dist/keys/registry.d.mts +15 -0
- package/dist/keys/registry.d.mts.map +1 -0
- package/dist/keys/registry.mjs +46 -0
- package/dist/keys/registry.mjs.map +1 -0
- package/dist/keys/time.cjs +128 -0
- package/dist/keys/time.mjs +128 -0
- package/dist/keys/time.mjs.map +1 -0
- package/dist/keys/topic.cjs +23 -0
- package/dist/keys/topic.mjs +23 -0
- package/dist/keys/topic.mjs.map +1 -0
- package/dist/manifest-index.cjs +29 -0
- package/dist/manifest-index.d.cts +24 -0
- package/dist/manifest-index.d.cts.map +1 -0
- package/dist/manifest-index.d.mts +24 -0
- package/dist/manifest-index.d.mts.map +1 -0
- package/dist/manifest-index.mjs +28 -0
- package/dist/manifest-index.mjs.map +1 -0
- package/dist/offline-rebuild.cjs +48 -0
- package/dist/offline-rebuild.d.cts +57 -0
- package/dist/offline-rebuild.d.cts.map +1 -0
- package/dist/offline-rebuild.d.mts +57 -0
- package/dist/offline-rebuild.d.mts.map +1 -0
- package/dist/offline-rebuild.mjs +49 -0
- package/dist/offline-rebuild.mjs.map +1 -0
- package/dist/query/engine.cjs +433 -0
- package/dist/query/engine.d.cts +34 -0
- package/dist/query/engine.d.cts.map +1 -0
- package/dist/query/engine.d.mts +34 -0
- package/dist/query/engine.d.mts.map +1 -0
- package/dist/query/engine.mjs +433 -0
- package/dist/query/engine.mjs.map +1 -0
- package/dist/query/limits.cjs +12 -0
- package/dist/query/limits.mjs +12 -0
- package/dist/query/limits.mjs.map +1 -0
- package/dist/text/cjk.cjs +30 -0
- package/dist/text/cjk.d.cts +24 -0
- package/dist/text/cjk.d.cts.map +1 -0
- package/dist/text/cjk.d.mts +24 -0
- package/dist/text/cjk.d.mts.map +1 -0
- package/dist/text/cjk.mjs +30 -0
- package/dist/text/cjk.mjs.map +1 -0
- package/dist/text/fts.cjs +244 -0
- package/dist/text/fts.mjs +245 -0
- package/dist/text/fts.mjs.map +1 -0
- package/dist/text/host.cjs +39 -0
- package/dist/text/host.mjs +38 -0
- package/dist/text/host.mjs.map +1 -0
- package/dist/types.cjs +44 -0
- package/dist/types.d.cts +393 -0
- package/dist/types.d.cts.map +1 -0
- package/dist/types.d.mts +393 -0
- package/dist/types.d.mts.map +1 -0
- package/dist/types.mjs +42 -0
- package/dist/types.mjs.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import { KeyGeneratorRegistry } from "../keys/registry.mjs";
|
|
2
|
+
import { segmentCJK } from "../text/cjk.mjs";
|
|
3
|
+
import { D1VectorBackend } from "./d1-vector-backend.mjs";
|
|
4
|
+
import { entryPathPrefixRange } from "./prefix-range.mjs";
|
|
5
|
+
import { initDatabase } from "@aigne/afs-sqlite";
|
|
6
|
+
import { sql } from "drizzle-orm";
|
|
7
|
+
|
|
8
|
+
//#region src/backend/d1-index-backend.ts
|
|
9
|
+
/**
|
|
10
|
+
* D1IndexBackend — IndexBackend implementation for Cloudflare D1.
|
|
11
|
+
*
|
|
12
|
+
* Runs in the DID Space D1 (the same edge SQLite as the tree index), so it is
|
|
13
|
+
* reachable from every path that builds a session view — afs-rpc, MCP, and the
|
|
14
|
+
* WebSocket/DO — because all three funnel through `buildCfSessionView`.
|
|
15
|
+
*
|
|
16
|
+
* Isolation is by SCOPE (a `scope` column on every table, mirroring how the DID
|
|
17
|
+
* Space tree index partitions its D1 rows). One backend instance is bound to one
|
|
18
|
+
* scope: per-user index → `deriveScope(callerDid)`, per-instance index →
|
|
19
|
+
* `deriveScope(installerDid)`. Different scopes never see each other's rows.
|
|
20
|
+
*
|
|
21
|
+
* Notes vs the (removed) DO-SQLite backend:
|
|
22
|
+
* - db is built via `initDatabase({ d1 })` (drizzle-orm/d1), not ctx.storage.sql.
|
|
23
|
+
* - D1 returns BLOB columns as `number[]`; `decodeStoredEmbedding` handles that
|
|
24
|
+
* alongside ArrayBuffer/Uint8Array.
|
|
25
|
+
* - No explicit `BEGIN TRANSACTION`: D1 auto-commits each statement and the
|
|
26
|
+
* index is a derived, rebuildable read model (a partial batch self-heals on
|
|
27
|
+
* the next reconcile).
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULT_FTS_TOKENIZER = "unicode61 remove_diacritics 2";
|
|
30
|
+
/** D1 handles whose scope-partitioned schema has been ensured this isolate. */
|
|
31
|
+
const schemaEnsured = /* @__PURE__ */ new WeakSet();
|
|
32
|
+
const cursorServedAtSupported = /* @__PURE__ */ new WeakMap();
|
|
33
|
+
var D1IndexBackend = class {
|
|
34
|
+
db;
|
|
35
|
+
d1;
|
|
36
|
+
scope;
|
|
37
|
+
keyRegistry;
|
|
38
|
+
ftsTokenizer;
|
|
39
|
+
_injectedVectorBackend;
|
|
40
|
+
_vectorBackend;
|
|
41
|
+
constructor(d1, scope, keyRegistry, opts = {}) {
|
|
42
|
+
this.d1 = d1;
|
|
43
|
+
this.scope = scope;
|
|
44
|
+
this.keyRegistry = keyRegistry ?? new KeyGeneratorRegistry();
|
|
45
|
+
this.ftsTokenizer = opts.ftsTokenizer ?? DEFAULT_FTS_TOKENIZER;
|
|
46
|
+
this._injectedVectorBackend = opts.vectorBackend;
|
|
47
|
+
}
|
|
48
|
+
/** See `IndexBackend.setKeyRegistry` — wires an injected backend to the provider's shared registry. */
|
|
49
|
+
setKeyRegistry(registry) {
|
|
50
|
+
this.keyRegistry = registry;
|
|
51
|
+
}
|
|
52
|
+
async initialize() {
|
|
53
|
+
this.db = await initDatabase({ d1: this.d1 });
|
|
54
|
+
if (!this.d1 || typeof this.d1 !== "object" || !schemaEnsured.has(this.d1)) {
|
|
55
|
+
await this.ensureSchema();
|
|
56
|
+
if (this.d1 && typeof this.d1 === "object") schemaEnsured.add(this.d1);
|
|
57
|
+
}
|
|
58
|
+
this._vectorBackend = this._injectedVectorBackend ?? new D1VectorBackend(this.db, this.scope);
|
|
59
|
+
}
|
|
60
|
+
async close() {
|
|
61
|
+
this.db = void 0;
|
|
62
|
+
}
|
|
63
|
+
d1BatchBinding() {
|
|
64
|
+
if (!this.d1 || typeof this.d1 !== "object") return null;
|
|
65
|
+
const candidate = this.d1;
|
|
66
|
+
return typeof candidate.prepare === "function" && typeof candidate.batch === "function" ? candidate : null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Rename the anchor tables `arc_anchor_*` → `idx_anchor_*` in place, preserving
|
|
70
|
+
* rows. Probe-guarded (renames only when the legacy table exists and the target
|
|
71
|
+
* does not) so it is idempotent and safe on fresh / already-migrated / racing
|
|
72
|
+
* isolates. Concurrent isolates may both attempt the ALTER on the same D1 —
|
|
73
|
+
* the loser's `no such table` / `already exists` is swallowed.
|
|
74
|
+
*
|
|
75
|
+
* ensureSchema runs on the cold-start (first-request-per-isolate) path, so the
|
|
76
|
+
* existence check is a SINGLE `sqlite_master` read for all four names rather
|
|
77
|
+
* than one per pair — one O(1) probe, no per-request cost (steady state is
|
|
78
|
+
* gated out by the `schemaEnsured` WeakSet).
|
|
79
|
+
*/
|
|
80
|
+
async renameAnchorTables() {
|
|
81
|
+
const pairs = [["arc_anchor_data", "idx_anchor_data"], ["arc_anchor_key", "idx_anchor_key"]];
|
|
82
|
+
const names = pairs.flat().map((n) => `'${n}'`);
|
|
83
|
+
const rows = await this.db.all(sql.raw(`SELECT name FROM sqlite_master WHERE type='table' AND name IN (${names.join(", ")})`));
|
|
84
|
+
const present = new Set(rows.map((r) => r.name));
|
|
85
|
+
for (const [legacy, next] of pairs) {
|
|
86
|
+
if (!present.has(legacy)) continue;
|
|
87
|
+
try {
|
|
88
|
+
if (present.has(next)) await this.db.run(sql.raw(`DROP TABLE IF EXISTS ${legacy}`));
|
|
89
|
+
else await this.db.run(sql.raw(`ALTER TABLE ${legacy} RENAME TO ${next}`));
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async ensureSchema() {
|
|
94
|
+
const tokenizer = this.ftsTokenizer.replace(/'/g, "''");
|
|
95
|
+
await this.renameAnchorTables();
|
|
96
|
+
await this.db.run(sql.raw(`CREATE TABLE IF NOT EXISTS ds_indexed_entry (
|
|
97
|
+
scope TEXT NOT NULL,
|
|
98
|
+
entry_path TEXT NOT NULL,
|
|
99
|
+
domain TEXT,
|
|
100
|
+
state TEXT,
|
|
101
|
+
indexed_at TEXT,
|
|
102
|
+
updated_at TEXT,
|
|
103
|
+
anchor_count INTEGER DEFAULT 0,
|
|
104
|
+
source_path TEXT,
|
|
105
|
+
source_hash TEXT,
|
|
106
|
+
extractor_id TEXT,
|
|
107
|
+
meta TEXT,
|
|
108
|
+
PRIMARY KEY (scope, entry_path)
|
|
109
|
+
)`));
|
|
110
|
+
await this.db.run(sql.raw(`CREATE TABLE IF NOT EXISTS idx_anchor_data (
|
|
111
|
+
scope TEXT NOT NULL,
|
|
112
|
+
entry_path TEXT NOT NULL,
|
|
113
|
+
anchor_type TEXT NOT NULL,
|
|
114
|
+
confidence REAL,
|
|
115
|
+
raw_anchor TEXT
|
|
116
|
+
)`));
|
|
117
|
+
await this.db.run(sql.raw(`CREATE TABLE IF NOT EXISTS idx_anchor_key (
|
|
118
|
+
scope TEXT NOT NULL,
|
|
119
|
+
index_key TEXT NOT NULL,
|
|
120
|
+
entry_path TEXT NOT NULL,
|
|
121
|
+
anchor_type TEXT NOT NULL,
|
|
122
|
+
confidence REAL,
|
|
123
|
+
PRIMARY KEY (scope, index_key, entry_path, anchor_type)
|
|
124
|
+
)`));
|
|
125
|
+
await this.db.run(sql.raw(`CREATE INDEX IF NOT EXISTS idx_ad_scope_path ON idx_anchor_data(scope, entry_path)`));
|
|
126
|
+
await this.db.run(sql.raw(`CREATE INDEX IF NOT EXISTS idx_ad_scope_anchor_type ON idx_anchor_data(scope, anchor_type)`));
|
|
127
|
+
await this.db.run(sql.raw(`CREATE INDEX IF NOT EXISTS idx_ak_scope_path ON idx_anchor_key(scope, entry_path)`));
|
|
128
|
+
await this.db.run(sql.raw(`CREATE TABLE IF NOT EXISTS idx_embedding_vector (
|
|
129
|
+
scope TEXT NOT NULL,
|
|
130
|
+
entry_path TEXT NOT NULL,
|
|
131
|
+
embedding TEXT,
|
|
132
|
+
embedding_blob BLOB,
|
|
133
|
+
dimension INTEGER,
|
|
134
|
+
PRIMARY KEY (scope, entry_path)
|
|
135
|
+
)`));
|
|
136
|
+
await this.db.run(sql.raw(`CREATE VIRTUAL TABLE IF NOT EXISTS idx_fts USING fts5(
|
|
137
|
+
scope UNINDEXED,
|
|
138
|
+
entry_path UNINDEXED,
|
|
139
|
+
summary,
|
|
140
|
+
tokenize='${tokenizer}'
|
|
141
|
+
)`));
|
|
142
|
+
await this.ensureFtsRowidMapping();
|
|
143
|
+
await this.db.run(sql.raw(`CREATE TABLE IF NOT EXISTS idx_index_cursor (
|
|
144
|
+
scope TEXT NOT NULL,
|
|
145
|
+
consumer_id TEXT NOT NULL,
|
|
146
|
+
last_seq INTEGER NOT NULL DEFAULT 0,
|
|
147
|
+
watermarks TEXT,
|
|
148
|
+
updated_at INTEGER NOT NULL,
|
|
149
|
+
served_at INTEGER,
|
|
150
|
+
instance_did TEXT,
|
|
151
|
+
domains TEXT,
|
|
152
|
+
PRIMARY KEY (scope, consumer_id)
|
|
153
|
+
)`));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Create `idx_fts_rowid` the first time this D1 sees it, backfilling from
|
|
157
|
+
* any pre-existing `idx_fts` rows (issue #1103). The backfill maps every
|
|
158
|
+
* existing FTS row to its own rowid so a later rewrite REPLACEs in place
|
|
159
|
+
* instead of leaving a duplicate for the same (scope, entry_path).
|
|
160
|
+
*
|
|
161
|
+
* Race-safe across concurrent cold isolates — D1 auto-commits each statement
|
|
162
|
+
* (no transaction), so two isolates can run this at once on a fresh D1:
|
|
163
|
+
* - `CREATE TABLE IF NOT EXISTS` never throws on a concurrent create.
|
|
164
|
+
* - The one-time backfill runs only while the mapping is still empty (a
|
|
165
|
+
* cheap LIMIT-1 probe, NOT a scan), so steady state costs nothing extra.
|
|
166
|
+
* - `INSERT OR IGNORE` makes the backfill itself idempotent, so two
|
|
167
|
+
* isolates racing it (or a re-run) can't collide on the PK/UNIQUE.
|
|
168
|
+
*/
|
|
169
|
+
async ensureFtsRowidMapping() {
|
|
170
|
+
await this.db.run(sql.raw(`CREATE TABLE IF NOT EXISTS idx_fts_rowid (
|
|
171
|
+
fts_rowid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
172
|
+
scope TEXT NOT NULL,
|
|
173
|
+
entry_path TEXT NOT NULL,
|
|
174
|
+
UNIQUE (scope, entry_path)
|
|
175
|
+
)`));
|
|
176
|
+
if ((await this.db.all(sql.raw("SELECT 1 AS one FROM idx_fts_rowid LIMIT 1"))).length > 0) return;
|
|
177
|
+
await this.db.run(sql.raw(`INSERT OR IGNORE INTO idx_fts_rowid (fts_rowid, scope, entry_path) SELECT rowid, scope, entry_path FROM idx_fts`));
|
|
178
|
+
}
|
|
179
|
+
async supportsCursorServedAt() {
|
|
180
|
+
if (this.d1 && typeof this.d1 === "object") {
|
|
181
|
+
const cached = cursorServedAtSupported.get(this.d1);
|
|
182
|
+
if (cached !== void 0) return cached;
|
|
183
|
+
}
|
|
184
|
+
const supported = (await this.db.all(sql.raw("PRAGMA table_info(idx_index_cursor)"))).some((c) => c.name === "served_at");
|
|
185
|
+
if (supported && this.d1 && typeof this.d1 === "object") cursorServedAtSupported.set(this.d1, supported);
|
|
186
|
+
return supported;
|
|
187
|
+
}
|
|
188
|
+
async writeEntry(entry) {
|
|
189
|
+
const e = entryToRow(entry);
|
|
190
|
+
await this.db.run(sql`INSERT OR REPLACE INTO ds_indexed_entry (scope, entry_path, domain, state, indexed_at, updated_at, anchor_count, source_path, source_hash, extractor_id, meta)
|
|
191
|
+
VALUES (${this.scope}, ${e.entry_path}, ${e.domain}, ${e.state}, ${e.indexed_at}, ${e.updated_at}, ${e.anchor_count}, ${e.source_path ?? null}, ${e.source_hash ?? null}, ${e.extractor_id ?? null}, ${e.meta ?? null})`);
|
|
192
|
+
}
|
|
193
|
+
async writeIndexedDocument(document) {
|
|
194
|
+
const d1 = this.d1BatchBinding();
|
|
195
|
+
if (!d1) {
|
|
196
|
+
await this.writeEntry(document.entry);
|
|
197
|
+
await this.writeAnchors(document.entry.entryPath, document.anchors ?? []);
|
|
198
|
+
if (document.summary) await this.writeSummary(document.entry.entryPath, document.summary);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const entry = entryToRow(document.entry);
|
|
202
|
+
const entryPath = document.entry.entryPath;
|
|
203
|
+
const statements = [
|
|
204
|
+
d1.prepare(`INSERT OR REPLACE INTO ds_indexed_entry (scope, entry_path, domain, state, indexed_at, updated_at, anchor_count, source_path, source_hash, extractor_id, meta)
|
|
205
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).bind(this.scope, entry.entry_path, entry.domain, entry.state, entry.indexed_at, entry.updated_at, entry.anchor_count, entry.source_path, entry.source_hash, entry.extractor_id, entry.meta),
|
|
206
|
+
d1.prepare("DELETE FROM idx_anchor_data WHERE scope = ? AND entry_path = ?").bind(this.scope, entryPath),
|
|
207
|
+
d1.prepare("DELETE FROM idx_anchor_key WHERE scope = ? AND entry_path = ?").bind(this.scope, entryPath)
|
|
208
|
+
];
|
|
209
|
+
const anchors = document.anchors ?? [];
|
|
210
|
+
for (const chunk of chunkArray(anchors, 18)) statements.push(d1.prepare(`INSERT INTO idx_anchor_data (scope, entry_path, anchor_type, confidence, raw_anchor) VALUES ${chunk.map(() => "(?, ?, ?, ?, ?)").join(", ")}`).bind(...chunk.flatMap((a) => [
|
|
211
|
+
this.scope,
|
|
212
|
+
entryPath,
|
|
213
|
+
a.type,
|
|
214
|
+
a.confidence,
|
|
215
|
+
JSON.stringify(a)
|
|
216
|
+
])));
|
|
217
|
+
const keyRows = anchors.flatMap((a) => this.keyRegistry.generateKeys(a).map((key) => ({
|
|
218
|
+
anchor: a,
|
|
219
|
+
key
|
|
220
|
+
})));
|
|
221
|
+
for (const chunk of chunkArray(keyRows, 18)) statements.push(d1.prepare(`INSERT OR REPLACE INTO idx_anchor_key (scope, index_key, entry_path, anchor_type, confidence) VALUES ${chunk.map(() => "(?, ?, ?, ?, ?)").join(", ")}`).bind(...chunk.flatMap(({ anchor, key }) => [
|
|
222
|
+
this.scope,
|
|
223
|
+
key,
|
|
224
|
+
entryPath,
|
|
225
|
+
anchor.type,
|
|
226
|
+
anchor.confidence
|
|
227
|
+
])));
|
|
228
|
+
if (document.summary) statements.push(d1.prepare("INSERT INTO idx_fts_rowid (scope, entry_path) VALUES (?, ?) ON CONFLICT(scope, entry_path) DO NOTHING").bind(this.scope, entryPath), d1.prepare(`INSERT OR REPLACE INTO idx_fts (rowid, scope, entry_path, summary)
|
|
229
|
+
VALUES ((SELECT fts_rowid FROM idx_fts_rowid WHERE scope = ? AND entry_path = ?), ?, ?, ?)`).bind(this.scope, entryPath, this.scope, entryPath, segmentCJK(document.summary)));
|
|
230
|
+
await d1.batch(statements);
|
|
231
|
+
}
|
|
232
|
+
async getEntry(path) {
|
|
233
|
+
const rows = await this.db.all(sql`SELECT * FROM ds_indexed_entry WHERE scope = ${this.scope} AND entry_path = ${path} LIMIT 1`);
|
|
234
|
+
if (rows.length === 0) return null;
|
|
235
|
+
return rowToEntry(rows[0]);
|
|
236
|
+
}
|
|
237
|
+
async removeEntry(path) {
|
|
238
|
+
await this.db.run(sql`DELETE FROM ds_indexed_entry WHERE scope = ${this.scope} AND entry_path = ${path}`);
|
|
239
|
+
}
|
|
240
|
+
async removeIndexedDocument(path, opts) {
|
|
241
|
+
const skipVector = opts?.skipVector === true;
|
|
242
|
+
const d1 = this.d1BatchBinding();
|
|
243
|
+
if (!d1) {
|
|
244
|
+
await this.removeAnchors(path);
|
|
245
|
+
await this.removeSummary(path);
|
|
246
|
+
if (!skipVector) await this.removeEmbedding(path);
|
|
247
|
+
await this.removeEntry(path);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (this._injectedVectorBackend && !skipVector) await this.removeEmbedding(path);
|
|
251
|
+
const statements = [
|
|
252
|
+
d1.prepare("DELETE FROM idx_anchor_data WHERE scope = ? AND entry_path = ?").bind(this.scope, path),
|
|
253
|
+
d1.prepare("DELETE FROM idx_anchor_key WHERE scope = ? AND entry_path = ?").bind(this.scope, path),
|
|
254
|
+
d1.prepare("DELETE FROM idx_fts WHERE rowid = (SELECT fts_rowid FROM idx_fts_rowid WHERE scope = ? AND entry_path = ?)").bind(this.scope, path),
|
|
255
|
+
d1.prepare("DELETE FROM idx_fts_rowid WHERE scope = ? AND entry_path = ?").bind(this.scope, path),
|
|
256
|
+
d1.prepare("DELETE FROM ds_indexed_entry WHERE scope = ? AND entry_path = ?").bind(this.scope, path)
|
|
257
|
+
];
|
|
258
|
+
if (!this._injectedVectorBackend && !skipVector) statements.splice(4, 0, d1.prepare("DELETE FROM idx_embedding_vector WHERE scope = ? AND entry_path = ?").bind(this.scope, path));
|
|
259
|
+
await d1.batch(statements);
|
|
260
|
+
}
|
|
261
|
+
async listEntries(filter) {
|
|
262
|
+
const conditions = this.entryConditions(filter);
|
|
263
|
+
return (await this.db.all(sql`SELECT * FROM ds_indexed_entry WHERE ${sql.join(conditions, sql` AND `)}`)).map(rowToEntry);
|
|
264
|
+
}
|
|
265
|
+
entryConditions(filter) {
|
|
266
|
+
const conditions = [sql`scope = ${this.scope}`];
|
|
267
|
+
if (filter.domain) conditions.push(sql`domain = ${filter.domain}`);
|
|
268
|
+
if (filter.state) conditions.push(sql`state = ${filter.state}`);
|
|
269
|
+
if (filter.extractorId) conditions.push(sql`extractor_id = ${filter.extractorId}`);
|
|
270
|
+
if (filter.scope) conditions.push(sql`entry_path LIKE ${`${filter.scope}%`}`);
|
|
271
|
+
if (filter.boundDomains && filter.boundDomains.length > 0) {
|
|
272
|
+
const ps = filter.boundDomains.map((d) => sql`${d}`);
|
|
273
|
+
conditions.push(sql`domain IN (${sql.join(ps, sql`, `)})`);
|
|
274
|
+
}
|
|
275
|
+
return conditions;
|
|
276
|
+
}
|
|
277
|
+
async markStale(filter) {
|
|
278
|
+
const conditions = [sql`scope = ${this.scope}`, sql`state = 'active'`];
|
|
279
|
+
if (filter.domain) conditions.push(sql`domain = ${filter.domain}`);
|
|
280
|
+
if (filter.extractorId) conditions.push(sql`extractor_id = ${filter.extractorId}`);
|
|
281
|
+
if (filter.scope) conditions.push(sql`entry_path LIKE ${`${filter.scope}%`}`);
|
|
282
|
+
if (filter.boundDomains && filter.boundDomains.length > 0) {
|
|
283
|
+
const ps = filter.boundDomains.map((d) => sql`${d}`);
|
|
284
|
+
conditions.push(sql`domain IN (${sql.join(ps, sql`, `)})`);
|
|
285
|
+
}
|
|
286
|
+
if (filter.anchorType) {
|
|
287
|
+
const anchorRows = await this.db.all(sql`SELECT DISTINCT entry_path FROM idx_anchor_data INDEXED BY idx_ad_scope_anchor_type WHERE scope = ${this.scope} AND anchor_type = ${filter.anchorType}`);
|
|
288
|
+
if (anchorRows.length === 0) return 0;
|
|
289
|
+
const placeholders = anchorRows.map((r) => sql`${r.entry_path}`);
|
|
290
|
+
conditions.push(sql`entry_path IN (${sql.join(placeholders, sql`, `)})`);
|
|
291
|
+
}
|
|
292
|
+
const where = sql.join(conditions, sql` AND `);
|
|
293
|
+
const count = (await this.db.all(sql`SELECT COUNT(*) as count FROM ds_indexed_entry WHERE ${where}`))[0]?.count ?? 0;
|
|
294
|
+
if (count === 0) return 0;
|
|
295
|
+
await this.db.run(sql`UPDATE ds_indexed_entry SET state = 'stale' WHERE ${where}`);
|
|
296
|
+
return count;
|
|
297
|
+
}
|
|
298
|
+
async countEntries(filter) {
|
|
299
|
+
const conditions = this.entryConditions(filter);
|
|
300
|
+
return (await this.db.all(sql`SELECT COUNT(*) as count FROM ds_indexed_entry WHERE ${sql.join(conditions, sql` AND `)}`))[0]?.count ?? 0;
|
|
301
|
+
}
|
|
302
|
+
async writeAnchors(path, anchors) {
|
|
303
|
+
await this.db.run(sql`DELETE FROM idx_anchor_data WHERE scope = ${this.scope} AND entry_path = ${path}`);
|
|
304
|
+
await this.db.run(sql`DELETE FROM idx_anchor_key WHERE scope = ${this.scope} AND entry_path = ${path}`);
|
|
305
|
+
if (anchors.length === 0) return;
|
|
306
|
+
const dataRows = anchors.map((a) => sql`(${this.scope}, ${path}, ${a.type}, ${a.confidence}, ${JSON.stringify(a)})`);
|
|
307
|
+
await this.multiRowInsert(sql`INSERT INTO idx_anchor_data (scope, entry_path, anchor_type, confidence, raw_anchor) VALUES `, dataRows);
|
|
308
|
+
const keyRows = anchors.flatMap((a) => this.keyRegistry.generateKeys(a).map((key) => sql`(${this.scope}, ${key}, ${path}, ${a.type}, ${a.confidence})`));
|
|
309
|
+
await this.multiRowInsert(sql`INSERT OR REPLACE INTO idx_anchor_key (scope, index_key, entry_path, anchor_type, confidence) VALUES `, keyRows);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Run `<prefix>(row),(row),…` as multi-row INSERT statement(s), chunked so
|
|
313
|
+
* each stays under D1's 100-bound-parameter-per-statement limit (5 columns per
|
|
314
|
+
* row → 18 rows ≈ 90 params). No-op on an empty row list. Portable across all
|
|
315
|
+
* db backends (plain `db.run`, unlike the driver-specific db.batch).
|
|
316
|
+
*/
|
|
317
|
+
async multiRowInsert(prefix, rows) {
|
|
318
|
+
const CHUNK = 18;
|
|
319
|
+
for (let i = 0; i < rows.length; i += CHUNK) await this.db.run(sql`${prefix}${sql.join(rows.slice(i, i + CHUNK), sql`, `)}`);
|
|
320
|
+
}
|
|
321
|
+
async queryAnchors(query) {
|
|
322
|
+
if (query.keys.length === 0) return [];
|
|
323
|
+
const keyGroups = query.keys.map((kg) => {
|
|
324
|
+
const values = /* @__PURE__ */ new Set();
|
|
325
|
+
for (const raw of kg.values) {
|
|
326
|
+
const synthetic = {
|
|
327
|
+
type: kg.type ?? "exact",
|
|
328
|
+
values: [raw],
|
|
329
|
+
confidence: 1
|
|
330
|
+
};
|
|
331
|
+
for (const k of this.keyRegistry.generateKeys(synthetic)) values.add(k);
|
|
332
|
+
values.add(raw);
|
|
333
|
+
}
|
|
334
|
+
return { values: [...values] };
|
|
335
|
+
});
|
|
336
|
+
const cteNames = [];
|
|
337
|
+
const cteParts = [];
|
|
338
|
+
for (let i = 0; i < keyGroups.length; i++) {
|
|
339
|
+
const cteName = `g${i}`;
|
|
340
|
+
cteNames.push(cteName);
|
|
341
|
+
const extraConds = [];
|
|
342
|
+
for (const prefix of [query.scope, query.pathPrefix]) if (prefix) {
|
|
343
|
+
const [lo, hi] = entryPathPrefixRange(prefix);
|
|
344
|
+
extraConds.push(sql`entry_path >= ${lo} AND entry_path < ${hi}`);
|
|
345
|
+
}
|
|
346
|
+
if (query.minConfidence != null) extraConds.push(sql`confidence >= ${query.minConfidence}`);
|
|
347
|
+
const arms = keyGroups[i].values.map((v) => sql`SELECT entry_path, confidence FROM idx_anchor_key WHERE ${sql.join([
|
|
348
|
+
sql`scope = ${this.scope}`,
|
|
349
|
+
sql`index_key = ${v}`,
|
|
350
|
+
...extraConds
|
|
351
|
+
], sql` AND `)}`);
|
|
352
|
+
cteParts.push(sql`${sql.raw(cteName)} AS (SELECT entry_path, MIN(confidence) as min_conf FROM (${sql.join(arms, sql` UNION ALL `)}) GROUP BY entry_path)`);
|
|
353
|
+
}
|
|
354
|
+
const cteSql = sql`WITH ${sql.join(cteParts, sql`, `)}`;
|
|
355
|
+
const cteJoinParts = [];
|
|
356
|
+
let scoreExpr;
|
|
357
|
+
if (cteNames.length === 1) scoreExpr = `${cteNames[0]}.min_conf`;
|
|
358
|
+
else {
|
|
359
|
+
for (let i = 1; i < cteNames.length; i++) cteJoinParts.push(`INNER JOIN ${cteNames[i]} ON ${cteNames[0]}.entry_path = ${cteNames[i]}.entry_path`);
|
|
360
|
+
scoreExpr = `MIN(${cteNames.map((n) => `${n}.min_conf`).join(", ")})`;
|
|
361
|
+
}
|
|
362
|
+
const metaFilter = query.metaFilter;
|
|
363
|
+
const domains = query.domains;
|
|
364
|
+
const entryJoinPart = [];
|
|
365
|
+
let entryWhereSql = null;
|
|
366
|
+
if (domains && domains.length > 0 || metaFilter && Object.keys(metaFilter).length > 0) {
|
|
367
|
+
entryJoinPart.push(`INNER JOIN ds_indexed_entry ie ON ${cteNames[0]}.entry_path = ie.entry_path AND ie.scope = '${sqlEscape(this.scope)}'`);
|
|
368
|
+
const entryConditions = [];
|
|
369
|
+
if (domains && domains.length > 0) {
|
|
370
|
+
const ps = domains.map((d) => sql`${d}`);
|
|
371
|
+
entryConditions.push(sql`ie.domain IN (${sql.join(ps, sql`, `)})`);
|
|
372
|
+
}
|
|
373
|
+
for (const [key, expected] of Object.entries(metaFilter ?? {})) if (Array.isArray(expected)) {
|
|
374
|
+
if (expected.length === 0) return [];
|
|
375
|
+
const ps = expected.map((v) => sql`${v}`);
|
|
376
|
+
entryConditions.push(sql`json_extract(ie.meta, ${`$.${key}`}) IN (${sql.join(ps, sql`, `)})`);
|
|
377
|
+
} else entryConditions.push(sql`json_extract(ie.meta, ${`$.${key}`}) = ${expected}`);
|
|
378
|
+
entryWhereSql = sql.join(entryConditions, sql` AND `);
|
|
379
|
+
}
|
|
380
|
+
const allJoins = [...cteJoinParts, ...entryJoinPart].join(" ");
|
|
381
|
+
let selectSql;
|
|
382
|
+
if (entryWhereSql) selectSql = sql`${cteSql} SELECT ${sql.raw(cteNames[0])}.entry_path, ${sql.raw(scoreExpr)} as score FROM ${sql.raw(cteNames[0])} ${sql.raw(allJoins)} WHERE ${entryWhereSql}`;
|
|
383
|
+
else selectSql = sql`${cteSql} SELECT ${sql.raw(cteNames[0])}.entry_path, ${sql.raw(scoreExpr)} as score FROM ${sql.raw(cteNames[0])} ${sql.raw(allJoins)}`;
|
|
384
|
+
const orderSql = query.limit != null ? sql`${selectSql} ORDER BY score DESC, ${sql.raw(cteNames[0])}.entry_path LIMIT ${query.limit}` : sql`${selectSql} ORDER BY score DESC, ${sql.raw(cteNames[0])}.entry_path`;
|
|
385
|
+
return (await this.db.all(orderSql)).map((r) => ({
|
|
386
|
+
entryPath: r.entry_path,
|
|
387
|
+
score: r.score
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
async removeAnchors(path) {
|
|
391
|
+
await this.db.run(sql`DELETE FROM idx_anchor_data WHERE scope = ${this.scope} AND entry_path = ${path}`);
|
|
392
|
+
await this.db.run(sql`DELETE FROM idx_anchor_key WHERE scope = ${this.scope} AND entry_path = ${path}`);
|
|
393
|
+
}
|
|
394
|
+
async getAnchorData(path) {
|
|
395
|
+
return (await this.db.all(sql`SELECT * FROM idx_anchor_data WHERE scope = ${this.scope} AND entry_path = ${path}`)).map((r) => ({
|
|
396
|
+
entryPath: r.entry_path,
|
|
397
|
+
anchorType: r.anchor_type,
|
|
398
|
+
confidence: r.confidence,
|
|
399
|
+
rawAnchor: r.raw_anchor
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
async writeSummary(path, summary) {
|
|
403
|
+
await writeFtsRow(this.db, this.scope, path, segmentCJK(summary));
|
|
404
|
+
}
|
|
405
|
+
async getSummary(path) {
|
|
406
|
+
return (await this.db.all(sql`SELECT summary FROM idx_fts WHERE scope = ${this.scope} AND entry_path = ${path} LIMIT 1`))[0]?.summary ?? null;
|
|
407
|
+
}
|
|
408
|
+
async queryFTS(text, limit, metaFilter, opts) {
|
|
409
|
+
try {
|
|
410
|
+
const sanitized = sanitizeFTS5(text);
|
|
411
|
+
if (!sanitized) return [];
|
|
412
|
+
const needsJoin = metaFilter && Object.keys(metaFilter).length > 0 || typeof opts?.domain === "string" && opts.domain.length > 0 || Array.isArray(opts?.domains) && opts.domains.length > 0 || typeof opts?.pathPrefix === "string" && opts.pathPrefix.length > 0;
|
|
413
|
+
let query;
|
|
414
|
+
if (needsJoin) {
|
|
415
|
+
const conditions = [
|
|
416
|
+
sql`idx_fts MATCH ${sanitized}`,
|
|
417
|
+
sql`idx_fts.scope = ${this.scope}`,
|
|
418
|
+
sql`ie.scope = ${this.scope}`
|
|
419
|
+
];
|
|
420
|
+
if (opts?.domain) conditions.push(sql`ie.domain = ${opts.domain}`);
|
|
421
|
+
if (opts?.domains && opts.domains.length > 0) {
|
|
422
|
+
const ps = opts.domains.map((d) => sql`${d}`);
|
|
423
|
+
conditions.push(sql`ie.domain IN (${sql.join(ps, sql`, `)})`);
|
|
424
|
+
}
|
|
425
|
+
if (opts?.pathPrefix) conditions.push(sql`ie.entry_path LIKE ${`${opts.pathPrefix}%`}`);
|
|
426
|
+
for (const [key, expected] of Object.entries(metaFilter ?? {})) if (Array.isArray(expected)) {
|
|
427
|
+
if (expected.length === 0) return [];
|
|
428
|
+
const ps = expected.map((v) => sql`${v}`);
|
|
429
|
+
conditions.push(sql`json_extract(ie.meta, ${`$.${key}`}) IN (${sql.join(ps, sql`, `)})`);
|
|
430
|
+
} else conditions.push(sql`json_extract(ie.meta, ${`$.${key}`}) = ${expected}`);
|
|
431
|
+
const where = sql.join(conditions, sql` AND `);
|
|
432
|
+
query = limit != null ? sql`SELECT idx_fts.entry_path, idx_fts.rank FROM idx_fts INNER JOIN ds_indexed_entry ie ON idx_fts.entry_path = ie.entry_path WHERE ${where} ORDER BY idx_fts.rank LIMIT ${limit}` : sql`SELECT idx_fts.entry_path, idx_fts.rank FROM idx_fts INNER JOIN ds_indexed_entry ie ON idx_fts.entry_path = ie.entry_path WHERE ${where} ORDER BY idx_fts.rank`;
|
|
433
|
+
} else query = limit != null ? sql`SELECT entry_path, rank FROM idx_fts WHERE idx_fts MATCH ${sanitized} AND scope = ${this.scope} ORDER BY rank LIMIT ${limit}` : sql`SELECT entry_path, rank FROM idx_fts WHERE idx_fts MATCH ${sanitized} AND scope = ${this.scope} ORDER BY rank`;
|
|
434
|
+
return (await this.db.all(query)).map((r) => ({
|
|
435
|
+
entryPath: r.entry_path,
|
|
436
|
+
rank: -r.rank
|
|
437
|
+
}));
|
|
438
|
+
} catch {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
async removeSummary(path) {
|
|
443
|
+
await deleteFtsRow(this.db, this.scope, path);
|
|
444
|
+
}
|
|
445
|
+
async writeEmbedding(path, embedding, domain) {
|
|
446
|
+
await this._vectorBackend.upsert(path, embedding, domain);
|
|
447
|
+
}
|
|
448
|
+
async queryVector(embedding, limit, opts) {
|
|
449
|
+
return this._vectorBackend.query(embedding, {
|
|
450
|
+
domain: opts?.domain,
|
|
451
|
+
domains: opts?.domains,
|
|
452
|
+
pathPrefix: opts?.pathPrefix
|
|
453
|
+
}, limit);
|
|
454
|
+
}
|
|
455
|
+
async removeEmbedding(path) {
|
|
456
|
+
await this._vectorBackend.delete(path);
|
|
457
|
+
}
|
|
458
|
+
embeddingCapability() {
|
|
459
|
+
return this._vectorBackend.capability();
|
|
460
|
+
}
|
|
461
|
+
async readCursor(consumerId) {
|
|
462
|
+
const rows = await this.db.all(sql`SELECT consumer_id, last_seq, watermarks, updated_at FROM idx_index_cursor WHERE scope = ${this.scope} AND consumer_id = ${consumerId} LIMIT 1`);
|
|
463
|
+
if (rows.length === 0) return null;
|
|
464
|
+
const row = rows[0];
|
|
465
|
+
return {
|
|
466
|
+
consumerId: row.consumer_id,
|
|
467
|
+
lastSeq: row.last_seq,
|
|
468
|
+
watermarks: row.watermarks ? JSON.parse(row.watermarks) : void 0,
|
|
469
|
+
updatedAt: row.updated_at
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Set a cursor row's `last_seq` (+ watermarks/updated_at/served_at).
|
|
474
|
+
*
|
|
475
|
+
* NOTE (index-follower-dirty-path §4.2 cutover): the index FOLLOWER no longer
|
|
476
|
+
* calls this — after the changelog replay was deleted, `last_seq` is a DEAD
|
|
477
|
+
* COLUMN (nothing advances it during drain; scheduling rides `served_at` via
|
|
478
|
+
* `markCursorServed`). `writeCursor` now has NO production caller (the ops
|
|
479
|
+
* scripts write cursor rows via raw SQL, not this method); it is retained for
|
|
480
|
+
* the monotonic-guard test suite + as a stable backend primitive, so the
|
|
481
|
+
* `MAX(last_seq, excluded.last_seq)` guard (issue #935 BLOCK fix #2) stays
|
|
482
|
+
* correct there; watermarks/updated_at/served_at update unconditionally.
|
|
483
|
+
*/
|
|
484
|
+
async writeCursor(consumerId, lastSeq, watermarks) {
|
|
485
|
+
const now = Date.now();
|
|
486
|
+
const watermarksJson = watermarks ? JSON.stringify(watermarks) : null;
|
|
487
|
+
if (await this.supportsCursorServedAt()) {
|
|
488
|
+
await this.db.run(sql`INSERT INTO idx_index_cursor (scope, consumer_id, last_seq, watermarks, updated_at, served_at)
|
|
489
|
+
VALUES (${this.scope}, ${consumerId}, ${lastSeq}, ${watermarksJson}, ${now}, ${now})
|
|
490
|
+
ON CONFLICT(scope, consumer_id) DO UPDATE SET
|
|
491
|
+
last_seq = MAX(last_seq, excluded.last_seq),
|
|
492
|
+
watermarks = excluded.watermarks,
|
|
493
|
+
updated_at = excluded.updated_at,
|
|
494
|
+
served_at = excluded.served_at`);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
await this.db.run(sql`INSERT INTO idx_index_cursor (scope, consumer_id, last_seq, watermarks, updated_at)
|
|
498
|
+
VALUES (${this.scope}, ${consumerId}, ${lastSeq}, ${watermarksJson}, ${now})
|
|
499
|
+
ON CONFLICT(scope, consumer_id) DO UPDATE SET
|
|
500
|
+
last_seq = MAX(last_seq, excluded.last_seq),
|
|
501
|
+
watermarks = excluded.watermarks,
|
|
502
|
+
updated_at = excluded.updated_at`);
|
|
503
|
+
}
|
|
504
|
+
async markCursorServed(consumerId) {
|
|
505
|
+
if (!await this.supportsCursorServedAt()) return;
|
|
506
|
+
await this.db.run(sql`UPDATE idx_index_cursor SET served_at = ${Date.now()} WHERE scope = ${this.scope} AND consumer_id = ${consumerId}`);
|
|
507
|
+
}
|
|
508
|
+
async initCursor(consumerId, registration) {
|
|
509
|
+
const now = Date.now();
|
|
510
|
+
if (await this.supportsCursorServedAt()) {
|
|
511
|
+
await this.db.run(sql`INSERT OR IGNORE INTO idx_index_cursor (scope, consumer_id, last_seq, watermarks, updated_at, served_at, instance_did, domains)
|
|
512
|
+
VALUES (${this.scope}, ${consumerId}, 0, NULL, ${now}, ${now}, ${registration?.instanceDid ?? null}, ${registration?.domainsJson ?? null})`);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
await this.db.run(sql`INSERT OR IGNORE INTO idx_index_cursor (scope, consumer_id, last_seq, watermarks, updated_at, instance_did, domains)
|
|
516
|
+
VALUES (${this.scope}, ${consumerId}, 0, NULL, ${now}, ${registration?.instanceDid ?? null}, ${registration?.domainsJson ?? null})`);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* See {@link IndexBackend.listCursorScopes}. Deliberately NOT filtered by
|
|
520
|
+
* `this.scope` — this is the one cursor operation that must see EVERY
|
|
521
|
+
* scope in the D1, so the CF cron can discover work registered by ANY
|
|
522
|
+
* caller in ANY prior isolate, not just the scope this backend instance
|
|
523
|
+
* happens to be constructed for.
|
|
524
|
+
*
|
|
525
|
+
* `consumerId` is a FAMILY prefix, not an exact match (design.md §4.4
|
|
526
|
+
* addendum, owner decision 2026-07-05): every real cursor row's
|
|
527
|
+
* `consumer_id` is scope-qualified via `deriveFollowerConsumerId(scope)`
|
|
528
|
+
* ("index:{scope}") so a single shared backend/database can host multiple
|
|
529
|
+
* scopes without their cursor rows colliding — see follower.ts. Discovery
|
|
530
|
+
* doesn't know the scope in advance (that's its whole job), so it matches
|
|
531
|
+
* every row whose `consumer_id` starts with `"{consumerId}:"` instead of
|
|
532
|
+
* one exact value. Callers keep passing the bare `FOLLOWER_CONSUMER_ID`
|
|
533
|
+
* ("index") — this method appends the `:` separator internally.
|
|
534
|
+
*/
|
|
535
|
+
async listCursorScopes(consumerId) {
|
|
536
|
+
return (await this.db.all(await this.supportsCursorServedAt() ? sql`SELECT scope, consumer_id, instance_did, domains, updated_at, served_at FROM idx_index_cursor WHERE consumer_id LIKE ${`${consumerId}:%`}` : sql`SELECT scope, consumer_id, instance_did, domains, updated_at FROM idx_index_cursor WHERE consumer_id LIKE ${`${consumerId}:%`}`)).map((r) => ({
|
|
537
|
+
scope: r.scope,
|
|
538
|
+
consumerId: r.consumer_id,
|
|
539
|
+
instanceDid: r.instance_did,
|
|
540
|
+
domainsJson: r.domains,
|
|
541
|
+
updatedAt: r.updated_at,
|
|
542
|
+
servedAt: r.served_at ?? r.updated_at
|
|
543
|
+
}));
|
|
544
|
+
}
|
|
545
|
+
async writeEntriesBatch(entries) {
|
|
546
|
+
for (const entry of entries) await this.writeEntry(entry);
|
|
547
|
+
}
|
|
548
|
+
async writeAnchorsBatch(items) {
|
|
549
|
+
for (const item of items) await this.writeAnchors(item.path, item.anchors);
|
|
550
|
+
}
|
|
551
|
+
async writeSummariesBatch(items) {
|
|
552
|
+
for (const item of items) await this.writeSummary(item.path, item.summary);
|
|
553
|
+
}
|
|
554
|
+
async purgeEntries(paths, opts) {
|
|
555
|
+
const skipVector = opts?.skipVector === true;
|
|
556
|
+
const d1 = this.d1BatchBinding();
|
|
557
|
+
if (d1 && skipVector && paths.length > 0) {
|
|
558
|
+
const stmts = [];
|
|
559
|
+
for (const path of paths) stmts.push(d1.prepare("DELETE FROM idx_anchor_data WHERE scope = ? AND entry_path = ?").bind(this.scope, path), d1.prepare("DELETE FROM idx_anchor_key WHERE scope = ? AND entry_path = ?").bind(this.scope, path), d1.prepare("DELETE FROM idx_fts WHERE rowid = (SELECT fts_rowid FROM idx_fts_rowid WHERE scope = ? AND entry_path = ?)").bind(this.scope, path), d1.prepare("DELETE FROM idx_fts_rowid WHERE scope = ? AND entry_path = ?").bind(this.scope, path), d1.prepare("DELETE FROM ds_indexed_entry WHERE scope = ? AND entry_path = ?").bind(this.scope, path));
|
|
560
|
+
for (const chunk of chunkArray(stmts, 50)) await d1.batch(chunk);
|
|
561
|
+
return paths.length;
|
|
562
|
+
}
|
|
563
|
+
for (const path of paths) {
|
|
564
|
+
await this.removeAnchors(path);
|
|
565
|
+
await this.removeSummary(path);
|
|
566
|
+
if (!skipVector) await this.removeEmbedding(path);
|
|
567
|
+
await this.removeEntry(path);
|
|
568
|
+
}
|
|
569
|
+
return paths.length;
|
|
570
|
+
}
|
|
571
|
+
async queryCombined(keys, text, opts) {
|
|
572
|
+
const anchorResults = keys.length > 0 ? await this.queryAnchors({
|
|
573
|
+
keys,
|
|
574
|
+
scope: opts?.scope,
|
|
575
|
+
minConfidence: opts?.minConfidence,
|
|
576
|
+
limit: opts?.limit
|
|
577
|
+
}) : [];
|
|
578
|
+
const anchorPaths = new Set(anchorResults.map((r) => r.entryPath));
|
|
579
|
+
const ftsResults = text ? await this.queryFTS(text, opts?.limit) : [];
|
|
580
|
+
const results = anchorResults.map((r) => {
|
|
581
|
+
const fts = ftsResults.find((f) => f.entryPath === r.entryPath);
|
|
582
|
+
return {
|
|
583
|
+
entryPath: r.entryPath,
|
|
584
|
+
anchorScore: r.score,
|
|
585
|
+
ftsRank: fts?.rank,
|
|
586
|
+
score: r.score + (fts?.rank ?? 0)
|
|
587
|
+
};
|
|
588
|
+
});
|
|
589
|
+
for (const f of ftsResults) if (!anchorPaths.has(f.entryPath)) results.push({
|
|
590
|
+
entryPath: f.entryPath,
|
|
591
|
+
ftsRank: f.rank,
|
|
592
|
+
score: f.rank
|
|
593
|
+
});
|
|
594
|
+
results.sort((a, b) => b.score - a.score);
|
|
595
|
+
if (opts?.limit != null) return results.slice(0, opts.limit);
|
|
596
|
+
return results;
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
/** Get-or-create the stable idx_fts rowid for (scope, path) via the mapping table. */
|
|
600
|
+
async function ensureFtsRowid(db, scope, path) {
|
|
601
|
+
await db.run(sql`INSERT INTO idx_fts_rowid (scope, entry_path) VALUES (${scope}, ${path})
|
|
602
|
+
ON CONFLICT(scope, entry_path) DO NOTHING`);
|
|
603
|
+
return (await db.all(sql`SELECT fts_rowid FROM idx_fts_rowid WHERE scope = ${scope} AND entry_path = ${path} LIMIT 1`))[0].fts_rowid;
|
|
604
|
+
}
|
|
605
|
+
/** Atomically insert-or-replace an entry's FTS row at its stable mapped rowid. */
|
|
606
|
+
async function writeFtsRow(db, scope, path, summary) {
|
|
607
|
+
const rowid = await ensureFtsRowid(db, scope, path);
|
|
608
|
+
await db.run(sql`INSERT OR REPLACE INTO idx_fts (rowid, scope, entry_path, summary) VALUES (${rowid}, ${scope}, ${path}, ${summary})`);
|
|
609
|
+
}
|
|
610
|
+
/** Delete an entry's idx_fts row (by mapped rowid, O(1)) and its mapping row. */
|
|
611
|
+
async function deleteFtsRow(db, scope, path) {
|
|
612
|
+
await db.run(sql`DELETE FROM idx_fts WHERE rowid = (SELECT fts_rowid FROM idx_fts_rowid WHERE scope = ${scope} AND entry_path = ${path})`);
|
|
613
|
+
await db.run(sql`DELETE FROM idx_fts_rowid WHERE scope = ${scope} AND entry_path = ${path}`);
|
|
614
|
+
}
|
|
615
|
+
function entryToRow(entry) {
|
|
616
|
+
return {
|
|
617
|
+
entry_path: entry.entryPath,
|
|
618
|
+
domain: entry.domain,
|
|
619
|
+
state: entry.state,
|
|
620
|
+
indexed_at: entry.indexedAt,
|
|
621
|
+
updated_at: entry.updatedAt,
|
|
622
|
+
anchor_count: entry.anchorCount,
|
|
623
|
+
source_path: entry.sourcePath ?? null,
|
|
624
|
+
source_hash: entry.sourceHash ?? null,
|
|
625
|
+
extractor_id: entry.extractorId ?? null,
|
|
626
|
+
meta: entry.meta != null ? JSON.stringify(entry.meta) : null
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
function rowToEntry(row) {
|
|
630
|
+
return {
|
|
631
|
+
entryPath: row.entry_path,
|
|
632
|
+
sourcePath: row.source_path ?? void 0,
|
|
633
|
+
sourceHash: row.source_hash ?? void 0,
|
|
634
|
+
domain: row.domain,
|
|
635
|
+
state: row.state,
|
|
636
|
+
extractorId: row.extractor_id ?? void 0,
|
|
637
|
+
indexedAt: row.indexed_at,
|
|
638
|
+
updatedAt: row.updated_at,
|
|
639
|
+
anchorCount: row.anchor_count,
|
|
640
|
+
meta: row.meta ? JSON.parse(row.meta) : void 0
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function sqlEscape(value) {
|
|
644
|
+
return value.replace(/'/g, "''");
|
|
645
|
+
}
|
|
646
|
+
function chunkArray(items, size) {
|
|
647
|
+
const chunks = [];
|
|
648
|
+
for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
|
|
649
|
+
return chunks;
|
|
650
|
+
}
|
|
651
|
+
function sanitizeFTS5(text) {
|
|
652
|
+
return text.replace(/["*^()\-+{}:]/g, " ").replace(/\s+/g, " ").trim();
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
//#endregion
|
|
656
|
+
export { D1IndexBackend };
|
|
657
|
+
//# sourceMappingURL=d1-index-backend.mjs.map
|