@openparachute/vault 0.6.4-rc.9 → 0.6.5-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/README.md +6 -4
- package/core/src/__fixtures__/golden-vault.ts +125 -0
- package/core/src/__fixtures__/portable-export-golden.json +10 -0
- package/core/src/bare-tag-guard.test.ts +246 -0
- package/core/src/conformance.test.ts +112 -0
- package/core/src/conformance.ts +214 -0
- package/core/src/mcp.ts +313 -316
- package/core/src/notes.ts +73 -57
- package/core/src/portable-md-batching.test.ts +67 -0
- package/core/src/portable-md-golden.test.ts +55 -0
- package/core/src/portable-md.ts +579 -435
- package/core/src/schema.ts +21 -43
- package/core/src/store.ts +79 -11
- package/core/src/tag-hierarchy.ts +26 -0
- package/core/src/txn.test.ts +161 -0
- package/core/src/txn.ts +78 -0
- package/core/src/types.ts +24 -0
- package/package.json +1 -1
- package/src/bare-tag-guard-routes.test.ts +72 -0
- package/src/cli.ts +1 -1
- package/src/first-boot-create.test.ts +155 -0
- package/src/routes.ts +198 -32
- package/src/routing.test.ts +125 -0
- package/src/routing.ts +10 -2
- package/src/self-register.test.ts +4 -4
- package/src/self-register.ts +1 -2
- package/src/server.ts +58 -38
- package/src/storage.test.ts +75 -8
- package/web/ui/dist/assets/{index-UlvD8KHr.css → index-C5XqQM-c.css} +1 -1
- package/web/ui/dist/assets/index-TJ-XN4OZ.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DwYo23aY.js +0 -61
package/core/src/schema.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { normalizePath } from "./paths.js";
|
|
3
3
|
import { rebuildIndexes } from "./indexed-fields.js";
|
|
4
|
+
import { transaction } from "./txn.js";
|
|
4
5
|
|
|
5
6
|
export const SCHEMA_VERSION = 23;
|
|
6
7
|
|
|
@@ -680,8 +681,7 @@ function migrateToV13(db: Database): void {
|
|
|
680
681
|
function migrateToV14(db: Database): void {
|
|
681
682
|
if (!hasTable(db, "tags")) return;
|
|
682
683
|
|
|
683
|
-
db
|
|
684
|
-
try {
|
|
684
|
+
const { copiedSchemas, copiedHierarchy } = transaction(db, () => {
|
|
685
685
|
// 1. ALTER TABLE — additive, idempotent.
|
|
686
686
|
const cols: [string, string][] = [
|
|
687
687
|
["description", "TEXT"],
|
|
@@ -762,16 +762,13 @@ function migrateToV14(db: Database): void {
|
|
|
762
762
|
db.exec("DROP TABLE tag_schemas");
|
|
763
763
|
}
|
|
764
764
|
|
|
765
|
-
|
|
765
|
+
return { copiedSchemas, copiedHierarchy };
|
|
766
|
+
});
|
|
766
767
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
}
|
|
772
|
-
} catch (err) {
|
|
773
|
-
db.exec("ROLLBACK");
|
|
774
|
-
throw err;
|
|
768
|
+
if (copiedSchemas > 0 || copiedHierarchy > 0) {
|
|
769
|
+
console.log(
|
|
770
|
+
`[vault] migrated to schema v14: copied ${copiedSchemas} tag_schemas + ${copiedHierarchy} _tags/* hierarchies onto tags rows`,
|
|
771
|
+
);
|
|
775
772
|
}
|
|
776
773
|
}
|
|
777
774
|
|
|
@@ -806,8 +803,7 @@ function migrateToV15(db: Database): void {
|
|
|
806
803
|
).get()) !== null;
|
|
807
804
|
if (hasSchemas || hasMappings) return;
|
|
808
805
|
|
|
809
|
-
db
|
|
810
|
-
try {
|
|
806
|
+
const { copiedSchemas, copiedMappings } = transaction(db, () => {
|
|
811
807
|
const now = new Date().toISOString();
|
|
812
808
|
let copiedSchemas = 0;
|
|
813
809
|
let copiedMappings = 0;
|
|
@@ -884,16 +880,13 @@ function migrateToV15(db: Database): void {
|
|
|
884
880
|
}
|
|
885
881
|
}
|
|
886
882
|
|
|
887
|
-
|
|
883
|
+
return { copiedSchemas, copiedMappings };
|
|
884
|
+
});
|
|
888
885
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
}
|
|
894
|
-
} catch (err) {
|
|
895
|
-
db.exec("ROLLBACK");
|
|
896
|
-
throw err;
|
|
886
|
+
if (copiedSchemas > 0 || copiedMappings > 0) {
|
|
887
|
+
console.log(
|
|
888
|
+
`[vault] migrated to schema v15: copied ${copiedSchemas} _schemas/* + ${copiedMappings} _schema_defaults mappings into note_schemas/schema_mappings`,
|
|
889
|
+
);
|
|
897
890
|
}
|
|
898
891
|
}
|
|
899
892
|
|
|
@@ -922,15 +915,10 @@ function migrateToV16(db: Database): void {
|
|
|
922
915
|
// ALTER block so a fresh vault — where the column exists but the index
|
|
923
916
|
// doesn't — still gets it.
|
|
924
917
|
if (!hasColumn(db, "tokens", "vault_name")) {
|
|
925
|
-
db
|
|
926
|
-
try {
|
|
918
|
+
transaction(db, () => {
|
|
927
919
|
db.exec("ALTER TABLE tokens ADD COLUMN vault_name TEXT");
|
|
928
920
|
db.exec("CREATE INDEX IF NOT EXISTS idx_tokens_vault_name ON tokens(vault_name)");
|
|
929
|
-
|
|
930
|
-
} catch (err) {
|
|
931
|
-
db.exec("ROLLBACK");
|
|
932
|
-
throw err;
|
|
933
|
-
}
|
|
921
|
+
});
|
|
934
922
|
return;
|
|
935
923
|
}
|
|
936
924
|
|
|
@@ -980,8 +968,7 @@ function migrateToV17(db: Database): void {
|
|
|
980
968
|
).all() as { schema_name: string; match_kind: string; match_value: string }[];
|
|
981
969
|
}
|
|
982
970
|
|
|
983
|
-
db
|
|
984
|
-
try {
|
|
971
|
+
transaction(db, () => {
|
|
985
972
|
// Drop the index first — the index references the table; SQLite would
|
|
986
973
|
// tear it down on DROP TABLE but the explicit DROP keeps the order
|
|
987
974
|
// obvious if a future migration reads from sqlite_master mid-flight.
|
|
@@ -993,11 +980,7 @@ function migrateToV17(db: Database): void {
|
|
|
993
980
|
if (hasNoteSchemas) {
|
|
994
981
|
db.exec("DROP TABLE note_schemas");
|
|
995
982
|
}
|
|
996
|
-
|
|
997
|
-
} catch (err) {
|
|
998
|
-
db.exec("ROLLBACK");
|
|
999
|
-
throw err;
|
|
1000
|
-
}
|
|
983
|
+
});
|
|
1001
984
|
|
|
1002
985
|
if (droppedSchemas.length > 0 || droppedMappings.length > 0) {
|
|
1003
986
|
const schemaNames = droppedSchemas.map((s) => s.name).join(", ");
|
|
@@ -1052,8 +1035,7 @@ function migrateToV18(db: Database): void {
|
|
|
1052
1035
|
const hasNewUnique = indexes.some((r) => r.name === "idx_notes_path_ext_unique");
|
|
1053
1036
|
if (!needsColumn && hasNewUnique && !hasOldUnique) return;
|
|
1054
1037
|
|
|
1055
|
-
db
|
|
1056
|
-
try {
|
|
1038
|
+
transaction(db, () => {
|
|
1057
1039
|
if (needsColumn) {
|
|
1058
1040
|
db.exec("ALTER TABLE notes ADD COLUMN extension TEXT NOT NULL DEFAULT 'md'");
|
|
1059
1041
|
}
|
|
@@ -1065,11 +1047,7 @@ function migrateToV18(db: Database): void {
|
|
|
1065
1047
|
"CREATE UNIQUE INDEX idx_notes_path_ext_unique ON notes(path, extension) WHERE path IS NOT NULL",
|
|
1066
1048
|
);
|
|
1067
1049
|
}
|
|
1068
|
-
|
|
1069
|
-
} catch (err) {
|
|
1070
|
-
db.exec("ROLLBACK");
|
|
1071
|
-
throw err;
|
|
1072
|
-
}
|
|
1050
|
+
});
|
|
1073
1051
|
}
|
|
1074
1052
|
|
|
1075
1053
|
/**
|
package/core/src/store.ts
CHANGED
|
@@ -12,10 +12,12 @@ import {
|
|
|
12
12
|
} from "./indexed-fields.js";
|
|
13
13
|
import { syncWikilinks, resolveUnresolvedWikilinks } from "./wikilinks.js";
|
|
14
14
|
import { pathTitle } from "./paths.js";
|
|
15
|
+
import { transaction } from "./txn.js";
|
|
15
16
|
import { HookRegistry } from "./hooks.js";
|
|
16
17
|
import {
|
|
17
18
|
loadTagHierarchy,
|
|
18
19
|
getTagExpansion,
|
|
20
|
+
stripTagHash,
|
|
19
21
|
TAG_CONFIG_PREFIX,
|
|
20
22
|
DEFAULT_TAG_NAME,
|
|
21
23
|
DEFAULT_TAG_EXPAND_MODE,
|
|
@@ -28,6 +30,10 @@ import {
|
|
|
28
30
|
type ResolvedSchemas,
|
|
29
31
|
type ValidationStatus,
|
|
30
32
|
} from "./schema-defaults.js";
|
|
33
|
+
import {
|
|
34
|
+
countConformanceViolations,
|
|
35
|
+
type ConformanceReport,
|
|
36
|
+
} from "./conformance.js";
|
|
31
37
|
|
|
32
38
|
/**
|
|
33
39
|
* bun:sqlite-backed Store implementation. Internally everything is
|
|
@@ -50,6 +56,15 @@ export class BunSqliteStore implements Store {
|
|
|
50
56
|
this.hooks = opts?.hooks ?? new HookRegistry();
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The transaction seam (see core/src/txn.ts). bun backs it with
|
|
61
|
+
* `BEGIN IMMEDIATE … COMMIT`; a DO-backed Store overrides this with
|
|
62
|
+
* `ctx.storage.transactionSync`. Synchronous — `fn` must not await.
|
|
63
|
+
*/
|
|
64
|
+
transaction<T>(fn: () => T): T {
|
|
65
|
+
return transaction(this.db, fn);
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
/**
|
|
54
69
|
* Lazy accessor for the `_tags/*` config-note hierarchy. First call after
|
|
55
70
|
* boot or after an invalidation does the scan; subsequent calls hit the
|
|
@@ -247,8 +262,30 @@ export class BunSqliteStore implements Store {
|
|
|
247
262
|
);
|
|
248
263
|
}
|
|
249
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Canonical-bare-tag guard (vault#XXX) for the QUERY path. Strip any leading
|
|
267
|
+
* `#` from `tags` / `excludeTags` BEFORE hierarchy expansion so a
|
|
268
|
+
* `#agent/message`-form query matches a bare-stored `agent/message` row (and
|
|
269
|
+
* vice-versa). This is what makes the data migration non-breaking — every
|
|
270
|
+
* old `#`-decorated query keeps working, mapping onto the same bare rows.
|
|
271
|
+
* Runs before `expandQueryTags` so hierarchy resolution / `_default` collapse
|
|
272
|
+
* see the bare names. Empty-after-strip entries are dropped.
|
|
273
|
+
*/
|
|
274
|
+
private normalizeQueryTags(opts: QueryOpts): QueryOpts {
|
|
275
|
+
let next = opts;
|
|
276
|
+
if (opts.tags && opts.tags.length > 0) {
|
|
277
|
+
const tags = opts.tags.map(stripTagHash).filter((t) => t !== "");
|
|
278
|
+
next = { ...next, tags };
|
|
279
|
+
}
|
|
280
|
+
if (opts.excludeTags && opts.excludeTags.length > 0) {
|
|
281
|
+
const excludeTags = opts.excludeTags.map(stripTagHash).filter((t) => t !== "");
|
|
282
|
+
next = { ...next, excludeTags };
|
|
283
|
+
}
|
|
284
|
+
return next;
|
|
285
|
+
}
|
|
286
|
+
|
|
250
287
|
async queryNotes(opts: QueryOpts): Promise<Note[]> {
|
|
251
|
-
return noteOps.queryNotes(this.db, this.expandQueryTags(opts));
|
|
288
|
+
return noteOps.queryNotes(this.db, this.expandQueryTags(this.normalizeQueryTags(opts)));
|
|
252
289
|
}
|
|
253
290
|
|
|
254
291
|
async queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage> {
|
|
@@ -258,7 +295,11 @@ export class BunSqliteStore implements Store {
|
|
|
258
295
|
// descendant set → different rows match → caller should restart). The
|
|
259
296
|
// alternative — hash the expanded set — would silently keep returning
|
|
260
297
|
// stale results from a hierarchy snapshot the caller never saw.
|
|
261
|
-
|
|
298
|
+
//
|
|
299
|
+
// Bare-tag normalization runs first (before the hash is taken inside
|
|
300
|
+
// queryNotesPaged) so a `#tag`-form page-1 and a bare `tag`-form follow-up
|
|
301
|
+
// resolve to the same cursor query_hash.
|
|
302
|
+
return noteOps.queryNotesPaged(this.db, this.expandQueryTags(this.normalizeQueryTags(opts)));
|
|
262
303
|
}
|
|
263
304
|
|
|
264
305
|
/**
|
|
@@ -328,6 +369,11 @@ export class BunSqliteStore implements Store {
|
|
|
328
369
|
}
|
|
329
370
|
|
|
330
371
|
async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]> {
|
|
372
|
+
// Canonical-bare-tag guard (vault#XXX): strip leading `#` from search tag
|
|
373
|
+
// filters before expansion, so `#manual` and `manual` resolve identically.
|
|
374
|
+
if (opts?.tags && opts.tags.length > 0) {
|
|
375
|
+
opts = { ...opts, tags: opts.tags.map(stripTagHash).filter((t) => t !== "") };
|
|
376
|
+
}
|
|
331
377
|
// Same tag-expansion treatment as queryNotes, along the SAME `expand` axis
|
|
332
378
|
// (vault tag `expand` axis) — searching `#manual` should match notes
|
|
333
379
|
// tagged with any descendant under "subtypes", any `manual/*` under
|
|
@@ -626,6 +672,18 @@ export class BunSqliteStore implements Store {
|
|
|
626
672
|
parent_names?: string[] | null;
|
|
627
673
|
},
|
|
628
674
|
) {
|
|
675
|
+
// Canonical-bare-tag guard (vault#XXX) for the SCHEMA path. Strip leading
|
|
676
|
+
// `#` from the tag NAME being upserted and from every `parent_names` entry,
|
|
677
|
+
// so the `tags` rows and the inheritance graph stay bare — matching the
|
|
678
|
+
// bare-stored note_tags. A `#foo` schema with `parent_names: ["#bar"]`
|
|
679
|
+
// becomes `foo` / `["bar"]`, so `tag:bar` still expands to `foo`.
|
|
680
|
+
tag = stripTagHash(tag);
|
|
681
|
+
if (patch.parent_names != null) {
|
|
682
|
+
patch = {
|
|
683
|
+
...patch,
|
|
684
|
+
parent_names: patch.parent_names.map(stripTagHash).filter((p) => p !== ""),
|
|
685
|
+
};
|
|
686
|
+
}
|
|
629
687
|
// Snapshot the prior indexed-field set BEFORE the write so the diff below
|
|
630
688
|
// sees what this tag declared going in. Only needed when `fields` changes.
|
|
631
689
|
const priorRecord =
|
|
@@ -668,10 +726,8 @@ export class BunSqliteStore implements Store {
|
|
|
668
726
|
// mismatch only detectable once the existing declarer set is consulted),
|
|
669
727
|
// the whole write rolls back — the schema never ends up claiming an index
|
|
670
728
|
// that doesn't exist. vault#478 transactional fix.
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
try {
|
|
674
|
-
result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
|
|
729
|
+
const result = this.transaction(() => {
|
|
730
|
+
const record = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
|
|
675
731
|
|
|
676
732
|
if (patch.fields !== undefined) {
|
|
677
733
|
for (const fieldName of nextIndexed) {
|
|
@@ -686,11 +742,8 @@ export class BunSqliteStore implements Store {
|
|
|
686
742
|
}
|
|
687
743
|
}
|
|
688
744
|
}
|
|
689
|
-
|
|
690
|
-
}
|
|
691
|
-
this.db.exec("ROLLBACK");
|
|
692
|
-
throw err;
|
|
693
|
-
}
|
|
745
|
+
return record;
|
|
746
|
+
});
|
|
694
747
|
|
|
695
748
|
if (patch.parent_names !== undefined) {
|
|
696
749
|
// parent_names drives both query expansion (tag hierarchy) AND, post
|
|
@@ -716,6 +769,21 @@ export class BunSqliteStore implements Store {
|
|
|
716
769
|
return result;
|
|
717
770
|
}
|
|
718
771
|
|
|
772
|
+
/**
|
|
773
|
+
* Conformance check (vault#283) — count how many EXISTING notes carrying
|
|
774
|
+
* `tag` (descendants included) would violate the PROPOSED field spec, so a
|
|
775
|
+
* tightening edit (strict / required / narrowed enum / changed type) can
|
|
776
|
+
* warn the operator BEFORE save. Pure read — no mutation. See
|
|
777
|
+
* core/src/conformance.ts.
|
|
778
|
+
*/
|
|
779
|
+
async countTagConformance(
|
|
780
|
+
tag: string,
|
|
781
|
+
proposedFields: Record<string, tagSchemaOps.TagFieldSchema>,
|
|
782
|
+
opts?: { sampleLimit?: number },
|
|
783
|
+
): Promise<ConformanceReport> {
|
|
784
|
+
return countConformanceViolations(this.db, tag, proposedFields, opts);
|
|
785
|
+
}
|
|
786
|
+
|
|
719
787
|
// ---- Batch Wikilink Sync ----
|
|
720
788
|
|
|
721
789
|
/**
|
|
@@ -27,6 +27,32 @@
|
|
|
27
27
|
|
|
28
28
|
import { Database } from "bun:sqlite";
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Canonical-bare-tag guard (vault#XXX). Strip any leading `#` (one or more)
|
|
32
|
+
* from a tag value so a `#`-prefixed tag can never be stored or queried
|
|
33
|
+
* out-of-band. Tags are stored BARE by convention (`agent/message/inbound`,
|
|
34
|
+
* not `#agent/message/inbound`); a client that passes the `#`-decorated form —
|
|
35
|
+
* as the agent module did, persisting `#agent/message/inbound` literally — must
|
|
36
|
+
* land on the same canonical row everyone else queries.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately MINIMAL: this is NOT `normalizeTagValue` (portable-md.ts). It
|
|
39
|
+
* does NOT lowercase, slug-validate, or otherwise transform the value — casing
|
|
40
|
+
* and structure are preserved. The ONLY job is to remove the stray leading `#`.
|
|
41
|
+
*
|
|
42
|
+
* Idempotent: `#tag` / `##tag` / `tag` all collapse to `tag`. Only LEADING `#`
|
|
43
|
+
* is stripped — a `#` mid-string (e.g. `c#`) is left intact. Whitespace is
|
|
44
|
+
* trimmed first so a `" #tag"` from a sloppy client also normalizes. Applied at
|
|
45
|
+
* the canonical chokepoints (note-tag write, query/exclude filters, tag-schema
|
|
46
|
+
* name + parent_names) so MCP, REST, and direct-core callers all converge.
|
|
47
|
+
*/
|
|
48
|
+
export function stripTagHash(tag: string): string {
|
|
49
|
+
// Strip any LEADING run of `#`/whitespace (handles `#tag`, `##tag`, ` #tag`,
|
|
50
|
+
// and `# tag` with a space after the hash), then trim the tail. A `#`
|
|
51
|
+
// mid-string (`c#`) is untouched; a degenerate `# #` collapses to "" (the
|
|
52
|
+
// write-path empty-tag gate drops it).
|
|
53
|
+
return tag.replace(/^[#\s]+/, "").trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
export interface TagHierarchy {
|
|
31
57
|
/** tag → set of immediate child tags (those that declared `tag` as a parent). */
|
|
32
58
|
childrenOf: Map<string, Set<string>>;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for the transaction seam (core/src/txn.ts).
|
|
3
|
+
*
|
|
4
|
+
* Pins the behavior every migrated call site now depends on: commit returns
|
|
5
|
+
* the callback's value, a throw rolls back and re-throws the ORIGINAL error,
|
|
6
|
+
* and a ROLLBACK that itself fails (the "COMMIT already resolved the txn"
|
|
7
|
+
* case) is swallowed so the original error still propagates. The swallow /
|
|
8
|
+
* ordering cases use a structural fake `TxnCapableDb` so we can force a
|
|
9
|
+
* COMMIT/ROLLBACK failure deterministically; the happy + real-rollback cases
|
|
10
|
+
* run against a live bun:sqlite connection.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from "bun:test";
|
|
14
|
+
import { Database } from "bun:sqlite";
|
|
15
|
+
import { transaction, transactionAsync, type TxnCapableDb } from "./txn.js";
|
|
16
|
+
|
|
17
|
+
/** A fake `TxnCapableDb` that records exec calls and can be told to throw on
|
|
18
|
+
* COMMIT and/or ROLLBACK — lets us drive the failure branches exactly. */
|
|
19
|
+
function fakeDb(opts: { commitThrows?: Error; rollbackThrows?: Error } = {}): {
|
|
20
|
+
db: TxnCapableDb;
|
|
21
|
+
calls: string[];
|
|
22
|
+
} {
|
|
23
|
+
const calls: string[] = [];
|
|
24
|
+
const db: TxnCapableDb = {
|
|
25
|
+
exec(sql: string): void {
|
|
26
|
+
calls.push(sql);
|
|
27
|
+
if (sql === "COMMIT" && opts.commitThrows) throw opts.commitThrows;
|
|
28
|
+
if (sql === "ROLLBACK" && opts.rollbackThrows) throw opts.rollbackThrows;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
return { db, calls };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function freshDb(): Database {
|
|
35
|
+
const db = new Database(":memory:");
|
|
36
|
+
db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)");
|
|
37
|
+
return db;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("transaction (sync)", () => {
|
|
41
|
+
it("commits and returns the callback's value", () => {
|
|
42
|
+
const db = freshDb();
|
|
43
|
+
const result = transaction(db, () => {
|
|
44
|
+
db.exec("INSERT INTO t (id, v) VALUES (1, 'a')");
|
|
45
|
+
return { count: 1 };
|
|
46
|
+
});
|
|
47
|
+
expect(result).toEqual({ count: 1 });
|
|
48
|
+
// Write is durable after commit.
|
|
49
|
+
expect((db.prepare("SELECT COUNT(*) AS c FROM t").get() as { c: number }).c).toBe(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("wraps in BEGIN IMMEDIATE … COMMIT", () => {
|
|
53
|
+
const { db, calls } = fakeDb();
|
|
54
|
+
transaction(db, () => "ok");
|
|
55
|
+
expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("rolls back and re-throws the ORIGINAL error when the callback throws", () => {
|
|
59
|
+
const db = freshDb();
|
|
60
|
+
const sentinel = new Error("boom");
|
|
61
|
+
expect(() =>
|
|
62
|
+
transaction(db, () => {
|
|
63
|
+
db.exec("INSERT INTO t (id, v) VALUES (1, 'a')");
|
|
64
|
+
throw sentinel;
|
|
65
|
+
}),
|
|
66
|
+
).toThrow(sentinel);
|
|
67
|
+
// The inner INSERT was rolled back.
|
|
68
|
+
expect((db.prepare("SELECT COUNT(*) AS c FROM t").get() as { c: number }).c).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("issues ROLLBACK (not COMMIT) on a callback throw", () => {
|
|
72
|
+
const { db, calls } = fakeDb();
|
|
73
|
+
expect(() => transaction(db, () => { throw new Error("x"); })).toThrow("x");
|
|
74
|
+
expect(calls).toEqual(["BEGIN IMMEDIATE", "ROLLBACK"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("swallows a ROLLBACK failure after a COMMIT throw and propagates the ORIGINAL (commit) error", () => {
|
|
78
|
+
const commitErr = new Error("commit failed");
|
|
79
|
+
const rollbackErr = new Error("no transaction is active");
|
|
80
|
+
const { db, calls } = fakeDb({ commitThrows: commitErr, rollbackThrows: rollbackErr });
|
|
81
|
+
let thrown: unknown;
|
|
82
|
+
try {
|
|
83
|
+
transaction(db, () => "value");
|
|
84
|
+
} catch (e) {
|
|
85
|
+
thrown = e;
|
|
86
|
+
}
|
|
87
|
+
// The COMMIT error is the original that propagates; the ROLLBACK error is swallowed.
|
|
88
|
+
expect(thrown).toBe(commitErr);
|
|
89
|
+
expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT", "ROLLBACK"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("preserves the callback error even when ROLLBACK also fails", () => {
|
|
93
|
+
const callbackErr = new Error("callback boom");
|
|
94
|
+
const rollbackErr = new Error("rollback boom");
|
|
95
|
+
const { db } = fakeDb({ rollbackThrows: rollbackErr });
|
|
96
|
+
let thrown: unknown;
|
|
97
|
+
try {
|
|
98
|
+
transaction(db, () => { throw callbackErr; });
|
|
99
|
+
} catch (e) {
|
|
100
|
+
thrown = e;
|
|
101
|
+
}
|
|
102
|
+
expect(thrown).toBe(callbackErr);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("transactionAsync", () => {
|
|
107
|
+
it("commits and returns the callback's resolved value", async () => {
|
|
108
|
+
const db = freshDb();
|
|
109
|
+
const result = await transactionAsync(db, async () => {
|
|
110
|
+
db.exec("INSERT INTO t (id, v) VALUES (1, 'a')");
|
|
111
|
+
return 7;
|
|
112
|
+
});
|
|
113
|
+
expect(result).toBe(7);
|
|
114
|
+
expect((db.prepare("SELECT COUNT(*) AS c FROM t").get() as { c: number }).c).toBe(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("wraps in BEGIN IMMEDIATE … COMMIT", async () => {
|
|
118
|
+
const { db, calls } = fakeDb();
|
|
119
|
+
await transactionAsync(db, async () => "ok");
|
|
120
|
+
expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("rolls back and re-throws the ORIGINAL error when the callback rejects", async () => {
|
|
124
|
+
const db = freshDb();
|
|
125
|
+
const sentinel = new Error("async boom");
|
|
126
|
+
await expect(
|
|
127
|
+
transactionAsync(db, async () => {
|
|
128
|
+
db.exec("INSERT INTO t (id, v) VALUES (1, 'a')");
|
|
129
|
+
throw sentinel;
|
|
130
|
+
}),
|
|
131
|
+
).rejects.toBe(sentinel);
|
|
132
|
+
expect((db.prepare("SELECT COUNT(*) AS c FROM t").get() as { c: number }).c).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("swallows a ROLLBACK failure after a COMMIT throw and propagates the ORIGINAL (commit) error", async () => {
|
|
136
|
+
const commitErr = new Error("commit failed");
|
|
137
|
+
const rollbackErr = new Error("no transaction is active");
|
|
138
|
+
const { db, calls } = fakeDb({ commitThrows: commitErr, rollbackThrows: rollbackErr });
|
|
139
|
+
let thrown: unknown;
|
|
140
|
+
try {
|
|
141
|
+
await transactionAsync(db, async () => "value");
|
|
142
|
+
} catch (e) {
|
|
143
|
+
thrown = e;
|
|
144
|
+
}
|
|
145
|
+
expect(thrown).toBe(commitErr);
|
|
146
|
+
expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT", "ROLLBACK"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("preserves the callback error even when ROLLBACK also fails", async () => {
|
|
150
|
+
const callbackErr = new Error("callback boom");
|
|
151
|
+
const rollbackErr = new Error("rollback boom");
|
|
152
|
+
const { db } = fakeDb({ rollbackThrows: rollbackErr });
|
|
153
|
+
let thrown: unknown;
|
|
154
|
+
try {
|
|
155
|
+
await transactionAsync(db, async () => { throw callbackErr; });
|
|
156
|
+
} catch (e) {
|
|
157
|
+
thrown = e;
|
|
158
|
+
}
|
|
159
|
+
expect(thrown).toBe(callbackErr);
|
|
160
|
+
});
|
|
161
|
+
});
|
package/core/src/txn.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transaction seam (Phase-1 shared-core refactor, vault cloud design §4).
|
|
3
|
+
*
|
|
4
|
+
* Core code must never emit raw `BEGIN`/`COMMIT`/`ROLLBACK` — that's the one
|
|
5
|
+
* SQLite construct DO's `sql.exec` blocks, so it can't be shimmed like the
|
|
6
|
+
* rest of the `Database` surface. Instead every atomic block routes through
|
|
7
|
+
* `transaction` / `transactionAsync` here. The bun backend implements them
|
|
8
|
+
* as `BEGIN IMMEDIATE … COMMIT` with rollback-on-throw; a future Durable-
|
|
9
|
+
* Object backend implements the same contract with `ctx.storage.transactionSync`
|
|
10
|
+
* (and `Store.transaction`, see types.ts, is the object-level entry point).
|
|
11
|
+
*
|
|
12
|
+
* `BEGIN IMMEDIATE` (write lock up front) matches the pre-seam behavior of
|
|
13
|
+
* the 13 core blocks this replaced — several used a bare `BEGIN`, but every
|
|
14
|
+
* one of them only ever wrote, so acquiring the write lock eagerly is a
|
|
15
|
+
* strict improvement (no lazy busy-upgrade) and never a semantic change.
|
|
16
|
+
*
|
|
17
|
+
* **Nesting is unsupported**, matching the pre-seam code exactly: SQLite
|
|
18
|
+
* throws "cannot start a transaction within a transaction" on a nested
|
|
19
|
+
* `BEGIN`, and none of the migrated call sites nest (a batch wraps individual
|
|
20
|
+
* `createNote`s, none of which open their own transaction). If a nested
|
|
21
|
+
* caller ever appears, the fix is SAVEPOINT-based re-entrancy *in the bun
|
|
22
|
+
* implementation here* — the call sites stay untouched.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The minimal DB surface the transaction seam needs — kept structural (no
|
|
26
|
+
* `bun:sqlite` import) so a non-bun backend satisfies it too. */
|
|
27
|
+
export interface TxnCapableDb {
|
|
28
|
+
exec(sql: string): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run `fn` inside a single write transaction, committing its result or
|
|
33
|
+
* rolling back on throw. Synchronous — the callback must not await (DO's
|
|
34
|
+
* `transactionSync` is sync-only; see `transactionAsync` for the batch paths
|
|
35
|
+
* that legitimately await the async Store facade).
|
|
36
|
+
*/
|
|
37
|
+
export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
|
|
38
|
+
db.exec("BEGIN IMMEDIATE");
|
|
39
|
+
try {
|
|
40
|
+
const result = fn();
|
|
41
|
+
db.exec("COMMIT");
|
|
42
|
+
return result;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
// Best-effort rollback — if the COMMIT itself threw the transaction is
|
|
45
|
+
// already resolved and ROLLBACK would throw "no transaction is active";
|
|
46
|
+
// swallow that so the original error is the one that propagates.
|
|
47
|
+
try {
|
|
48
|
+
db.exec("ROLLBACK");
|
|
49
|
+
} catch {
|
|
50
|
+
// no active transaction to roll back
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Async sibling of {@link transaction} for the batch executors, which run
|
|
58
|
+
* their body through the async `Store` facade (`await store.createNote(...)`
|
|
59
|
+
* etc.). The transaction spans the awaits on the shared connection — the
|
|
60
|
+
* same shape as the pre-seam `if (batched) db.exec("BEGIN")` blocks in
|
|
61
|
+
* mcp.ts / routes.ts. A DO backend can't map this onto `transactionSync`
|
|
62
|
+
* (which forbids awaiting); porting the batch path is a Phase-2 concern.
|
|
63
|
+
*/
|
|
64
|
+
export async function transactionAsync<T>(db: TxnCapableDb, fn: () => Promise<T>): Promise<T> {
|
|
65
|
+
db.exec("BEGIN IMMEDIATE");
|
|
66
|
+
try {
|
|
67
|
+
const result = await fn();
|
|
68
|
+
db.exec("COMMIT");
|
|
69
|
+
return result;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
try {
|
|
72
|
+
db.exec("ROLLBACK");
|
|
73
|
+
} catch {
|
|
74
|
+
// no active transaction to roll back
|
|
75
|
+
}
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/core/src/types.ts
CHANGED
|
@@ -3,12 +3,14 @@ import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } f
|
|
|
3
3
|
import type { PrunedField } from "./indexed-fields.js";
|
|
4
4
|
import type { TagExpandMode } from "./tag-hierarchy.js";
|
|
5
5
|
import type { ValidationStatus } from "./schema-defaults.js";
|
|
6
|
+
import type { ConformanceReport } from "./conformance.js";
|
|
6
7
|
|
|
7
8
|
// ---- Re-exports ----
|
|
8
9
|
|
|
9
10
|
export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
10
11
|
export type { PrunedField } from "./indexed-fields.js";
|
|
11
12
|
export type { TagExpandMode } from "./tag-hierarchy.js";
|
|
13
|
+
export type { ConformanceReport } from "./conformance.js";
|
|
12
14
|
|
|
13
15
|
// ---- Note ----
|
|
14
16
|
|
|
@@ -261,6 +263,15 @@ export interface Store {
|
|
|
261
263
|
*/
|
|
262
264
|
readonly db: Database;
|
|
263
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Run `fn` inside a single atomic write transaction (commit on return,
|
|
268
|
+
* rollback on throw). The transaction seam (see core/src/txn.ts): the
|
|
269
|
+
* bun backend implements it as `BEGIN IMMEDIATE … COMMIT`, a future
|
|
270
|
+
* Durable-Object backend as `ctx.storage.transactionSync`. Synchronous —
|
|
271
|
+
* `fn` must not await. Nesting is unsupported (matches raw-SQLite BEGIN).
|
|
272
|
+
*/
|
|
273
|
+
transaction<T>(fn: () => T): T;
|
|
274
|
+
|
|
264
275
|
// Notes. `actor` / `via` carry write-attribution (vault#298) — the
|
|
265
276
|
// principal + interface stamped onto created_by/created_via (and mirrored
|
|
266
277
|
// into the last_updated_* pair on create). Omitted → attribution NULL.
|
|
@@ -413,6 +424,19 @@ export interface Store {
|
|
|
413
424
|
},
|
|
414
425
|
): Promise<TagRecord>;
|
|
415
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Conformance check (vault#283) — count existing notes carrying `tag`
|
|
429
|
+
* (descendants included) that would violate the PROPOSED field spec, so a
|
|
430
|
+
* tightening edit (strict / required / narrowed enum / changed type) can
|
|
431
|
+
* warn before save. Pure read. `proposedFields` is the full merged field
|
|
432
|
+
* map the operator intends to save; only those fields are checked.
|
|
433
|
+
*/
|
|
434
|
+
countTagConformance(
|
|
435
|
+
tag: string,
|
|
436
|
+
proposedFields: Record<string, TagFieldSchema>,
|
|
437
|
+
opts?: { sampleLimit?: number },
|
|
438
|
+
): Promise<ConformanceReport>;
|
|
439
|
+
|
|
416
440
|
// Schema validation (post-v17: backed by `tags.fields` only — the
|
|
417
441
|
// standalone note_schemas + schema_mappings subsystem retired in v17, see
|
|
418
442
|
// vault#267). Post vault#270 the resolver walks `parent_names` so a note's
|