@openparachute/vault 0.7.3-rc.2 → 0.7.3-rc.4
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/core/src/conformance.ts +2 -1
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +455 -0
- package/core/src/indexed-fields.test.ts +9 -3
- package/core/src/indexed-fields.ts +9 -1
- package/core/src/mcp.ts +24 -4
- package/core/src/schema-defaults.ts +85 -1
- package/core/src/seed-packs.test.ts +117 -1
- package/core/src/seed-packs.ts +176 -0
- package/core/src/store.ts +59 -3
- package/core/src/tag-schemas.ts +27 -12
- package/core/src/types.ts +1 -1
- package/package.json +1 -1
- package/src/contract-errors.test.ts +2 -1
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
43
|
import { Database } from "bun:sqlite";
|
|
44
|
+
import { timestampToMs } from "./cursor.js";
|
|
44
45
|
|
|
45
46
|
// ---------------------------------------------------------------------------
|
|
46
47
|
// Types
|
|
@@ -57,7 +58,7 @@ export interface SchemaField {
|
|
|
57
58
|
* `type_mismatch` warning that previously fired on every integer-shaped
|
|
58
59
|
* field because the validator had no `"integer"` case. See vault#310.
|
|
59
60
|
*/
|
|
60
|
-
type?: "string" | "number" | "integer" | "boolean" | "array" | "object" | "reference";
|
|
61
|
+
type?: "string" | "number" | "integer" | "boolean" | "array" | "object" | "reference" | "date";
|
|
61
62
|
enum?: string[];
|
|
62
63
|
description?: string;
|
|
63
64
|
/**
|
|
@@ -320,6 +321,82 @@ export function resolveNoteSchemas(
|
|
|
320
321
|
return { effectiveTags, mergedFields, conflicts };
|
|
321
322
|
}
|
|
322
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Normalize `date`-typed field VALUES in `metadata` to canonical UTC ISO
|
|
326
|
+
* form (`Z`-suffixed) BEFORE persisting (vault#date-field-type — mixed-
|
|
327
|
+
* offset corruption, caught in review).
|
|
328
|
+
*
|
|
329
|
+
* The bug: every consumer of an indexed `date` field — `buildOperatorClause`
|
|
330
|
+
* (query-operators.ts), `date_filter` and `order_by` (notes.ts) — compares
|
|
331
|
+
* the generated `meta_<field>` column as raw TEXT. ISO-8601 strings sort
|
|
332
|
+
* correctly under a TEXT compare ONLY when every value shares the same
|
|
333
|
+
* offset representation. `timestampToMs` (cursor.ts) — the validator both
|
|
334
|
+
* `defaultMatchesType` and `valueMatchesType` reuse — correctly ACCEPTS an
|
|
335
|
+
* explicit `±HH:MM` offset (validation was never the gap), but a value like
|
|
336
|
+
* `"2026-07-16T10:00:00+02:00"` (= `08:00Z`) persisted VERBATIM sorts AFTER
|
|
337
|
+
* `"2026-07-16T09:00:00Z"` (= `09:00Z`) under a raw string compare, even
|
|
338
|
+
* though the actual instant is earlier — a mixed-offset vault silently gets
|
|
339
|
+
* wrong range-query/order_by/date_filter results. This is the exact bug
|
|
340
|
+
* class `updated_at` got a dedicated ms-mirror column for (vault#585/#586,
|
|
341
|
+
* see `notes.ts`'s `dateFilter` block) that never extended to user-declared
|
|
342
|
+
* `date` fields.
|
|
343
|
+
*
|
|
344
|
+
* The fix is normalize-on-write, not reject-on-write (matching the
|
|
345
|
+
* "paths are normalized on write" precedent — instant preserved,
|
|
346
|
+
* representation canonicalized) — rejecting offsets would defeat the
|
|
347
|
+
* type's own motivation (calendar integrations and other emitters commonly
|
|
348
|
+
* produce offset timestamps, not always `Z`). Only a FULL timestamp (has a
|
|
349
|
+
* time component) is rewritten, to `new Date(ms).toISOString()` — always
|
|
350
|
+
* UTC, millisecond precision, `Z`-suffixed. A bare `YYYY-MM-DD` value is
|
|
351
|
+
* left untouched: it has no offset to normalize, is already canonical, and
|
|
352
|
+
* prefix-sorts correctly against full timestamps sharing the same calendar
|
|
353
|
+
* day. A value that fails `timestampToMs` is left untouched too — that's a
|
|
354
|
+
* `type_mismatch` for `valueMatchesType` to catch, not this function's job.
|
|
355
|
+
*
|
|
356
|
+
* COPY-ON-WRITE, never mutates the input `metadata` object (vault#date-
|
|
357
|
+
* field-type review round 2) — `core/` is a published library; a direct
|
|
358
|
+
* embedder holding a reference to the object THEY passed in must never see
|
|
359
|
+
* it change out from under them. Returns the SAME reference when nothing
|
|
360
|
+
* needs rewriting (the common case — no unnecessary allocation on the
|
|
361
|
+
* no-op path) or `metadata` is undefined; returns a freshly shallow-copied
|
|
362
|
+
* object, with only the rewritten field(s) replaced, the first time a
|
|
363
|
+
* rewrite is actually needed. Every call site must use the RETURNED value
|
|
364
|
+
* (not assume its input was mutated) — see `store.createNote`/`updateNote`/
|
|
365
|
+
* `createNotes`/`createNoteRaw`, all of which reassign their local
|
|
366
|
+
* `opts`/`updates`/`input` binding from the return rather than relying on a
|
|
367
|
+
* side effect.
|
|
368
|
+
*
|
|
369
|
+
* Called from `store.createNote`/`updateNote`/`createNotes`/`createNoteRaw`
|
|
370
|
+
* — the lowest chokepoint every write path (MCP, REST, import) funnels
|
|
371
|
+
* through — BEFORE the row is persisted, so the value that's validated by
|
|
372
|
+
* `validateNote`/`valueMatchesType` upstream (offset-tolerant, so
|
|
373
|
+
* normalization can't newly fail a write) and the value that's written +
|
|
374
|
+
* echoed back on the response are the SAME normalized string.
|
|
375
|
+
*/
|
|
376
|
+
export function normalizeDateFields(
|
|
377
|
+
resolved: ResolvedSchemas,
|
|
378
|
+
note: { tags?: string[]; metadata?: Record<string, unknown> },
|
|
379
|
+
): Record<string, unknown> | undefined {
|
|
380
|
+
const metadata = note.metadata;
|
|
381
|
+
if (!metadata) return metadata;
|
|
382
|
+
const { mergedFields } = resolveNoteSchemas(resolved, note);
|
|
383
|
+
let copy: Record<string, unknown> | undefined;
|
|
384
|
+
for (const [fieldName, { spec }] of mergedFields) {
|
|
385
|
+
if (spec.type !== "date") continue;
|
|
386
|
+
const value = metadata[fieldName];
|
|
387
|
+
if (typeof value !== "string") continue;
|
|
388
|
+
// Bare `YYYY-MM-DD` (no time component) has no offset to normalize.
|
|
389
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) continue;
|
|
390
|
+
const ms = timestampToMs(value);
|
|
391
|
+
if (ms === null) continue; // unparseable — valueMatchesType's job, not ours
|
|
392
|
+
const canonical = new Date(ms).toISOString();
|
|
393
|
+
if (canonical === value) continue;
|
|
394
|
+
if (!copy) copy = { ...metadata }; // lazy: only copy once a rewrite is due
|
|
395
|
+
copy[fieldName] = canonical;
|
|
396
|
+
}
|
|
397
|
+
return copy ?? metadata;
|
|
398
|
+
}
|
|
399
|
+
|
|
323
400
|
function fieldSpecsEqual(a: SchemaField, b: SchemaField): boolean {
|
|
324
401
|
if (a.type !== b.type) return false;
|
|
325
402
|
if (!stringArraysEqual(a.enum, b.enum)) return false;
|
|
@@ -390,6 +467,13 @@ function valueMatchesType(value: unknown, type: SchemaField["type"], cardinality
|
|
|
390
467
|
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
391
468
|
}
|
|
392
469
|
return typeof value === "string";
|
|
470
|
+
// `date` validates like `string` — an ISO-8601 date (`YYYY-MM-DD`) or
|
|
471
|
+
// full RFC3339 timestamp. Reuses `cursor.ts`'s `timestampToMs` (the SAME
|
|
472
|
+
// UTC-correct parser `date_filter`'s `updated_at` bound uses), not a
|
|
473
|
+
// second date parser — see tag-schemas.ts's `VALID_FIELD_TYPES` doc
|
|
474
|
+
// comment.
|
|
475
|
+
case "date":
|
|
476
|
+
return typeof value === "string" && timestampToMs(value) !== null;
|
|
393
477
|
}
|
|
394
478
|
}
|
|
395
479
|
|
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
|
|
21
21
|
import { describe, test, expect } from "bun:test";
|
|
22
22
|
import {
|
|
23
|
+
ALL_NOTES_VIEW_PATH,
|
|
23
24
|
applySeedPack,
|
|
25
|
+
ARCHIVE_VIEW_PATH,
|
|
26
|
+
ARCHIVED_TAG,
|
|
24
27
|
CAPTURE_ANYTHING_PATH,
|
|
25
28
|
CONNECT_AI_PATH,
|
|
26
29
|
DEFAULT_VAULT_DESCRIPTION,
|
|
@@ -33,11 +36,17 @@ import {
|
|
|
33
36
|
GUIDE_TAG,
|
|
34
37
|
listSeedPacks,
|
|
35
38
|
NOTES_REQUIRED_TAGS,
|
|
39
|
+
PINNED_TAG,
|
|
40
|
+
PINNED_VIEW_PATH,
|
|
41
|
+
RECENT_VIEW_PATH,
|
|
36
42
|
SEED_PACK_NAMES,
|
|
43
|
+
STARTER_ONTOLOGY_PACK,
|
|
37
44
|
SURFACE_STARTER_CONTENT,
|
|
38
45
|
SURFACE_STARTER_PACK,
|
|
39
46
|
SURFACE_STARTER_PATH,
|
|
40
47
|
TAGS_GRAPH_PATH,
|
|
48
|
+
VIEW_TAG,
|
|
49
|
+
VIEWS_PATH_PREFIX,
|
|
41
50
|
WELCOME_PATH,
|
|
42
51
|
welcomePack,
|
|
43
52
|
YOURS_TO_KEEP_PATH,
|
|
@@ -335,12 +344,119 @@ describe("surface-starter pack", () => {
|
|
|
335
344
|
});
|
|
336
345
|
});
|
|
337
346
|
|
|
347
|
+
describe("starter-ontology pack", () => {
|
|
348
|
+
test("exactly four tags, in constitution order: view, archived, pinned, capture", () => {
|
|
349
|
+
expect(STARTER_ONTOLOGY_PACK.name).toBe("starter-ontology");
|
|
350
|
+
expect(STARTER_ONTOLOGY_PACK.tags.map((t) => t.name)).toEqual([
|
|
351
|
+
"view",
|
|
352
|
+
"archived",
|
|
353
|
+
"pinned",
|
|
354
|
+
"capture",
|
|
355
|
+
]);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("opt-in, not seeded by default", () => {
|
|
359
|
+
expect(STARTER_ONTOLOGY_PACK.description).toContain("Opt-in — not seeded by default.");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("view is the one meta tag — carries the kind/query/lane_by/date_field schema", () => {
|
|
363
|
+
expect(VIEW_TAG.name).toBe("view");
|
|
364
|
+
expect(VIEW_TAG.description).toContain("meta tag");
|
|
365
|
+
expect(VIEW_TAG.fields).toBeDefined();
|
|
366
|
+
expect(Object.keys(VIEW_TAG.fields!)).toEqual([
|
|
367
|
+
"kind",
|
|
368
|
+
"query",
|
|
369
|
+
"lane_by",
|
|
370
|
+
"date_field",
|
|
371
|
+
]);
|
|
372
|
+
|
|
373
|
+
const kind = VIEW_TAG.fields!.kind!;
|
|
374
|
+
expect(kind.type).toBe("string");
|
|
375
|
+
expect(kind.enum).toEqual(["list", "board", "calendar", "gallery"]);
|
|
376
|
+
expect(kind.default).toBe("list");
|
|
377
|
+
|
|
378
|
+
const query = VIEW_TAG.fields!.query!;
|
|
379
|
+
expect(query.type).toBe("string");
|
|
380
|
+
|
|
381
|
+
// lane_by / date_field are optional (no default — absent stays absent).
|
|
382
|
+
expect(VIEW_TAG.fields!.lane_by!.default).toBeUndefined();
|
|
383
|
+
expect(VIEW_TAG.fields!.date_field!.default).toBeUndefined();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("archived + pinned are plain tags — no schema", () => {
|
|
387
|
+
expect(ARCHIVED_TAG.name).toBe("archived");
|
|
388
|
+
expect(ARCHIVED_TAG.fields).toBeUndefined();
|
|
389
|
+
expect(ARCHIVED_TAG.description).toContain("out of the flow of the present");
|
|
390
|
+
|
|
391
|
+
expect(PINNED_TAG.name).toBe("pinned");
|
|
392
|
+
expect(PINNED_TAG.fields).toBeUndefined();
|
|
393
|
+
expect(PINNED_TAG.description).toContain("partition");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("capture is reused verbatim from NOTES_REQUIRED_TAGS — byte-equal, no drift risk", () => {
|
|
397
|
+
const capture = STARTER_ONTOLOGY_PACK.tags.find((t) => t.name === "capture");
|
|
398
|
+
expect(capture).toBe(NOTES_REQUIRED_TAGS[0]); // SAME object reference, not just equal content
|
|
399
|
+
expect(capture!.description).toBe(NOTES_REQUIRED_TAGS[0]!.description);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("four seed #view notes mirroring the app's default pages", () => {
|
|
403
|
+
expect(STARTER_ONTOLOGY_PACK.notes.map((n) => n.path)).toEqual([
|
|
404
|
+
ALL_NOTES_VIEW_PATH,
|
|
405
|
+
RECENT_VIEW_PATH,
|
|
406
|
+
PINNED_VIEW_PATH,
|
|
407
|
+
ARCHIVE_VIEW_PATH,
|
|
408
|
+
]);
|
|
409
|
+
for (const note of STARTER_ONTOLOGY_PACK.notes) {
|
|
410
|
+
expect(note.path.startsWith(VIEWS_PATH_PREFIX)).toBe(true);
|
|
411
|
+
expect(note.tags).toEqual(["view"]);
|
|
412
|
+
expect(note.metadata?.kind).toBe("list");
|
|
413
|
+
expect(typeof note.metadata?.query).toBe("string");
|
|
414
|
+
// Every seeded query must itself be valid JSON — a downstream reader
|
|
415
|
+
// parses it as query-notes params.
|
|
416
|
+
expect(() => JSON.parse(note.metadata!.query as string)).not.toThrow();
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("All notes / Recent exclude archived explicitly (authoring-time default, not magic)", () => {
|
|
421
|
+
for (const path of [ALL_NOTES_VIEW_PATH, RECENT_VIEW_PATH]) {
|
|
422
|
+
const note = noteAt(STARTER_ONTOLOGY_PACK, path);
|
|
423
|
+
const query = JSON.parse(note.metadata!.query as string);
|
|
424
|
+
expect(query.exclude_tags).toEqual(["archived"]);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("Recent orders by updated_at desc", () => {
|
|
429
|
+
const query = JSON.parse(noteAt(STARTER_ONTOLOGY_PACK, RECENT_VIEW_PATH).metadata!.query as string);
|
|
430
|
+
expect(query.order_by).toBe("updated_at");
|
|
431
|
+
expect(query.sort).toBe("desc");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("Pinned queries tag:pinned; Archive queries tag:archived (the deliberate exception to exclude_tags)", () => {
|
|
435
|
+
const pinnedQuery = JSON.parse(noteAt(STARTER_ONTOLOGY_PACK, PINNED_VIEW_PATH).metadata!.query as string);
|
|
436
|
+
expect(pinnedQuery.tag).toBe("pinned");
|
|
437
|
+
|
|
438
|
+
const archiveQuery = JSON.parse(noteAt(STARTER_ONTOLOGY_PACK, ARCHIVE_VIEW_PATH).metadata!.query as string);
|
|
439
|
+
expect(archiveQuery.tag).toBe("archived");
|
|
440
|
+
expect(archiveQuery.exclude_tags).toBeUndefined();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("no dangling wikilinks within the pack", () => {
|
|
444
|
+
const seededPaths = new Set(STARTER_ONTOLOGY_PACK.notes.map((n) => n.path));
|
|
445
|
+
for (const note of STARTER_ONTOLOGY_PACK.notes) {
|
|
446
|
+
for (const target of wikilinkTargets(note.content)) {
|
|
447
|
+
expect(seededPaths.has(target)).toBe(true);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
338
453
|
describe("pack registry", () => {
|
|
339
|
-
test("lists exactly the
|
|
454
|
+
test("lists exactly the four packs, in order", () => {
|
|
340
455
|
expect([...SEED_PACK_NAMES]).toEqual([
|
|
341
456
|
"welcome",
|
|
342
457
|
"getting-started",
|
|
343
458
|
"surface-starter",
|
|
459
|
+
"starter-ontology",
|
|
344
460
|
]);
|
|
345
461
|
expect(listSeedPacks().map((p) => p.name)).toEqual([...SEED_PACK_NAMES]);
|
|
346
462
|
for (const p of listSeedPacks()) {
|
package/core/src/seed-packs.ts
CHANGED
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
* surface (UI) over the vault. NOT default-seeded (ratified 2026-07-02:
|
|
15
15
|
* Surface Starter is out of the default seed) — added on demand via
|
|
16
16
|
* `parachute-vault add-pack surface-starter` or a console affordance.
|
|
17
|
+
* - `starter-ontology` — four starter **meta tags** (`view`, `archived`,
|
|
18
|
+
* `pinned`, `capture`) with constitution-style descriptions, plus seed
|
|
19
|
+
* `#view` notes mirroring the app's default pages (All notes, Recent,
|
|
20
|
+
* Pinned, Archive). NOT default-seeded (ratified 2026-07-16 design
|
|
21
|
+
* dialogue — deliberately opt-in; a fresh-vault materialization change
|
|
22
|
+
* needs its own decision) — added on demand via `parachute-vault
|
|
23
|
+
* add-pack starter-ontology` or a console affordance.
|
|
17
24
|
*
|
|
18
25
|
* Guides are the vault's skill files — the Parachute equivalent of a
|
|
19
26
|
* `SKILL.md`. They're tagged `#guide` (GUIDE_TAG): notes that teach how this
|
|
@@ -798,6 +805,172 @@ export const SURFACE_STARTER_PACK: SeedPack = {
|
|
|
798
805
|
],
|
|
799
806
|
};
|
|
800
807
|
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
// `starter-ontology` pack — four starter meta tags (opt-in)
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* The starter ontology, ratified in the 2026-07-16 design dialogue (Letter 1,
|
|
814
|
+
* Q5: "yes — tiny, opinionated, removable"): exactly four tags, no more.
|
|
815
|
+
* "Really want to start simple" / "don't be too prescriptive" — the vault
|
|
816
|
+
* ships the mechanisms; each parachute writes its own rules on top.
|
|
817
|
+
*
|
|
818
|
+
* A **meta tag** (the ratified user-facing term for "a tag with a schema")
|
|
819
|
+
* is `view` alone here — it carries `fields`. `archived`, `pinned`, and
|
|
820
|
+
* `capture` are plain tags: no schema, just a description that teaches the
|
|
821
|
+
* convention. Every description below is written as a constitution — it's
|
|
822
|
+
* read by both a human deciding whether to use the tag and an agent deciding
|
|
823
|
+
* how to behave around it — plain and warm, describing the DEFAULT
|
|
824
|
+
* convention rather than dictating one ("different people will draw that
|
|
825
|
+
* line differently").
|
|
826
|
+
*
|
|
827
|
+
* `capture` is NOT redeclared with new text — it's the exact `NOTES_REQUIRED_TAGS`
|
|
828
|
+
* entry, reused verbatim, because notes-ui's connect-time schema audit
|
|
829
|
+
* compares its `description` byte-for-byte (see the comment on
|
|
830
|
+
* `NOTES_REQUIRED_TAGS` above). Applying this pack must never be able to
|
|
831
|
+
* drift that string out from under the welcome pack, in either apply order.
|
|
832
|
+
*
|
|
833
|
+
* `pinned` shipped once before and was dropped as vestigial (PR #547,
|
|
834
|
+
* 2026-07-07): it was seeded onto `Welcome` with old sort-first semantics,
|
|
835
|
+
* which did nothing visible on a fresh vault (the Notes app had no list
|
|
836
|
+
* badge, and Welcome was already first). This is a different tag: the
|
|
837
|
+
* ratified semantics are partition-not-sort, it isn't stamped onto any
|
|
838
|
+
* existing note, and the pack ships a `Pinned` view that actually surfaces
|
|
839
|
+
* it.
|
|
840
|
+
*/
|
|
841
|
+
export const VIEW_TAG: SeedPackTag = {
|
|
842
|
+
name: "view",
|
|
843
|
+
description:
|
|
844
|
+
'A meta tag — it carries a schema, which is what makes a #view note a saved view rather than just a label. Tag a note #view and its metadata becomes the definition: `query` is the saved query itself (the same JSON shape query-notes accepts — tag, exclude_tags, search, metadata, order_by, ...), and `kind` says how to render the result (list, board, calendar, or gallery; an unrecognized or missing kind should degrade to list rather than fail — a view is never wrong to render as a list). `lane_by` names the metadata field a board\'s lanes come from; `date_field` names the date-typed field a calendar plots against — both only meaningful for their matching `kind`, and unset otherwise. The note\'s body is for people: what this view is for, when to reach for it. Because the definition lives in the vault as an ordinary note, any surface can read it and render the same view, and any agent can write one — "make me a board of my active drafts" is just creating a note. Authoring convention worth keeping: most views should write `exclude_tags: ["archived"]` into their own query rather than leaning on some surface-side default — an Archive view is the deliberate exception.',
|
|
845
|
+
fields: {
|
|
846
|
+
kind: {
|
|
847
|
+
type: "string",
|
|
848
|
+
description:
|
|
849
|
+
"How to render this view. Unrecognized or absent degrades to list — a view is never wrong to render as a list.",
|
|
850
|
+
enum: ["list", "board", "calendar", "gallery"],
|
|
851
|
+
default: "list",
|
|
852
|
+
},
|
|
853
|
+
query: {
|
|
854
|
+
type: "string",
|
|
855
|
+
description:
|
|
856
|
+
"The saved query — a JSON string of query-notes parameters (tag, exclude_tags, search, metadata, order_by, sort, ...). This is what actually determines which notes the view shows; kind only says how to draw them.",
|
|
857
|
+
},
|
|
858
|
+
lane_by: {
|
|
859
|
+
type: "string",
|
|
860
|
+
description:
|
|
861
|
+
"Board views only: the metadata field whose distinct values become the board's lanes/columns. Unset outside kind: board.",
|
|
862
|
+
},
|
|
863
|
+
date_field: {
|
|
864
|
+
type: "string",
|
|
865
|
+
description:
|
|
866
|
+
"Calendar views only: the date-typed metadata field the calendar plots notes against. Unset outside kind: calendar.",
|
|
867
|
+
},
|
|
868
|
+
},
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
export const ARCHIVED_TAG: SeedPackTag = {
|
|
872
|
+
name: "archived",
|
|
873
|
+
description:
|
|
874
|
+
"A plain tag — no schema, just a convention: this note is out of the flow of the present. Not deleted, not wrong, just done, closed, or set aside for now. The one behavior worth relying on: default views exclude archived notes unless a view's own query says otherwise (an Archive view, for instance, asks for them back on purpose). Archiving a note doesn't touch its content, tags, or links — it only changes whether it shows up by default. What actually gets archived, and when, is yours to decide: a finished project, last month's drafts, a conversation that's resolved. Different people will draw that line differently, and that's fine — the tag only promises the one thing above.",
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
export const PINNED_TAG: SeedPackTag = {
|
|
878
|
+
name: "pinned",
|
|
879
|
+
description:
|
|
880
|
+
"A plain tag — no schema, just a convention: this note gets surfaced first. Pinning is a partition, not a sort — a view that honors it renders pinned notes as their own small group above everything else, rather than nudging them up the existing order. That also means pinning doesn't fight with however the rest of a view is sorted; it just carves out a \"see this first\" spot above it. There's no built-in limit on how many notes can be pinned, and no rule about what belongs there — a current focus, something you keep needing to find, a note you're mid-thought on. Unpin by removing the tag. Different people, and different views, will use it differently; the tag only promises the placement, not the policy.",
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
export const VIEWS_PATH_PREFIX = "Views/";
|
|
884
|
+
export const ALL_NOTES_VIEW_PATH = `${VIEWS_PATH_PREFIX}All notes`;
|
|
885
|
+
export const RECENT_VIEW_PATH = `${VIEWS_PATH_PREFIX}Recent`;
|
|
886
|
+
export const PINNED_VIEW_PATH = `${VIEWS_PATH_PREFIX}Pinned`;
|
|
887
|
+
export const ARCHIVE_VIEW_PATH = `${VIEWS_PATH_PREFIX}Archive`;
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Build the `starter-ontology` pack: the four meta tags above, plus four
|
|
891
|
+
* seed `#view` notes mirroring the app's default pages — All notes, Recent,
|
|
892
|
+
* Pinned, Archive — so "default app pages are shipped views" has its data
|
|
893
|
+
* half from day one. Each view's `query` is written out explicitly
|
|
894
|
+
* (`exclude_tags: ["archived"]` where it applies) rather than relying on any
|
|
895
|
+
* surface-side default, per the authoring convention in `VIEW_TAG`'s own
|
|
896
|
+
* description. Kept tiny on purpose — these are starting points, not a
|
|
897
|
+
* demonstration of everything the schema can do.
|
|
898
|
+
*
|
|
899
|
+
* NOT applied by `src/onboarding-seed.ts`'s default materialization — opt-in
|
|
900
|
+
* only, via `parachute-vault add-pack starter-ontology` or a console
|
|
901
|
+
* affordance. A fresh-vault UX change (making this part of the default seed)
|
|
902
|
+
* is a separate decision; this pack existing makes that a one-line flip
|
|
903
|
+
* later, deliberately not taken here.
|
|
904
|
+
*/
|
|
905
|
+
export const STARTER_ONTOLOGY_PACK: SeedPack = {
|
|
906
|
+
name: "starter-ontology",
|
|
907
|
+
description:
|
|
908
|
+
"The starter ontology: one meta tag (view, with a schema) and three plain tags (archived, pinned, capture), each with a constitution-style description — plus four seed #view notes (All notes, Recent, Pinned, Archive) mirroring the app's default pages. Opt-in — not seeded by default.",
|
|
909
|
+
// `capture` reused verbatim from NOTES_REQUIRED_TAGS (byte-equal to the
|
|
910
|
+
// welcome pack's — see the module doc above and NOTES_REQUIRED_TAGS'
|
|
911
|
+
// comment). Order matches the constitution ordering, not upsert order.
|
|
912
|
+
tags: [VIEW_TAG, ARCHIVED_TAG, PINNED_TAG, ...NOTES_REQUIRED_TAGS],
|
|
913
|
+
notes: [
|
|
914
|
+
{
|
|
915
|
+
path: ALL_NOTES_VIEW_PATH,
|
|
916
|
+
tags: ["view"],
|
|
917
|
+
content: `# All notes
|
|
918
|
+
|
|
919
|
+
Everything in the vault except what's archived. The default view — no tag
|
|
920
|
+
filter, no sort override, just the whole thing minus the notes you've set
|
|
921
|
+
aside.
|
|
922
|
+
`,
|
|
923
|
+
metadata: {
|
|
924
|
+
kind: "list",
|
|
925
|
+
query: JSON.stringify({ exclude_tags: ["archived"] }),
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
path: RECENT_VIEW_PATH,
|
|
930
|
+
tags: ["view"],
|
|
931
|
+
content: `# Recent
|
|
932
|
+
|
|
933
|
+
The vault, newest edits first. Same notes as [[${ALL_NOTES_VIEW_PATH}]] —
|
|
934
|
+
archived excluded — just ordered by when they last changed.
|
|
935
|
+
`,
|
|
936
|
+
metadata: {
|
|
937
|
+
kind: "list",
|
|
938
|
+
query: JSON.stringify({
|
|
939
|
+
exclude_tags: ["archived"],
|
|
940
|
+
order_by: "updated_at",
|
|
941
|
+
sort: "desc",
|
|
942
|
+
}),
|
|
943
|
+
},
|
|
944
|
+
},
|
|
945
|
+
{
|
|
946
|
+
path: PINNED_VIEW_PATH,
|
|
947
|
+
tags: ["view"],
|
|
948
|
+
content: `# Pinned
|
|
949
|
+
|
|
950
|
+
Whatever you've marked #pinned — the notes surfaced first, regardless of how
|
|
951
|
+
anything else in the vault is organized.
|
|
952
|
+
`,
|
|
953
|
+
metadata: {
|
|
954
|
+
kind: "list",
|
|
955
|
+
query: JSON.stringify({ tag: "pinned" }),
|
|
956
|
+
},
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
path: ARCHIVE_VIEW_PATH,
|
|
960
|
+
tags: ["view"],
|
|
961
|
+
content: `# Archive
|
|
962
|
+
|
|
963
|
+
Everything tagged #archived — out of the flow of the present, but never
|
|
964
|
+
gone. The one view that asks archived notes back on purpose.
|
|
965
|
+
`,
|
|
966
|
+
metadata: {
|
|
967
|
+
kind: "list",
|
|
968
|
+
query: JSON.stringify({ tag: "archived" }),
|
|
969
|
+
},
|
|
970
|
+
},
|
|
971
|
+
],
|
|
972
|
+
};
|
|
973
|
+
|
|
801
974
|
// ---------------------------------------------------------------------------
|
|
802
975
|
// Registry
|
|
803
976
|
// ---------------------------------------------------------------------------
|
|
@@ -807,6 +980,7 @@ export const SEED_PACK_NAMES = [
|
|
|
807
980
|
"welcome",
|
|
808
981
|
"getting-started",
|
|
809
982
|
"surface-starter",
|
|
983
|
+
"starter-ontology",
|
|
810
984
|
] as const;
|
|
811
985
|
|
|
812
986
|
export type SeedPackName = (typeof SEED_PACK_NAMES)[number];
|
|
@@ -835,6 +1009,8 @@ export function getSeedPack(
|
|
|
835
1009
|
return GETTING_STARTED_PACK;
|
|
836
1010
|
case "surface-starter":
|
|
837
1011
|
return SURFACE_STARTER_PACK;
|
|
1012
|
+
case "starter-ontology":
|
|
1013
|
+
return STARTER_ONTOLOGY_PACK;
|
|
838
1014
|
default:
|
|
839
1015
|
return null;
|
|
840
1016
|
}
|
package/core/src/store.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
loadSchemaConfig,
|
|
39
39
|
validateNote as runValidateNote,
|
|
40
40
|
resolveNoteSchemas,
|
|
41
|
+
normalizeDateFields,
|
|
41
42
|
type ResolvedSchemas,
|
|
42
43
|
type ValidationStatus,
|
|
43
44
|
} from "./schema-defaults.js";
|
|
@@ -500,6 +501,16 @@ export class BunSqliteStore implements Store {
|
|
|
500
501
|
// ---- Notes ----
|
|
501
502
|
|
|
502
503
|
async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string; actor?: string | null; via?: string | null }): Promise<Note> {
|
|
504
|
+
// Normalize `date`-typed field values to canonical UTC ISO form BEFORE
|
|
505
|
+
// the write (vault#date-field-type — mixed-offset TEXT-compare
|
|
506
|
+
// corruption). COPY-ON-WRITE (round 2) — reassign the local `opts`
|
|
507
|
+
// binding from the returned value rather than mutating the caller's
|
|
508
|
+
// object; see `normalizeDateFields`'s doc comment.
|
|
509
|
+
if (opts?.metadata) {
|
|
510
|
+
const normalized = normalizeDateFields(this.getSchemaConfig(), { tags: opts.tags, metadata: opts.metadata });
|
|
511
|
+
if (normalized !== opts.metadata) opts = { ...opts, metadata: normalized };
|
|
512
|
+
}
|
|
513
|
+
|
|
503
514
|
const note = noteOps.createNote(this.db, content, opts);
|
|
504
515
|
|
|
505
516
|
if (content) {
|
|
@@ -548,6 +559,20 @@ export class BunSqliteStore implements Store {
|
|
|
548
559
|
actor?: string | null;
|
|
549
560
|
via?: string | null;
|
|
550
561
|
if_updated_at?: string;
|
|
562
|
+
/**
|
|
563
|
+
* `date`-field normalization override (vault#date-field-type review
|
|
564
|
+
* round 2). By default, `normalizeDateFields` below resolves the note's
|
|
565
|
+
* effective schema against its CURRENT tags in the DB — correct when
|
|
566
|
+
* this call doesn't also change tags. A caller that adds a tag IN THIS
|
|
567
|
+
* SAME logical update (via a separate `store.tagNote` issued right
|
|
568
|
+
* after this call — see mcp.ts's `update-note` handler and the batch
|
|
569
|
+
* upsert "update"/"replace" branch) must pass the PROJECTED final tag
|
|
570
|
+
* set here instead, or a newly-added tag's `type: "date"` field never
|
|
571
|
+
* gets seen (the schema resolution would still be looking at the
|
|
572
|
+
* pre-write tag set) and its offset-bearing value would persist
|
|
573
|
+
* verbatim. Ignored when `updates.metadata` is undefined.
|
|
574
|
+
*/
|
|
575
|
+
tagsForSchemaResolution?: string[];
|
|
551
576
|
},
|
|
552
577
|
): Promise<Note> {
|
|
553
578
|
let oldPath: string | undefined;
|
|
@@ -561,7 +586,19 @@ export class BunSqliteStore implements Store {
|
|
|
561
586
|
if (updates.path !== undefined || updates.metadata !== undefined) {
|
|
562
587
|
const existing = noteOps.getNote(this.db, id);
|
|
563
588
|
if (updates.path !== undefined) oldPath = existing?.path;
|
|
564
|
-
if (updates.metadata !== undefined)
|
|
589
|
+
if (updates.metadata !== undefined) {
|
|
590
|
+
priorMetadataForRefs = existing?.metadata;
|
|
591
|
+
// Normalize `date`-typed field values to canonical UTC ISO form
|
|
592
|
+
// BEFORE the write (vault#date-field-type — mixed-offset TEXT-
|
|
593
|
+
// compare corruption). COPY-ON-WRITE (round 2) — reassign the local
|
|
594
|
+
// `updates` binding from the returned value rather than mutating the
|
|
595
|
+
// caller's object; see `normalizeDateFields`'s doc comment.
|
|
596
|
+
// `tagsForSchemaResolution` (when the caller is ALSO adding a tag in
|
|
597
|
+
// this same logical update) wins over the note's current DB tags.
|
|
598
|
+
const tagsForResolution = updates.tagsForSchemaResolution ?? existing?.tags;
|
|
599
|
+
const normalized = normalizeDateFields(this.getSchemaConfig(), { tags: tagsForResolution, metadata: updates.metadata });
|
|
600
|
+
if (normalized !== updates.metadata) updates = { ...updates, metadata: normalized };
|
|
601
|
+
}
|
|
565
602
|
}
|
|
566
603
|
|
|
567
604
|
const note = noteOps.updateNote(this.db, id, updates);
|
|
@@ -1034,7 +1071,18 @@ export class BunSqliteStore implements Store {
|
|
|
1034
1071
|
// ---- Bulk Operations ----
|
|
1035
1072
|
|
|
1036
1073
|
async createNotes(inputs: noteOps.BulkNoteInput[]): Promise<Note[]> {
|
|
1037
|
-
|
|
1074
|
+
// Same pre-write `date`-field normalization as singleton createNote
|
|
1075
|
+
// (vault#date-field-type) — this bulk path bypasses it otherwise.
|
|
1076
|
+
// COPY-ON-WRITE (round 2) — build a new array with only the items that
|
|
1077
|
+
// actually need a rewrite replaced; never mutate the caller's `inputs`
|
|
1078
|
+
// or its element objects.
|
|
1079
|
+
const schemaConfig = this.getSchemaConfig();
|
|
1080
|
+
const normalizedInputs = inputs.map((input) => {
|
|
1081
|
+
if (!input.metadata) return input;
|
|
1082
|
+
const normalized = normalizeDateFields(schemaConfig, { tags: input.tags, metadata: input.metadata });
|
|
1083
|
+
return normalized !== input.metadata ? { ...input, metadata: normalized } : input;
|
|
1084
|
+
});
|
|
1085
|
+
const notes = noteOps.createNotes(this.db, normalizedInputs);
|
|
1038
1086
|
for (const note of notes) {
|
|
1039
1087
|
// Bulk path needs the same config-cache invalidation as singleton
|
|
1040
1088
|
// createNote — without it, a batch that includes `_tags/*` notes
|
|
@@ -1244,7 +1292,7 @@ export class BunSqliteStore implements Store {
|
|
|
1244
1292
|
const mapped = indexedFieldOps.mapFieldType(spec.type);
|
|
1245
1293
|
if (!mapped) {
|
|
1246
1294
|
throw new indexedFieldOps.IndexedFieldError(
|
|
1247
|
-
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean, reference)`,
|
|
1295
|
+
`field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean, reference, date)`,
|
|
1248
1296
|
);
|
|
1249
1297
|
}
|
|
1250
1298
|
// Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
|
|
@@ -1376,6 +1424,14 @@ export class BunSqliteStore implements Store {
|
|
|
1376
1424
|
* place.)
|
|
1377
1425
|
*/
|
|
1378
1426
|
async createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note> {
|
|
1427
|
+
// Same pre-write `date`-field normalization as createNote
|
|
1428
|
+
// (vault#date-field-type) — the legacy Obsidian importer (obsidian.ts)
|
|
1429
|
+
// funnels through this path and bypasses createNote's copy otherwise.
|
|
1430
|
+
// COPY-ON-WRITE (round 2) — see normalizeDateFields's doc comment.
|
|
1431
|
+
if (opts?.metadata) {
|
|
1432
|
+
const normalized = normalizeDateFields(this.getSchemaConfig(), { tags: opts.tags, metadata: opts.metadata });
|
|
1433
|
+
if (normalized !== opts.metadata) opts = { ...opts, metadata: normalized };
|
|
1434
|
+
}
|
|
1379
1435
|
return noteOps.createNote(this.db, content, opts);
|
|
1380
1436
|
}
|
|
1381
1437
|
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { Database } from "bun:sqlite";
|
|
19
19
|
import { mapFieldType, validateFieldName } from "./indexed-fields.js";
|
|
20
20
|
import { loadTagHierarchy, findParentCycle } from "./tag-hierarchy.js";
|
|
21
|
+
import { timestampToMs } from "./cursor.js";
|
|
21
22
|
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
23
24
|
// Types
|
|
@@ -192,7 +193,7 @@ export class InvalidFieldDefaultError extends Error {
|
|
|
192
193
|
|
|
193
194
|
/**
|
|
194
195
|
* Thrown by `upsertTagRecord` (core/src/store.ts's chokepoint) when a
|
|
195
|
-
* field's declared `type` isn't one of the
|
|
196
|
+
* field's declared `type` isn't one of the recognized values (vault#555
|
|
196
197
|
* — `update-tag{fields:{weird:{type:"frobnicator"}}}` used to be accepted
|
|
197
198
|
* and persisted verbatim, no error, for any NON-indexed field: the only
|
|
198
199
|
* existing type check, `mapFieldType`, ran solely on `indexed: true` fields
|
|
@@ -255,13 +256,13 @@ export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFi
|
|
|
255
256
|
|
|
256
257
|
/**
|
|
257
258
|
* The full recognized vocabulary for `TagFieldSchema.type` — storage/
|
|
258
|
-
* advisory validation accepts all
|
|
259
|
-
* `reference` are INDEXABLE (that narrower subset is
|
|
260
|
-
* `TYPE_MAP`, enforced separately via `mapFieldType`
|
|
261
|
-
* fields). Matches `defaultMatchesType`'s switch and
|
|
262
|
-
* `SchemaField.type` union — kept in lockstep by hand
|
|
263
|
-
* deliberately-decoupled modules (see `validateFieldDefault`'s
|
|
264
|
-
* for why they don't cross-import).
|
|
259
|
+
* advisory validation accepts all eight; only `string`/`integer`/`boolean`/
|
|
260
|
+
* `reference`/`date` are INDEXABLE (that narrower subset is
|
|
261
|
+
* `indexed-fields.ts`'s `TYPE_MAP`, enforced separately via `mapFieldType`
|
|
262
|
+
* for `indexed: true` fields). Matches `defaultMatchesType`'s switch and
|
|
263
|
+
* `schema-defaults.ts`'s `SchemaField.type` union — kept in lockstep by hand
|
|
264
|
+
* across the two deliberately-decoupled modules (see `validateFieldDefault`'s
|
|
265
|
+
* doc comment for why they don't cross-import).
|
|
265
266
|
*
|
|
266
267
|
* `reference` (vault#typed-reference-field) is a dual-write field type: the
|
|
267
268
|
* value is stored + validated exactly like `string` (an id/path/title the
|
|
@@ -270,11 +271,18 @@ export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFi
|
|
|
270
271
|
* to a note and maintains a graph `links` edge from this note to the
|
|
271
272
|
* resolved target, with `relationship` set to the field name. See
|
|
272
273
|
* `docs/design/typed-reference-field.md` for the full design + known gaps.
|
|
274
|
+
*
|
|
275
|
+
* `date` stores/validates like `string` (an ISO-8601 date or timestamp) so
|
|
276
|
+
* indexed `date` fields sort correctly under a plain TEXT comparison — see
|
|
277
|
+
* `defaultMatchesType`'s `"date"` case and `schema-defaults.ts`'s
|
|
278
|
+
* `valueMatchesType`, both of which reuse `cursor.ts`'s `timestampToMs`
|
|
279
|
+
* (the SAME UTC-correct parser `date_filter`'s `updated_at` bound uses) so
|
|
280
|
+
* there's exactly one ISO-parsing implementation in the codebase, not two.
|
|
273
281
|
*/
|
|
274
|
-
export const VALID_FIELD_TYPES = ["string", "number", "integer", "boolean", "array", "object", "reference"] as const;
|
|
282
|
+
export const VALID_FIELD_TYPES = ["string", "number", "integer", "boolean", "array", "object", "reference", "date"] as const;
|
|
275
283
|
|
|
276
284
|
/**
|
|
277
|
-
* Validate that a field's declared `type` is one of the
|
|
285
|
+
* Validate that a field's declared `type` is one of the recognized
|
|
278
286
|
* values (vault#555). Returns `null` when `type` is unset (own-field checks
|
|
279
287
|
* elsewhere already treat an unset type as "nothing to check against") or
|
|
280
288
|
* recognized; otherwise a {@link TagFieldViolation} with `reason:
|
|
@@ -328,7 +336,7 @@ export function collectOwnFieldDefaultAndTypeViolations(
|
|
|
328
336
|
return violations;
|
|
329
337
|
}
|
|
330
338
|
|
|
331
|
-
/** Same
|
|
339
|
+
/** Same eight-type vocabulary as `TagFieldSchema.type`'s doc comment. Unknown/unset types pass (nothing to check against). */
|
|
332
340
|
function defaultMatchesType(value: unknown, type: string): boolean {
|
|
333
341
|
switch (type) {
|
|
334
342
|
case "string":
|
|
@@ -348,6 +356,13 @@ function defaultMatchesType(value: unknown, type: string): boolean {
|
|
|
348
356
|
// VALID_FIELD_TYPES's doc comment.
|
|
349
357
|
case "reference":
|
|
350
358
|
return typeof value === "string";
|
|
359
|
+
// `date` accepts an ISO-8601 date (`YYYY-MM-DD`) or full RFC3339
|
|
360
|
+
// timestamp — the SAME grammar `date_filter`'s `updated_at` bound
|
|
361
|
+
// parses (`timestampToMs`, imported from cursor.ts), not a second,
|
|
362
|
+
// independently-drifting date parser. See VALID_FIELD_TYPES's doc
|
|
363
|
+
// comment.
|
|
364
|
+
case "date":
|
|
365
|
+
return typeof value === "string" && timestampToMs(value) !== null;
|
|
351
366
|
default:
|
|
352
367
|
return true;
|
|
353
368
|
}
|
|
@@ -767,7 +782,7 @@ export function collectTagFieldViolations(
|
|
|
767
782
|
violations.push({
|
|
768
783
|
field: fieldName,
|
|
769
784
|
reason: "unsupported_indexed_type",
|
|
770
|
-
message: `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean, reference)`,
|
|
785
|
+
message: `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean, reference, date)`,
|
|
771
786
|
});
|
|
772
787
|
} else {
|
|
773
788
|
try {
|