@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.
Files changed (186) hide show
  1. package/LICENSE.md +26 -0
  2. package/dist/_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.cjs +11 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.mjs +10 -0
  4. package/dist/backend/d1-index-backend.cjs +656 -0
  5. package/dist/backend/d1-index-backend.d.cts +149 -0
  6. package/dist/backend/d1-index-backend.d.cts.map +1 -0
  7. package/dist/backend/d1-index-backend.d.mts +149 -0
  8. package/dist/backend/d1-index-backend.d.mts.map +1 -0
  9. package/dist/backend/d1-index-backend.mjs +657 -0
  10. package/dist/backend/d1-index-backend.mjs.map +1 -0
  11. package/dist/backend/d1-vector-backend.cjs +83 -0
  12. package/dist/backend/d1-vector-backend.d.cts +21 -0
  13. package/dist/backend/d1-vector-backend.d.cts.map +1 -0
  14. package/dist/backend/d1-vector-backend.d.mts +21 -0
  15. package/dist/backend/d1-vector-backend.d.mts.map +1 -0
  16. package/dist/backend/d1-vector-backend.mjs +84 -0
  17. package/dist/backend/d1-vector-backend.mjs.map +1 -0
  18. package/dist/backend/memory-backend.cjs +304 -0
  19. package/dist/backend/memory-backend.d.cts +66 -0
  20. package/dist/backend/memory-backend.d.cts.map +1 -0
  21. package/dist/backend/memory-backend.d.mts +66 -0
  22. package/dist/backend/memory-backend.d.mts.map +1 -0
  23. package/dist/backend/memory-backend.mjs +305 -0
  24. package/dist/backend/memory-backend.mjs.map +1 -0
  25. package/dist/backend/prefix-range.cjs +25 -0
  26. package/dist/backend/prefix-range.mjs +25 -0
  27. package/dist/backend/prefix-range.mjs.map +1 -0
  28. package/dist/backend/schema.cjs +187 -0
  29. package/dist/backend/schema.mjs +188 -0
  30. package/dist/backend/schema.mjs.map +1 -0
  31. package/dist/backend/sqlite-backend.cjs +889 -0
  32. package/dist/backend/sqlite-backend.d.cts +96 -0
  33. package/dist/backend/sqlite-backend.d.cts.map +1 -0
  34. package/dist/backend/sqlite-backend.d.mts +96 -0
  35. package/dist/backend/sqlite-backend.d.mts.map +1 -0
  36. package/dist/backend/sqlite-backend.mjs +890 -0
  37. package/dist/backend/sqlite-backend.mjs.map +1 -0
  38. package/dist/backend/types.d.cts +209 -0
  39. package/dist/backend/types.d.cts.map +1 -0
  40. package/dist/backend/types.d.mts +209 -0
  41. package/dist/backend/types.d.mts.map +1 -0
  42. package/dist/backend/upstash-vector-backend.cjs +98 -0
  43. package/dist/backend/upstash-vector-backend.d.cts +21 -0
  44. package/dist/backend/upstash-vector-backend.d.cts.map +1 -0
  45. package/dist/backend/upstash-vector-backend.d.mts +21 -0
  46. package/dist/backend/upstash-vector-backend.d.mts.map +1 -0
  47. package/dist/backend/upstash-vector-backend.mjs +98 -0
  48. package/dist/backend/upstash-vector-backend.mjs.map +1 -0
  49. package/dist/backend/vector-backend.d.cts +29 -0
  50. package/dist/backend/vector-backend.d.cts.map +1 -0
  51. package/dist/backend/vector-backend.d.mts +29 -0
  52. package/dist/backend/vector-backend.d.mts.map +1 -0
  53. package/dist/dirty-queue.d.cts +41 -0
  54. package/dist/dirty-queue.d.cts.map +1 -0
  55. package/dist/dirty-queue.d.mts +41 -0
  56. package/dist/dirty-queue.d.mts.map +1 -0
  57. package/dist/embed.cjs +45 -0
  58. package/dist/embed.d.cts +20 -0
  59. package/dist/embed.d.cts.map +1 -0
  60. package/dist/embed.d.mts +20 -0
  61. package/dist/embed.d.mts.map +1 -0
  62. package/dist/embed.mjs +46 -0
  63. package/dist/embed.mjs.map +1 -0
  64. package/dist/extraction/defaults.cjs +242 -0
  65. package/dist/extraction/defaults.d.cts +14 -0
  66. package/dist/extraction/defaults.d.cts.map +1 -0
  67. package/dist/extraction/defaults.d.mts +14 -0
  68. package/dist/extraction/defaults.d.mts.map +1 -0
  69. package/dist/extraction/defaults.mjs +242 -0
  70. package/dist/extraction/defaults.mjs.map +1 -0
  71. package/dist/extraction/field-map.cjs +162 -0
  72. package/dist/extraction/field-map.d.cts +70 -0
  73. package/dist/extraction/field-map.d.cts.map +1 -0
  74. package/dist/extraction/field-map.d.mts +70 -0
  75. package/dist/extraction/field-map.d.mts.map +1 -0
  76. package/dist/extraction/field-map.mjs +157 -0
  77. package/dist/extraction/field-map.mjs.map +1 -0
  78. package/dist/extraction/manager.cjs +74 -0
  79. package/dist/extraction/manager.d.cts +47 -0
  80. package/dist/extraction/manager.d.cts.map +1 -0
  81. package/dist/extraction/manager.d.mts +47 -0
  82. package/dist/extraction/manager.d.mts.map +1 -0
  83. package/dist/extraction/manager.mjs +74 -0
  84. package/dist/extraction/manager.mjs.map +1 -0
  85. package/dist/extraction/path-anchors.cjs +86 -0
  86. package/dist/extraction/path-anchors.d.cts +72 -0
  87. package/dist/extraction/path-anchors.d.cts.map +1 -0
  88. package/dist/extraction/path-anchors.d.mts +72 -0
  89. package/dist/extraction/path-anchors.d.mts.map +1 -0
  90. package/dist/extraction/path-anchors.mjs +86 -0
  91. package/dist/extraction/path-anchors.mjs.map +1 -0
  92. package/dist/follower-scheduler.cjs +92 -0
  93. package/dist/follower-scheduler.d.cts +58 -0
  94. package/dist/follower-scheduler.d.cts.map +1 -0
  95. package/dist/follower-scheduler.d.mts +58 -0
  96. package/dist/follower-scheduler.d.mts.map +1 -0
  97. package/dist/follower-scheduler.mjs +92 -0
  98. package/dist/follower-scheduler.mjs.map +1 -0
  99. package/dist/follower.cjs +577 -0
  100. package/dist/follower.d.cts +241 -0
  101. package/dist/follower.d.cts.map +1 -0
  102. package/dist/follower.d.mts +241 -0
  103. package/dist/follower.d.mts.map +1 -0
  104. package/dist/follower.mjs +571 -0
  105. package/dist/follower.mjs.map +1 -0
  106. package/dist/index-provider.cjs +1166 -0
  107. package/dist/index-provider.d.cts +175 -0
  108. package/dist/index-provider.d.cts.map +1 -0
  109. package/dist/index-provider.d.mts +175 -0
  110. package/dist/index-provider.d.mts.map +1 -0
  111. package/dist/index-provider.mjs +1167 -0
  112. package/dist/index-provider.mjs.map +1 -0
  113. package/dist/index.cjs +53 -0
  114. package/dist/index.d.cts +22 -0
  115. package/dist/index.d.mts +22 -0
  116. package/dist/index.mjs +20 -0
  117. package/dist/keys/exact.cjs +20 -0
  118. package/dist/keys/exact.mjs +20 -0
  119. package/dist/keys/exact.mjs.map +1 -0
  120. package/dist/keys/location.cjs +13 -0
  121. package/dist/keys/location.mjs +13 -0
  122. package/dist/keys/location.mjs.map +1 -0
  123. package/dist/keys/normalized.cjs +62 -0
  124. package/dist/keys/normalized.mjs +63 -0
  125. package/dist/keys/normalized.mjs.map +1 -0
  126. package/dist/keys/person.cjs +15 -0
  127. package/dist/keys/person.mjs +15 -0
  128. package/dist/keys/person.mjs.map +1 -0
  129. package/dist/keys/registry.cjs +45 -0
  130. package/dist/keys/registry.d.cts +15 -0
  131. package/dist/keys/registry.d.cts.map +1 -0
  132. package/dist/keys/registry.d.mts +15 -0
  133. package/dist/keys/registry.d.mts.map +1 -0
  134. package/dist/keys/registry.mjs +46 -0
  135. package/dist/keys/registry.mjs.map +1 -0
  136. package/dist/keys/time.cjs +128 -0
  137. package/dist/keys/time.mjs +128 -0
  138. package/dist/keys/time.mjs.map +1 -0
  139. package/dist/keys/topic.cjs +23 -0
  140. package/dist/keys/topic.mjs +23 -0
  141. package/dist/keys/topic.mjs.map +1 -0
  142. package/dist/manifest-index.cjs +29 -0
  143. package/dist/manifest-index.d.cts +24 -0
  144. package/dist/manifest-index.d.cts.map +1 -0
  145. package/dist/manifest-index.d.mts +24 -0
  146. package/dist/manifest-index.d.mts.map +1 -0
  147. package/dist/manifest-index.mjs +28 -0
  148. package/dist/manifest-index.mjs.map +1 -0
  149. package/dist/offline-rebuild.cjs +48 -0
  150. package/dist/offline-rebuild.d.cts +57 -0
  151. package/dist/offline-rebuild.d.cts.map +1 -0
  152. package/dist/offline-rebuild.d.mts +57 -0
  153. package/dist/offline-rebuild.d.mts.map +1 -0
  154. package/dist/offline-rebuild.mjs +49 -0
  155. package/dist/offline-rebuild.mjs.map +1 -0
  156. package/dist/query/engine.cjs +433 -0
  157. package/dist/query/engine.d.cts +34 -0
  158. package/dist/query/engine.d.cts.map +1 -0
  159. package/dist/query/engine.d.mts +34 -0
  160. package/dist/query/engine.d.mts.map +1 -0
  161. package/dist/query/engine.mjs +433 -0
  162. package/dist/query/engine.mjs.map +1 -0
  163. package/dist/query/limits.cjs +12 -0
  164. package/dist/query/limits.mjs +12 -0
  165. package/dist/query/limits.mjs.map +1 -0
  166. package/dist/text/cjk.cjs +30 -0
  167. package/dist/text/cjk.d.cts +24 -0
  168. package/dist/text/cjk.d.cts.map +1 -0
  169. package/dist/text/cjk.d.mts +24 -0
  170. package/dist/text/cjk.d.mts.map +1 -0
  171. package/dist/text/cjk.mjs +30 -0
  172. package/dist/text/cjk.mjs.map +1 -0
  173. package/dist/text/fts.cjs +244 -0
  174. package/dist/text/fts.mjs +245 -0
  175. package/dist/text/fts.mjs.map +1 -0
  176. package/dist/text/host.cjs +39 -0
  177. package/dist/text/host.mjs +38 -0
  178. package/dist/text/host.mjs.map +1 -0
  179. package/dist/types.cjs +44 -0
  180. package/dist/types.d.cts +393 -0
  181. package/dist/types.d.cts.map +1 -0
  182. package/dist/types.d.mts +393 -0
  183. package/dist/types.d.mts.map +1 -0
  184. package/dist/types.mjs +42 -0
  185. package/dist/types.mjs.map +1 -0
  186. package/package.json +59 -0
