@openparachute/vault 0.2.3 → 0.3.0-rc.1
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/.claude/settings.local.json +8 -0
- package/CHANGELOG.md +70 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +603 -19
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +157 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +92 -0
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +29 -1
- package/docs/HTTP_API.md +105 -1
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/cli.ts +179 -121
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +136 -0
- package/src/scopes.ts +105 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +57 -5
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +583 -0
- package/src/transcription-worker.ts +346 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { SqliteStore } from "./store.js";
|
|
4
|
+
import { generateMcpTools, type McpToolDef } from "./mcp.js";
|
|
5
|
+
import {
|
|
6
|
+
IndexedFieldError,
|
|
7
|
+
declareField,
|
|
8
|
+
getIndexedField,
|
|
9
|
+
listIndexedFields,
|
|
10
|
+
rebuildIndexes,
|
|
11
|
+
releaseField,
|
|
12
|
+
TYPE_MAP,
|
|
13
|
+
validateFieldName,
|
|
14
|
+
} from "./indexed-fields.js";
|
|
15
|
+
|
|
16
|
+
let db: Database;
|
|
17
|
+
let store: SqliteStore;
|
|
18
|
+
let tools: Record<string, McpToolDef>;
|
|
19
|
+
|
|
20
|
+
function findTool(name: string): McpToolDef {
|
|
21
|
+
const t = tools[name];
|
|
22
|
+
if (!t) throw new Error(`tool not found: ${name}`);
|
|
23
|
+
return t;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
db = new Database(":memory:");
|
|
28
|
+
store = new SqliteStore(db);
|
|
29
|
+
tools = Object.fromEntries(generateMcpTools(store).map((t) => [t.name, t]));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function notesColumns(): string[] {
|
|
33
|
+
return (db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map(
|
|
34
|
+
(r) => r.name,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function notesIndexes(): string[] {
|
|
39
|
+
return (
|
|
40
|
+
db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'").all() as {
|
|
41
|
+
name: string;
|
|
42
|
+
}[]
|
|
43
|
+
).map((r) => r.name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("indexed-fields: module", () => {
|
|
47
|
+
it("schema creates the indexed_fields table", () => {
|
|
48
|
+
const row = db
|
|
49
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='indexed_fields'")
|
|
50
|
+
.get();
|
|
51
|
+
expect(row).toBeTruthy();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("validateFieldName accepts safe identifiers", () => {
|
|
55
|
+
expect(() => validateFieldName("status")).not.toThrow();
|
|
56
|
+
expect(() => validateFieldName("first_seen_at")).not.toThrow();
|
|
57
|
+
expect(() => validateFieldName("_private")).not.toThrow();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("validateFieldName rejects unsafe names", () => {
|
|
61
|
+
expect(() => validateFieldName("has-dash")).toThrow(IndexedFieldError);
|
|
62
|
+
expect(() => validateFieldName("1leading_digit")).toThrow(IndexedFieldError);
|
|
63
|
+
expect(() => validateFieldName("has space")).toThrow(IndexedFieldError);
|
|
64
|
+
expect(() => validateFieldName("'; DROP TABLE notes; --")).toThrow(IndexedFieldError);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("TYPE_MAP covers string/integer/boolean", () => {
|
|
68
|
+
expect(TYPE_MAP.string).toBe("TEXT");
|
|
69
|
+
expect(TYPE_MAP.integer).toBe("INTEGER");
|
|
70
|
+
expect(TYPE_MAP.boolean).toBe("INTEGER");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("declareField creates column + index on first declaration", () => {
|
|
74
|
+
declareField(db, "status", "TEXT", "project");
|
|
75
|
+
expect(notesColumns()).toContain("meta_status");
|
|
76
|
+
expect(notesIndexes()).toContain("idx_meta_status");
|
|
77
|
+
const row = getIndexedField(db, "status");
|
|
78
|
+
expect(row?.sqliteType).toBe("TEXT");
|
|
79
|
+
expect(row?.declarerTags).toEqual(["project"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("declareField adds second declarer without duplicating the column", () => {
|
|
83
|
+
declareField(db, "status", "TEXT", "project");
|
|
84
|
+
declareField(db, "status", "TEXT", "ticket");
|
|
85
|
+
const row = getIndexedField(db, "status");
|
|
86
|
+
expect(row?.declarerTags).toEqual(["project", "ticket"]);
|
|
87
|
+
const statusColumns = notesColumns().filter((c) => c === "meta_status");
|
|
88
|
+
expect(statusColumns).toHaveLength(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("declareField is idempotent when the tag is already a declarer", () => {
|
|
92
|
+
declareField(db, "status", "TEXT", "project");
|
|
93
|
+
declareField(db, "status", "TEXT", "project");
|
|
94
|
+
const row = getIndexedField(db, "status");
|
|
95
|
+
expect(row?.declarerTags).toEqual(["project"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("declareField throws on type mismatch with other declarers", () => {
|
|
99
|
+
declareField(db, "priority", "INTEGER", "project");
|
|
100
|
+
expect(() => declareField(db, "priority", "TEXT", "ticket")).toThrow(IndexedFieldError);
|
|
101
|
+
const row = getIndexedField(db, "priority");
|
|
102
|
+
expect(row?.sqliteType).toBe("INTEGER");
|
|
103
|
+
expect(row?.declarerTags).toEqual(["project"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("declareField allows a sole declarer to change type (drops + recreates)", () => {
|
|
107
|
+
declareField(db, "priority", "TEXT", "project");
|
|
108
|
+
declareField(db, "priority", "INTEGER", "project");
|
|
109
|
+
const row = getIndexedField(db, "priority");
|
|
110
|
+
expect(row?.sqliteType).toBe("INTEGER");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("releaseField removes a single declarer but keeps column while others remain", () => {
|
|
114
|
+
declareField(db, "status", "TEXT", "project");
|
|
115
|
+
declareField(db, "status", "TEXT", "ticket");
|
|
116
|
+
const dropped = releaseField(db, "status", "project");
|
|
117
|
+
expect(dropped).toBe(false);
|
|
118
|
+
expect(notesColumns()).toContain("meta_status");
|
|
119
|
+
const row = getIndexedField(db, "status");
|
|
120
|
+
expect(row?.declarerTags).toEqual(["ticket"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("releaseField drops column + index when last declarer leaves", () => {
|
|
124
|
+
declareField(db, "status", "TEXT", "project");
|
|
125
|
+
const dropped = releaseField(db, "status", "project");
|
|
126
|
+
expect(dropped).toBe(true);
|
|
127
|
+
expect(notesColumns()).not.toContain("meta_status");
|
|
128
|
+
expect(notesIndexes()).not.toContain("idx_meta_status");
|
|
129
|
+
expect(getIndexedField(db, "status")).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("releaseField is a no-op for unknown field", () => {
|
|
133
|
+
expect(releaseField(db, "nonexistent", "t")).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("indexed column reflects json_extract of metadata at query time", async () => {
|
|
137
|
+
declareField(db, "priority", "INTEGER", "project");
|
|
138
|
+
await store.createNote("a", { metadata: { priority: 1 } });
|
|
139
|
+
await store.createNote("b", { metadata: { priority: 5 } });
|
|
140
|
+
await store.createNote("c", { metadata: { priority: 3 } });
|
|
141
|
+
const rows = db
|
|
142
|
+
.prepare("SELECT id, meta_priority FROM notes ORDER BY meta_priority")
|
|
143
|
+
.all() as { id: string; meta_priority: number }[];
|
|
144
|
+
expect(rows.map((r) => r.meta_priority)).toEqual([1, 3, 5]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("listIndexedFields returns rows ordered by field", () => {
|
|
148
|
+
declareField(db, "zeta", "TEXT", "t");
|
|
149
|
+
declareField(db, "alpha", "TEXT", "t");
|
|
150
|
+
declareField(db, "mu", "TEXT", "t");
|
|
151
|
+
expect(listIndexedFields(db).map((f) => f.field)).toEqual(["alpha", "mu", "zeta"]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("rebuildIndexes restores columns that are missing from notes", () => {
|
|
155
|
+
declareField(db, "status", "TEXT", "project");
|
|
156
|
+
// Simulate a stale DB: drop the column while leaving the row intact.
|
|
157
|
+
db.exec('DROP INDEX IF EXISTS "idx_meta_status"');
|
|
158
|
+
db.exec('ALTER TABLE notes DROP COLUMN "meta_status"');
|
|
159
|
+
expect(notesColumns()).not.toContain("meta_status");
|
|
160
|
+
rebuildIndexes(db);
|
|
161
|
+
expect(notesColumns()).toContain("meta_status");
|
|
162
|
+
expect(notesIndexes()).toContain("idx_meta_status");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("update-tag: indexed flag", () => {
|
|
167
|
+
it("declaring indexed field creates column + index", async () => {
|
|
168
|
+
await findTool("update-tag").execute({
|
|
169
|
+
tag: "project",
|
|
170
|
+
fields: { status: { type: "string", indexed: true } },
|
|
171
|
+
});
|
|
172
|
+
expect(notesColumns()).toContain("meta_status");
|
|
173
|
+
expect(notesIndexes()).toContain("idx_meta_status");
|
|
174
|
+
expect(getIndexedField(db, "status")?.declarerTags).toEqual(["project"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("second declarer with matching type joins the declarer set", async () => {
|
|
178
|
+
const t = findTool("update-tag");
|
|
179
|
+
await t.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
|
|
180
|
+
await t.execute({ tag: "ticket", fields: { status: { type: "string", indexed: true } } });
|
|
181
|
+
expect(getIndexedField(db, "status")?.declarerTags).toEqual(["project", "ticket"]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("type conflict across declarers throws and names the other tag", async () => {
|
|
185
|
+
const t = findTool("update-tag");
|
|
186
|
+
await t.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
|
|
187
|
+
expect(() =>
|
|
188
|
+
t.execute({ tag: "ticket", fields: { status: { type: "integer", indexed: true } } }),
|
|
189
|
+
).toThrow(/tag "project".*"string"/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("indexed-flag conflict across declarers throws", async () => {
|
|
193
|
+
const t = findTool("update-tag");
|
|
194
|
+
await t.execute({ tag: "project", fields: { priority: { type: "integer", indexed: true } } });
|
|
195
|
+
expect(() =>
|
|
196
|
+
t.execute({ tag: "ticket", fields: { priority: { type: "integer", indexed: false } } }),
|
|
197
|
+
).toThrow(/indexed-flag conflict/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("unsupported field type for indexing throws", async () => {
|
|
201
|
+
expect(() =>
|
|
202
|
+
findTool("update-tag").execute({
|
|
203
|
+
tag: "project",
|
|
204
|
+
fields: { weird: { type: "date", indexed: true } },
|
|
205
|
+
}),
|
|
206
|
+
).toThrow(/unsupported type "date"/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("invalid field name for indexing throws", async () => {
|
|
210
|
+
expect(() =>
|
|
211
|
+
findTool("update-tag").execute({
|
|
212
|
+
tag: "project",
|
|
213
|
+
fields: { "bad-name": { type: "string", indexed: true } },
|
|
214
|
+
}),
|
|
215
|
+
).toThrow(IndexedFieldError);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("rejects non-atomic indexed-flag change while other declarers hold it true", async () => {
|
|
219
|
+
const t = findTool("update-tag");
|
|
220
|
+
await t.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
|
|
221
|
+
await t.execute({ tag: "ticket", fields: { status: { type: "string", indexed: true } } });
|
|
222
|
+
expect(() =>
|
|
223
|
+
t.execute({
|
|
224
|
+
tag: "project",
|
|
225
|
+
fields: { status: { type: "string", indexed: false } },
|
|
226
|
+
}),
|
|
227
|
+
).toThrow(/indexed-flag conflict.*tag "ticket"/);
|
|
228
|
+
expect(getIndexedField(db, "status")?.declarerTags).toEqual(["project", "ticket"]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("last declarer releasing drops column + index", async () => {
|
|
232
|
+
const t = findTool("update-tag");
|
|
233
|
+
await t.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
|
|
234
|
+
await t.execute({ tag: "project", fields: { status: { type: "string", indexed: false } } });
|
|
235
|
+
expect(getIndexedField(db, "status")).toBeNull();
|
|
236
|
+
expect(notesColumns()).not.toContain("meta_status");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("indexing a boolean field maps to INTEGER storage", async () => {
|
|
240
|
+
await findTool("update-tag").execute({
|
|
241
|
+
tag: "project",
|
|
242
|
+
fields: { archived: { type: "boolean", indexed: true } },
|
|
243
|
+
});
|
|
244
|
+
expect(getIndexedField(db, "archived")?.sqliteType).toBe("INTEGER");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("non-indexed fields are not tracked", async () => {
|
|
248
|
+
await findTool("update-tag").execute({
|
|
249
|
+
tag: "project",
|
|
250
|
+
fields: { notes: { type: "string" } },
|
|
251
|
+
});
|
|
252
|
+
expect(listIndexedFields(db)).toEqual([]);
|
|
253
|
+
expect(notesColumns()).not.toContain("meta_notes");
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("delete-tag: indexed fields", () => {
|
|
258
|
+
it("releases indexed fields the tag declared", async () => {
|
|
259
|
+
const update = findTool("update-tag");
|
|
260
|
+
const del = findTool("delete-tag");
|
|
261
|
+
await update.execute({
|
|
262
|
+
tag: "project",
|
|
263
|
+
fields: { status: { type: "string", indexed: true } },
|
|
264
|
+
});
|
|
265
|
+
await del.execute({ tag: "project" });
|
|
266
|
+
expect(getIndexedField(db, "status")).toBeNull();
|
|
267
|
+
expect(notesColumns()).not.toContain("meta_status");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("keeps the column if another tag still declares the field", async () => {
|
|
271
|
+
const update = findTool("update-tag");
|
|
272
|
+
const del = findTool("delete-tag");
|
|
273
|
+
await update.execute({
|
|
274
|
+
tag: "project",
|
|
275
|
+
fields: { status: { type: "string", indexed: true } },
|
|
276
|
+
});
|
|
277
|
+
await update.execute({
|
|
278
|
+
tag: "ticket",
|
|
279
|
+
fields: { status: { type: "string", indexed: true } },
|
|
280
|
+
});
|
|
281
|
+
await del.execute({ tag: "project" });
|
|
282
|
+
expect(getIndexedField(db, "status")?.declarerTags).toEqual(["ticket"]);
|
|
283
|
+
expect(notesColumns()).toContain("meta_status");
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Indexed-field lifecycle — manages generated columns + indexes on `notes`
|
|
3
|
+
* for metadata fields declared `indexed: true` by one or more tag schemas.
|
|
4
|
+
*
|
|
5
|
+
* Semantic: the tag authorizes the index, but the index is universal across
|
|
6
|
+
* all notes (not partitioned by tag). A generated column mirrors
|
|
7
|
+
* `json_extract(metadata, '$.<field>')` and a B-tree index on that column
|
|
8
|
+
* makes operator queries (eq/gt/lt/in/...) and `order_by` fast.
|
|
9
|
+
*
|
|
10
|
+
* Lifetime is tied to the declarer set: the column + index exist as long as
|
|
11
|
+
* at least one tag schema declares `indexed: true` for the field. When the
|
|
12
|
+
* last declarer releases (schema update or `delete-tag`), the column + index
|
|
13
|
+
* are dropped. The `indexed_fields` table is the single source of truth and
|
|
14
|
+
* is used by `rebuildIndexes` on vault init for idempotent reconstruction.
|
|
15
|
+
*
|
|
16
|
+
* See Parachute/Decisions/2026-04-19-metadata-indexing-via-tag-schemas.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Database } from "bun:sqlite";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export type SqliteType = "TEXT" | "INTEGER";
|
|
26
|
+
export type FieldType = "string" | "integer" | "boolean";
|
|
27
|
+
|
|
28
|
+
export const TYPE_MAP: Record<FieldType, SqliteType> = {
|
|
29
|
+
string: "TEXT",
|
|
30
|
+
integer: "INTEGER",
|
|
31
|
+
boolean: "INTEGER",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface IndexedField {
|
|
35
|
+
field: string;
|
|
36
|
+
sqliteType: SqliteType;
|
|
37
|
+
declarerTags: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface IndexedFieldRow {
|
|
41
|
+
field: string;
|
|
42
|
+
sqlite_type: string;
|
|
43
|
+
declarer_tags: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class IndexedFieldError extends Error {
|
|
47
|
+
override name = "IndexedFieldError";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Restrict field names to safe SQL identifiers. This also bounds the
|
|
51
|
+
// generated column and index names, which are derived from the field.
|
|
52
|
+
const FIELD_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/;
|
|
53
|
+
|
|
54
|
+
export function validateFieldName(field: string): void {
|
|
55
|
+
if (!FIELD_NAME_RE.test(field)) {
|
|
56
|
+
throw new IndexedFieldError(
|
|
57
|
+
`invalid field name "${field}": must start with a letter or underscore and contain only [A-Za-z0-9_] (max 63 chars)`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Map a tag-schema field type to the backing SQLite storage class. */
|
|
63
|
+
export function mapFieldType(type: string): SqliteType | null {
|
|
64
|
+
return TYPE_MAP[type as FieldType] ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function columnName(field: string): string {
|
|
68
|
+
return `meta_${field}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function indexName(field: string): string {
|
|
72
|
+
return `idx_meta_${field}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function rowToField(row: IndexedFieldRow): IndexedField {
|
|
76
|
+
let declarerTags: string[] = [];
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(row.declarer_tags);
|
|
79
|
+
if (Array.isArray(parsed)) {
|
|
80
|
+
declarerTags = parsed.filter((t): t is string => typeof t === "string");
|
|
81
|
+
}
|
|
82
|
+
} catch {}
|
|
83
|
+
return {
|
|
84
|
+
field: row.field,
|
|
85
|
+
sqliteType: row.sqlite_type as SqliteType,
|
|
86
|
+
declarerTags,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Queries
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
export function listIndexedFields(db: Database): IndexedField[] {
|
|
95
|
+
const rows = db
|
|
96
|
+
.prepare("SELECT field, sqlite_type, declarer_tags FROM indexed_fields ORDER BY field")
|
|
97
|
+
.all() as IndexedFieldRow[];
|
|
98
|
+
return rows.map(rowToField);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getIndexedField(db: Database, field: string): IndexedField | null {
|
|
102
|
+
const row = db
|
|
103
|
+
.prepare("SELECT field, sqlite_type, declarer_tags FROM indexed_fields WHERE field = ?")
|
|
104
|
+
.get(field) as IndexedFieldRow | undefined;
|
|
105
|
+
return row ? rowToField(row) : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// DDL helpers
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function hasNotesColumn(db: Database, col: string): boolean {
|
|
113
|
+
// `table_xinfo` includes generated (VIRTUAL) columns, which `table_info`
|
|
114
|
+
// omits. We use xinfo here so re-declaration paths detect the existing
|
|
115
|
+
// generated column and stay idempotent.
|
|
116
|
+
const rows = db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[];
|
|
117
|
+
return rows.some((r) => r.name === col);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function createColumnAndIndex(db: Database, field: string, sqliteType: SqliteType): void {
|
|
121
|
+
const col = columnName(field);
|
|
122
|
+
if (!hasNotesColumn(db, col)) {
|
|
123
|
+
// SQLite requires VIRTUAL (not STORED) for generated columns added via
|
|
124
|
+
// ALTER TABLE. VIRTUAL is also what we want here: the value isn't stored
|
|
125
|
+
// twice (just indexed), so writes stay cheap.
|
|
126
|
+
db.exec(
|
|
127
|
+
`ALTER TABLE notes ADD COLUMN "${col}" ${sqliteType} GENERATED ALWAYS AS (json_extract(metadata, '$."${field}"')) VIRTUAL`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
db.exec(`CREATE INDEX IF NOT EXISTS "${indexName(field)}" ON notes("${col}")`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function dropColumnAndIndex(db: Database, field: string): void {
|
|
134
|
+
db.exec(`DROP INDEX IF EXISTS "${indexName(field)}"`);
|
|
135
|
+
if (hasNotesColumn(db, columnName(field))) {
|
|
136
|
+
db.exec(`ALTER TABLE notes DROP COLUMN "${columnName(field)}"`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Lifecycle operations
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Register `tag` as a declarer of indexed `field` with the given storage type.
|
|
146
|
+
*
|
|
147
|
+
* - First declarer: inserts the row, creates the generated column + index.
|
|
148
|
+
* - Additional declarer (matching type): adds tag to the set. Idempotent if
|
|
149
|
+
* the tag is already a declarer.
|
|
150
|
+
* - Type mismatch with an existing *other* declarer: throws. This is a
|
|
151
|
+
* defensive check; the MCP layer should have already rejected the call
|
|
152
|
+
* with a more descriptive cross-tag error message.
|
|
153
|
+
* - Type mismatch when this tag is the sole declarer: drops + recreates the
|
|
154
|
+
* column with the new type. Allowed because no other schema's contract
|
|
155
|
+
* depends on the prior type.
|
|
156
|
+
*/
|
|
157
|
+
export function declareField(
|
|
158
|
+
db: Database,
|
|
159
|
+
field: string,
|
|
160
|
+
sqliteType: SqliteType,
|
|
161
|
+
tag: string,
|
|
162
|
+
): void {
|
|
163
|
+
validateFieldName(field);
|
|
164
|
+
const existing = getIndexedField(db, field);
|
|
165
|
+
if (!existing) {
|
|
166
|
+
db.prepare(
|
|
167
|
+
"INSERT INTO indexed_fields (field, sqlite_type, declarer_tags) VALUES (?, ?, ?)",
|
|
168
|
+
).run(field, sqliteType, JSON.stringify([tag]));
|
|
169
|
+
createColumnAndIndex(db, field, sqliteType);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (existing.sqliteType !== sqliteType) {
|
|
173
|
+
const others = existing.declarerTags.filter((t) => t !== tag);
|
|
174
|
+
if (others.length > 0) {
|
|
175
|
+
throw new IndexedFieldError(
|
|
176
|
+
`field "${field}" is declared by tag(s) [${others.join(", ")}] with sqlite type ${existing.sqliteType}; tag "${tag}" requested ${sqliteType}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
dropColumnAndIndex(db, field);
|
|
180
|
+
db.prepare(
|
|
181
|
+
"UPDATE indexed_fields SET sqlite_type = ?, declarer_tags = ? WHERE field = ?",
|
|
182
|
+
).run(sqliteType, JSON.stringify([tag]), field);
|
|
183
|
+
createColumnAndIndex(db, field, sqliteType);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (existing.declarerTags.includes(tag)) {
|
|
187
|
+
createColumnAndIndex(db, field, sqliteType);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const next = [...existing.declarerTags, tag];
|
|
191
|
+
db.prepare("UPDATE indexed_fields SET declarer_tags = ? WHERE field = ?").run(
|
|
192
|
+
JSON.stringify(next),
|
|
193
|
+
field,
|
|
194
|
+
);
|
|
195
|
+
createColumnAndIndex(db, field, sqliteType);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Remove `tag` from `field`'s declarer set. If the set becomes empty, drop
|
|
200
|
+
* the row, the generated column, and the index. Returns true if the column
|
|
201
|
+
* was dropped.
|
|
202
|
+
*/
|
|
203
|
+
export function releaseField(db: Database, field: string, tag: string): boolean {
|
|
204
|
+
const existing = getIndexedField(db, field);
|
|
205
|
+
if (!existing) return false;
|
|
206
|
+
const next = existing.declarerTags.filter((t) => t !== tag);
|
|
207
|
+
if (next.length === 0) {
|
|
208
|
+
db.prepare("DELETE FROM indexed_fields WHERE field = ?").run(field);
|
|
209
|
+
dropColumnAndIndex(db, field);
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
db.prepare("UPDATE indexed_fields SET declarer_tags = ? WHERE field = ?").run(
|
|
213
|
+
JSON.stringify(next),
|
|
214
|
+
field,
|
|
215
|
+
);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Reconcile the generated columns + indexes with `indexed_fields` rows.
|
|
221
|
+
* Idempotent — safe to call on every vault init.
|
|
222
|
+
*
|
|
223
|
+
* `indexed_fields` is authoritative. Any row without its column/index gets
|
|
224
|
+
* one created. Extras (columns beginning with `meta_` but not backed by a
|
|
225
|
+
* row) are not touched — cleanup happens through the normal release path.
|
|
226
|
+
*/
|
|
227
|
+
export function rebuildIndexes(db: Database): void {
|
|
228
|
+
for (const f of listIndexedFields(db)) {
|
|
229
|
+
try {
|
|
230
|
+
createColumnAndIndex(db, f.field, f.sqliteType);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.error(
|
|
233
|
+
`[indexed-fields] could not rebuild column for "${f.field}":`,
|
|
234
|
+
err,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|