@@ -0,0 +1,188 @@
1
+ import { sql } from "drizzle-orm";
2
+
3
+ //#region src/backend/schema.ts
4
+ const DEFAULT_FTS_TOKENIZER = "unicode61 remove_diacritics 2";
5
+ /**
6
+ * Schema version. Bumped to 2 when the per-caller tenant `scope` column landed
7
+ * on every data table (cross-user isolation, mirroring the D1 backend). Bumped
8
+ * to 3 when `idx_index_cursor.served_at` landed for scheduler fairness. A v1
9
+ * file has NO `scope` column and cannot be back-filled (the tenant scope is not
10
+ * recoverable from `entry_path`), so `migrateToScopedSchema` DROPS the derived
11
+ * index (data tables + follower cursors) and lets the changelog follower
12
+ * re-index from seq 0 — the index is a rebuildable derived model.
13
+ */
14
+ const SCHEMA_VERSION = 3;
15
+ async function createSchema(db, opts = {}) {
16
+ const tokenizer = opts.ftsTokenizer ?? DEFAULT_FTS_TOKENIZER;
17
+ await migrateToScopedSchema(db);
18
+ await renameAnchorTables(db);
19
+ await db.run(sql.raw(`
20
+ CREATE TABLE IF NOT EXISTS ds_indexed_entry (
21
+ scope TEXT NOT NULL,
22
+ entry_path TEXT NOT NULL,
23
+ source_path TEXT,
24
+ source_hash TEXT,
25
+ domain TEXT NOT NULL,
26
+ state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'stale', 'removed')),
27
+ extractor_id TEXT,
28
+ indexed_at TEXT NOT NULL,
29
+ updated_at TEXT NOT NULL,
30
+ anchor_count INTEGER DEFAULT 0,
31
+ meta TEXT,
32
+ PRIMARY KEY (scope, entry_path)
33
+ )
34
+ `));
35
+ await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS idx_entry_domain ON ds_indexed_entry(scope, domain)`));
36
+ await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS idx_entry_state ON ds_indexed_entry(scope, state)`));
37
+ await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS idx_entry_extractor ON ds_indexed_entry(scope, extractor_id)`));
38
+ await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS idx_entry_source ON ds_indexed_entry(scope, source_path)`));
39
+ await db.run(sql.raw(`
40
+ CREATE TABLE IF NOT EXISTS idx_anchor_data (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ scope TEXT NOT NULL,
43
+ entry_path TEXT NOT NULL,
44
+ anchor_type TEXT NOT NULL,
45
+ confidence REAL NOT NULL,
46
+ raw_anchor TEXT NOT NULL
47
+ )
48
+ `));
49
+ await db.run(sql.raw(`
50
+ CREATE TABLE IF NOT EXISTS idx_anchor_key (
51
+ scope TEXT NOT NULL,
52
+ index_key TEXT,
53
+ entry_path TEXT,
54
+ anchor_type TEXT,
55
+ confidence REAL,
56
+ PRIMARY KEY (scope, index_key, entry_path, anchor_type)
57
+ ) WITHOUT ROWID
58
+ `));
59
+ await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS idx_ad_path ON idx_anchor_data(scope, entry_path)`));
60
+ await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS idx_ak_path ON idx_anchor_key(scope, entry_path)`));
61
+ await db.run(sql.raw(`
62
+ CREATE TABLE IF NOT EXISTS idx_embedding_vector (
63
+ scope TEXT NOT NULL,
64
+ entry_path TEXT NOT NULL,
65
+ embedding TEXT,
66
+ embedding_blob BLOB,
67
+ dimension INTEGER NOT NULL,
68
+ PRIMARY KEY (scope, entry_path)
69
+ )
70
+ `));
71
+ if ((await db.all(sql.raw("SELECT name FROM sqlite_master WHERE type='table' AND name='idx_fts'"))).length === 0) await db.run(sql.raw(`
72
+ CREATE VIRTUAL TABLE idx_fts USING fts5(
73
+ scope UNINDEXED,
74
+ entry_path UNINDEXED,
75
+ summary,
76
+ tokenize='${tokenizer.replace(/'/g, "''")}'
77
+ )
78
+ `));
79
+ await ensureFtsRowidMapping(db);
80
+ await db.run(sql.raw(`
81
+ CREATE TABLE IF NOT EXISTS idx_index_cursor (
82
+ consumer_id TEXT NOT NULL PRIMARY KEY,
83
+ last_seq INTEGER NOT NULL DEFAULT 0,
84
+ watermarks TEXT,
85
+ updated_at INTEGER NOT NULL,
86
+ served_at INTEGER,
87
+ instance_did TEXT,
88
+ domains TEXT
89
+ )
90
+ `));
91
+ await ensureCursorServedAt(db);
92
+ await setSchemaVersion(db, SCHEMA_VERSION);
93
+ }
94
+ /**
95
+ * Drop the v1 (scope-less) derived index in place so `createSchema` can rebuild
96
+ * it with the tenant `scope` column. A v1 file is detected by the ABSENCE of a
97
+ * `scope` column on `ds_indexed_entry` (the version table doesn't exist yet).
98
+ *
99
+ * Data tables AND the follower cursors are dropped together: keeping the
100
+ * cursors would leave the follower believing it is already caught up
101
+ * (`last_seq` high) against now-empty tables, so it would never re-index. With
102
+ * the cursors gone, `initCursor` recreates them at seq 0 and the follower
103
+ * re-materializes every tenant's index from the changelog.
104
+ *
105
+ * Idempotent: on a fresh db (no `ds_indexed_entry` yet) and on an already-v2 db
106
+ * it is a no-op. The one-time drop on a shared file rebuilds the index for ALL
107
+ * tenants at once; acceptable because the index is a derived model.
108
+ */
109
+ async function migrateToScopedSchema(db) {
110
+ if ((await db.all(sql.raw("SELECT name FROM sqlite_master WHERE type='table' AND name='ds_indexed_entry'"))).length === 0) return;
111
+ if ((await db.all(sql.raw(`PRAGMA table_info(ds_indexed_entry)`))).some((c) => c.name === "scope")) return;
112
+ for (const table of [
113
+ "ds_indexed_entry",
114
+ "arc_anchor_data",
115
+ "arc_anchor_key",
116
+ "idx_anchor_data",
117
+ "idx_anchor_key",
118
+ "idx_embedding_vector",
119
+ "idx_fts",
120
+ "idx_fts_rowid",
121
+ "idx_index_cursor"
122
+ ]) await db.run(sql.raw(`DROP TABLE IF EXISTS ${table}`));
123
+ }
124
+ /**
125
+ * Rename the anchor tables from their legacy `arc_` prefix to the correct
126
+ * `idx_` module prefix. These tables are owned by the index provider — the same
127
+ * module as idx_fts / idx_embedding_vector — and were mis-prefixed `arc_`, a
128
+ * prefix reserved for `runtimes/*` Arc-system state (scheduler). Data-preserving:
129
+ * `ALTER TABLE … RENAME` keeps every row and its secondary indexes, so no
130
+ * re-index / follower-cursor reset is needed.
131
+ *
132
+ * Idempotent and safe on fresh / already-migrated / interrupted dbs: renames
133
+ * only when the legacy table exists and the target does not. If BOTH exist (an
134
+ * interrupted prior run), the legacy copy is dropped in favour of the
135
+ * already-created idx_ table. Must run AFTER migrateToScopedSchema (a v1 db has
136
+ * its anchor tables dropped there) and BEFORE the CREATE TABLE IF NOT EXISTS in
137
+ * createSchema (which would otherwise create an empty idx_ table alongside the
138
+ * populated arc_ one).
139
+ */
140
+ async function renameAnchorTables(db) {
141
+ const pairs = [["arc_anchor_data", "idx_anchor_data"], ["arc_anchor_key", "idx_anchor_key"]];
142
+ const names = pairs.flat().map((n) => `'${n}'`);
143
+ const rows = await db.all(sql.raw(`SELECT name FROM sqlite_master WHERE type='table' AND name IN (${names.join(", ")})`));
144
+ const present = new Set(rows.map((r) => r.name));
145
+ for (const [legacy, next] of pairs) {
146
+ if (!present.has(legacy)) continue;
147
+ if (present.has(next)) await db.run(sql.raw(`DROP TABLE IF EXISTS ${legacy}`));
148
+ else await db.run(sql.raw(`ALTER TABLE ${legacy} RENAME TO ${next}`));
149
+ }
150
+ }
151
+ /**
152
+ * Create the `idx_fts_rowid` mapping table the first time this db sees it,
153
+ * backfilling it from any pre-existing `idx_fts` rows (issue #1103). The
154
+ * backfill maps every existing FTS row to its own rowid so a later `fts_write`
155
+ * REPLACEs in place instead of leaving a duplicate for the same
156
+ * (scope, entry_path) — silently doubling search results for that entry.
157
+ *
158
+ * Race-safe and idempotent (matters on shared backends where two contexts may
159
+ * init at once, and there is no enclosing transaction):
160
+ * - `CREATE TABLE IF NOT EXISTS` never throws on a concurrent create.
161
+ * - The one-time backfill runs only while the mapping is still empty (a cheap
162
+ * LIMIT-1 probe, NOT a scan of `idx_fts`), so steady state costs nothing.
163
+ * - `INSERT OR IGNORE` makes the backfill itself idempotent, so a racing
164
+ * backfill (or a re-run) can't collide on the PK/UNIQUE.
165
+ */
166
+ async function ensureFtsRowidMapping(db) {
167
+ await db.run(sql.raw(`
168
+ CREATE TABLE IF NOT EXISTS idx_fts_rowid (
169
+ fts_rowid INTEGER PRIMARY KEY AUTOINCREMENT,
170
+ scope TEXT NOT NULL,
171
+ entry_path TEXT NOT NULL,
172
+ UNIQUE (scope, entry_path)
173
+ )
174
+ `));
175
+ if ((await db.all(sql.raw("SELECT 1 AS one FROM idx_fts_rowid LIMIT 1"))).length > 0) return;
176
+ await db.run(sql.raw(`INSERT OR IGNORE INTO idx_fts_rowid (fts_rowid, scope, entry_path) SELECT rowid, scope, entry_path FROM idx_fts`));
177
+ }
178
+ async function ensureCursorServedAt(db) {
179
+ if ((await db.all(sql.raw("PRAGMA table_info(idx_index_cursor)"))).some((c) => c.name === "served_at")) return;
180
+ await db.run(sql.raw("ALTER TABLE idx_index_cursor ADD COLUMN served_at INTEGER"));
181
+ }
182
+ async function setSchemaVersion(db, version) {
183
+ await db.run(sql.raw(`PRAGMA user_version = ${version}`));
184
+ }
185
+
186
+ //#endregion
187
+ export { createSchema };
188
+ //# sourceMappingURL=schema.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.mjs","names":[],"sources":["../../src/backend/schema.ts"],"sourcesContent":["/**\n * Schema initialization for AFS Index Provider.\n *\n * Creates tables via the SQLiteAFS database:\n * - ds_indexed_entry: main entry metadata\n * - idx_anchor_data: raw anchor storage\n * - idx_anchor_key: generated keys for fast lookup (WITHOUT ROWID)\n * - idx_fts: FTS5 virtual table for summary search\n * - idx_fts_rowid: (scope, entry_path) -> idx_fts rowid mapping (issue #1103),\n * used by fts_write/fts_delete to delete by rowid (O(1)) instead of by\n * (scope, entry_path) (full FTS5 table scan — those columns are UNINDEXED)\n * - idx_index_cursor: changelog-follower per-(scope, consumer_id) progress cursor\n *\n * The FTS5 tokenizer is configurable. Default is `unicode61 remove_diacritics 2`\n * which suits English/Latin text. CJK content (Chinese, Japanese, Korean)\n * has no whitespace, so unicode61 alone treats long strings as a single\n * token. We compensate at write+query time via `segmentCJK` (char-level\n * spaces around CJK code points), which works but indexes per-character so\n * queries like `性别` get split to `性 OR 别`.\n *\n * Alternative: `trigram` tokenizer is FTS5's built-in 3-gram tokenizer,\n * which works without segmentation for CJK because every 3-char window is\n * a token. Trade-off is bigger index + slightly slower writes for better\n * recall on multi-character CJK terms. Pass via AFSIndexOptions.ftsTokenizer\n * to opt in. See planning/memory-system-improvements/design.md §P3.\n */\n\nimport type { SqliteDatabase } from \"@aigne/afs-sqlite\";\nimport { sql } from \"drizzle-orm\";\n\nexport const DEFAULT_FTS_TOKENIZER = \"unicode61 remove_diacritics 2\";\n\n/**\n * Schema version. Bumped to 2 when the per-caller tenant `scope` column landed\n * on every data table (cross-user isolation, mirroring the D1 backend). Bumped\n * to 3 when `idx_index_cursor.served_at` landed for scheduler fairness. A v1\n * file has NO `scope` column and cannot be back-filled (the tenant scope is not\n * recoverable from `entry_path`), so `migrateToScopedSchema` DROPS the derived\n * index (data tables + follower cursors) and lets the changelog follower\n * re-index from seq 0 — the index is a rebuildable derived model.\n */\nconst SCHEMA_VERSION = 3;\n\nexport async function createSchema(\n db: SqliteDatabase,\n opts: { ftsTokenizer?: string } = {},\n): Promise<void> {\n const tokenizer = opts.ftsTokenizer ?? DEFAULT_FTS_TOKENIZER;\n\n // One-time upgrade from the v1 (scope-less) schema. Must run BEFORE the\n // CREATE TABLE IF NOT EXISTS below, which would otherwise no-op against the\n // stale v1 tables and leave them without the `scope` column.\n await migrateToScopedSchema(db);\n\n // One-time rename of the anchor tables from the mis-applied `arc_` prefix to\n // the correct `idx_` module prefix. Runs AFTER migrateToScopedSchema (which\n // drops v1 tables outright) and BEFORE the CREATE TABLE IF NOT EXISTS below,\n // so a v2/v3 db keeps its anchor rows via ALTER RENAME instead of orphaning\n // them behind the new name.\n await renameAnchorTables(db);\n\n await db.run(\n sql.raw(`\n CREATE TABLE IF NOT EXISTS ds_indexed_entry (\n scope TEXT NOT NULL,\n entry_path TEXT NOT NULL,\n source_path TEXT,\n source_hash TEXT,\n domain TEXT NOT NULL,\n state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'stale', 'removed')),\n extractor_id TEXT,\n indexed_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n anchor_count INTEGER DEFAULT 0,\n meta TEXT,\n PRIMARY KEY (scope, entry_path)\n )\n `),\n );\n\n // Scope-leading composite indexes: every hot filter (boundDomains, markStale\n // by state, source lookup) is tenant-scoped, so `scope` must lead the index\n // to stay selective on the shared file.\n await db.run(\n sql.raw(`CREATE INDEX IF NOT EXISTS idx_entry_domain ON ds_indexed_entry(scope, domain)`),\n );\n await db.run(\n sql.raw(`CREATE INDEX IF NOT EXISTS idx_entry_state ON ds_indexed_entry(scope, state)`),\n );\n await db.run(\n sql.raw(\n `CREATE INDEX IF NOT EXISTS idx_entry_extractor ON ds_indexed_entry(scope, extractor_id)`,\n ),\n );\n await db.run(\n sql.raw(\n `CREATE INDEX IF NOT EXISTS idx_entry_source ON ds_indexed_entry(scope, source_path)`,\n ),\n );\n\n await db.run(\n sql.raw(`\n CREATE TABLE IF NOT EXISTS idx_anchor_data (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n scope TEXT NOT NULL,\n entry_path TEXT NOT NULL,\n anchor_type TEXT NOT NULL,\n confidence REAL NOT NULL,\n raw_anchor TEXT NOT NULL\n )\n `),\n );\n\n await db.run(\n sql.raw(`\n CREATE TABLE IF NOT EXISTS idx_anchor_key (\n scope TEXT NOT NULL,\n index_key TEXT,\n entry_path TEXT,\n anchor_type TEXT,\n confidence REAL,\n PRIMARY KEY (scope, index_key, entry_path, anchor_type)\n ) WITHOUT ROWID\n `),\n );\n\n await db.run(\n sql.raw(`CREATE INDEX IF NOT EXISTS idx_ad_path ON idx_anchor_data(scope, entry_path)`),\n );\n await db.run(\n sql.raw(`CREATE INDEX IF NOT EXISTS idx_ak_path ON idx_anchor_key(scope, entry_path)`),\n );\n // No idx_ak_lookup(scope, index_key): idx_anchor_key is WITHOUT ROWID with PK\n // (scope, index_key, entry_path, anchor_type). A secondary index appends the\n // missing PK columns, so idx_ak_lookup is a byte-for-byte copy of the PK — it\n // only doubles anchor_key write cost and the planner ignores it for\n // `index_key IN (...)` anyway. queryAnchors seeks the PK directly by rewriting\n // the IN into a UNION of equalities (issue #1120), so no index is needed here.\n\n // Embedding storage (brute-force vector search, no extensions required).\n //\n // Vectors stored as Float32Array bytes in `embedding_blob` (4 bytes/dim,\n // 6KB for a 1536-d vector vs ~24KB JSON). The legacy `embedding` TEXT\n // column is kept readable for old rows but new writes only populate the\n // blob — see `embedding_write` action.\n await db.run(\n sql.raw(`\n CREATE TABLE IF NOT EXISTS idx_embedding_vector (\n scope TEXT NOT NULL,\n entry_path TEXT NOT NULL,\n embedding TEXT,\n embedding_blob BLOB,\n dimension INTEGER NOT NULL,\n PRIMARY KEY (scope, entry_path)\n )\n `),\n );\n\n // FTS5 virtual tables don't support IF NOT EXISTS in all SQLite versions.\n // `scope` is an extra UNINDEXED column (real tenant scope, filtered as\n // `idx_fts.scope = ?`) — distinct from the `scope:\"/domain\"` path-prefix\n // shorthand some callers pass to fts_query, which stays a domain filter.\n const ftsCheck = await db.all<{ name: string }>(\n sql.raw(\"SELECT name FROM sqlite_master WHERE type='table' AND name='idx_fts'\"),\n );\n if (ftsCheck.length === 0) {\n await db.run(\n sql.raw(`\n CREATE VIRTUAL TABLE idx_fts USING fts5(\n scope UNINDEXED,\n entry_path UNINDEXED,\n summary,\n tokenize='${tokenizer.replace(/'/g, \"''\")}'\n )\n `),\n );\n }\n\n // (scope, entry_path) -> idx_fts rowid mapping (issue #1103). A dedicated\n // table rather than a column on `ds_indexed_entry`: `writeSummary`/\n // `removeSummary` are called independently of `writeEntry`/`removeEntry`\n // in this codebase (see test/backend/fts.test.ts), so an entry-keyed\n // mapping could go stale or simply not exist while FTS rows do.\n await ensureFtsRowidMapping(db);\n\n await db.run(\n sql.raw(`\n CREATE TABLE IF NOT EXISTS idx_index_cursor (\n consumer_id TEXT NOT NULL PRIMARY KEY,\n last_seq INTEGER NOT NULL DEFAULT 0,\n watermarks TEXT,\n updated_at INTEGER NOT NULL,\n served_at INTEGER,\n instance_did TEXT,\n domains TEXT\n )\n `),\n );\n await ensureCursorServedAt(db);\n\n await setSchemaVersion(db, SCHEMA_VERSION);\n}\n\n/**\n * Drop the v1 (scope-less) derived index in place so `createSchema` can rebuild\n * it with the tenant `scope` column. A v1 file is detected by the ABSENCE of a\n * `scope` column on `ds_indexed_entry` (the version table doesn't exist yet).\n *\n * Data tables AND the follower cursors are dropped together: keeping the\n * cursors would leave the follower believing it is already caught up\n * (`last_seq` high) against now-empty tables, so it would never re-index. With\n * the cursors gone, `initCursor` recreates them at seq 0 and the follower\n * re-materializes every tenant's index from the changelog.\n *\n * Idempotent: on a fresh db (no `ds_indexed_entry` yet) and on an already-v2 db\n * it is a no-op. The one-time drop on a shared file rebuilds the index for ALL\n * tenants at once; acceptable because the index is a derived model.\n */\nasync function migrateToScopedSchema(db: SqliteDatabase): Promise<void> {\n const entryTable = await db.all<{ name: string }>(\n sql.raw(\"SELECT name FROM sqlite_master WHERE type='table' AND name='ds_indexed_entry'\"),\n );\n if (entryTable.length === 0) return; // fresh db — nothing to migrate\n\n const cols = await db.all<{ name: string }>(sql.raw(`PRAGMA table_info(ds_indexed_entry)`));\n if (cols.some((c) => c.name === \"scope\")) return; // already v2\n\n for (const table of [\n \"ds_indexed_entry\",\n // Both prefixes: a v1 file predates the arc_→idx_ anchor rename, so its\n // anchor tables may still carry either name. DROP IF EXISTS both.\n \"arc_anchor_data\",\n \"arc_anchor_key\",\n \"idx_anchor_data\",\n \"idx_anchor_key\",\n \"idx_embedding_vector\",\n \"idx_fts\",\n \"idx_fts_rowid\",\n \"idx_index_cursor\",\n ]) {\n await db.run(sql.raw(`DROP TABLE IF EXISTS ${table}`));\n }\n}\n\n/**\n * Rename the anchor tables from their legacy `arc_` prefix to the correct\n * `idx_` module prefix. These tables are owned by the index provider — the same\n * module as idx_fts / idx_embedding_vector — and were mis-prefixed `arc_`, a\n * prefix reserved for `runtimes/*` Arc-system state (scheduler). Data-preserving:\n * `ALTER TABLE … RENAME` keeps every row and its secondary indexes, so no\n * re-index / follower-cursor reset is needed.\n *\n * Idempotent and safe on fresh / already-migrated / interrupted dbs: renames\n * only when the legacy table exists and the target does not. If BOTH exist (an\n * interrupted prior run), the legacy copy is dropped in favour of the\n * already-created idx_ table. Must run AFTER migrateToScopedSchema (a v1 db has\n * its anchor tables dropped there) and BEFORE the CREATE TABLE IF NOT EXISTS in\n * createSchema (which would otherwise create an empty idx_ table alongside the\n * populated arc_ one).\n */\nasync function renameAnchorTables(db: SqliteDatabase): Promise<void> {\n const pairs: Array<[legacy: string, next: string]> = [\n [\"arc_anchor_data\", \"idx_anchor_data\"],\n [\"arc_anchor_key\", \"idx_anchor_key\"],\n ];\n // Single sqlite_master read for all four names (kept in step with\n // d1-index-backend.ts: one O(1) probe, no per-open scan of the catalog).\n const names = pairs.flat().map((n) => `'${n}'`);\n const rows = await db.all<{ name: string }>(\n sql.raw(`SELECT name FROM sqlite_master WHERE type='table' AND name IN (${names.join(\", \")})`),\n );\n const present = new Set(rows.map((r) => r.name));\n for (const [legacy, next] of pairs) {\n if (!present.has(legacy)) continue; // fresh db or already renamed\n if (present.has(next)) {\n await db.run(sql.raw(`DROP TABLE IF EXISTS ${legacy}`)); // interrupted migration — idx_ wins\n } else {\n await db.run(sql.raw(`ALTER TABLE ${legacy} RENAME TO ${next}`));\n }\n }\n}\n\n/**\n * Create the `idx_fts_rowid` mapping table the first time this db sees it,\n * backfilling it from any pre-existing `idx_fts` rows (issue #1103). The\n * backfill maps every existing FTS row to its own rowid so a later `fts_write`\n * REPLACEs in place instead of leaving a duplicate for the same\n * (scope, entry_path) — silently doubling search results for that entry.\n *\n * Race-safe and idempotent (matters on shared backends where two contexts may\n * init at once, and there is no enclosing transaction):\n * - `CREATE TABLE IF NOT EXISTS` never throws on a concurrent create.\n * - The one-time backfill runs only while the mapping is still empty (a cheap\n * LIMIT-1 probe, NOT a scan of `idx_fts`), so steady state costs nothing.\n * - `INSERT OR IGNORE` makes the backfill itself idempotent, so a racing\n * backfill (or a re-run) can't collide on the PK/UNIQUE.\n */\nasync function ensureFtsRowidMapping(db: SqliteDatabase): Promise<void> {\n await db.run(\n sql.raw(`\n CREATE TABLE IF NOT EXISTS idx_fts_rowid (\n fts_rowid INTEGER PRIMARY KEY AUTOINCREMENT,\n scope TEXT NOT NULL,\n entry_path TEXT NOT NULL,\n UNIQUE (scope, entry_path)\n )\n `),\n );\n\n const populated = await db.all<{ one: number }>(\n sql.raw(\"SELECT 1 AS one FROM idx_fts_rowid LIMIT 1\"),\n );\n if (populated.length > 0) return; // already backfilled / in use — no scan.\n\n // Preserve existing FTS rowids as the owned fts_rowid; explicit inserts into\n // the AUTOINCREMENT column also seed sqlite_sequence to MAX(rowid) so\n // newly-allocated ids never collide with a live idx_fts row.\n await db.run(\n sql.raw(\n `INSERT OR IGNORE INTO idx_fts_rowid (fts_rowid, scope, entry_path) SELECT rowid, scope, entry_path FROM idx_fts`,\n ),\n );\n}\n\nasync function ensureCursorServedAt(db: SqliteDatabase): Promise<void> {\n const cols = await db.all<{ name: string }>(sql.raw(\"PRAGMA table_info(idx_index_cursor)\"));\n if (cols.some((c) => c.name === \"served_at\")) return;\n await db.run(sql.raw(\"ALTER TABLE idx_index_cursor ADD COLUMN served_at INTEGER\"));\n}\n\nasync function setSchemaVersion(db: SqliteDatabase, version: number): Promise<void> {\n // PRAGMA user_version is a single 32-bit slot on the db file — perfect for a\n // monotonic schema marker, no extra table, no row to filter by tenant.\n await db.run(sql.raw(`PRAGMA user_version = ${version}`));\n}\n"],"mappings":"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"}