From 5a480b93173fbb2dc8a2dcc1b330a1b50252150e Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 29 Sep 2026 09:55:42 +1000 Subject: [PATCH 1/3] Store saved views as records claimed through obsidian.base and mdbase.view Bases are YAML document records whose type implements obsidian.base, and mdbase views are records whose type implements mdbase.view. TaskNotes provisions both published packs during collection setup, requires md and base record extensions, and reads, writes and deletes view sources with the ordinary record operations instead of the view-source operations, so it no longer asks for views.manage. Requires an mdbase connect release whose connectors advertise yaml-document-records-v1 and whose setup accepts the record_extensions target. --- scripts/tasknotes-manifest.mjs | 49 ++++++- scripts/tasknotes-manifest.test.mjs | 27 +++- src/cloud/connect.test.ts | 3 +- src/domain/default-view-source.test.ts | 27 ++++ src/domain/default-view-source.ts | 37 ++++- src/generated/mdbase-app.json | 130 ++++++++++++++++- src/storage/mdbase-repository.test.ts | 6 +- src/storage/mdbase-repository.ts | 63 ++++++-- src/storage/mdbase-scratchpad-stream.test.ts | 2 +- src/test/mdbase-fixture.ts | 135 +++++++----------- .../mdbase-contracts/mdbase.view-1.0.0.json | 53 +++++++ .../mdbase-contracts/obsidian.base-1.0.0.json | 42 ++++++ 12 files changed, 468 insertions(+), 106 deletions(-) create mode 100644 vendor/mdbase-contracts/mdbase.view-1.0.0.json create mode 100644 vendor/mdbase-contracts/obsidian.base-1.0.0.json diff --git a/scripts/tasknotes-manifest.mjs b/scripts/tasknotes-manifest.mjs index 0d45ae3a..cf4495da 100644 --- a/scripts/tasknotes-manifest.mjs +++ b/scripts/tasknotes-manifest.mjs @@ -2,6 +2,8 @@ import { MDBASE_TIMER_FIRED_CONTRACT } from "@mdbase-dev/connect-protocol"; import { loadCanonicalTaskNotesTypePack } from "./canonical-task-pack.mjs"; import { buildScratchpadTypePack } from "./scratchpad-type.mjs"; import { buildScratchImageTypePack } from "./scratch-image-type.mjs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; export { TASKNOTES_APP_TYPE_PACK_VERSION } from "./canonical-task-pack.mjs"; @@ -15,6 +17,18 @@ export async function buildTaskNotesManifest({ // authorization should use a public tunnel and TASKNOTES_APP_URL. const identityUrl = appUrl; const typePack = await loadCanonicalTaskNotesTypePack(); + // Saved views are records: Bases through obsidian.base (YAML document + // records) and mdbase views through mdbase.view. The published packs are + // embedded byte for byte. + const viewPack = async (file) => + JSON.parse( + await readFile( + resolve(process.cwd(), "vendor/mdbase-contracts", file), + "utf8", + ), + ); + const basePack = await viewPack("obsidian.base-1.0.0.json"); + const mdbaseViewPack = await viewPack("mdbase.view-1.0.0.json"); const taskContract = typePack.provides.find( (contract) => contract.id === "tasknotes.task", ); @@ -32,7 +46,11 @@ export async function buildTaskNotesManifest({ ], requirements: { people: { version: 1, optional: ["identity", "members"] }, - contracts: [taskContract], + contracts: [ + taskContract, + ...basePack.provides, + ...mdbaseViewPack.provides, + ], capabilities: { contract_version: 2, required: [ @@ -40,7 +58,6 @@ export async function buildTaskNotesManifest({ "records.create", "records.edit", "records.delete", - "views.manage", "definitions.manage", "background.schedule", ], @@ -57,6 +74,20 @@ export async function buildTaskNotesManifest({ predicate: "contains", value: "TaskNotes/Views/**/*.base", }, + // record_extensions is the complete set, so keep Markdown explicitly + // when adding Bases. + { + id: "tasknotes-markdown-records", + path: "/settings/record_extensions", + predicate: "contains", + value: "md", + }, + { + id: "tasknotes-base-records", + path: "/settings/record_extensions", + predicate: "contains", + value: "base", + }, ], }, provisions: { @@ -64,6 +95,8 @@ export async function buildTaskNotesManifest({ typePack, buildScratchpadTypePack(), buildScratchImageTypePack(), + basePack, + mdbaseViewPack, ], configuration: [ { @@ -72,6 +105,18 @@ export async function buildTaskNotesManifest({ path: "/x-obsidian/bases/include", value: "TaskNotes/Views/**/*.base", }, + { + requirement: "tasknotes-markdown-records", + operation: "set_add", + path: "/settings/record_extensions", + value: "md", + }, + { + requirement: "tasknotes-base-records", + operation: "set_add", + path: "/settings/record_extensions", + value: "base", + }, ], }, notifications: { diff --git a/scripts/tasknotes-manifest.test.mjs b/scripts/tasknotes-manifest.test.mjs index 75438298..7e370838 100644 --- a/scripts/tasknotes-manifest.test.mjs +++ b/scripts/tasknotes-manifest.test.mjs @@ -40,6 +40,8 @@ describe("TaskNotes mdbase manifest", () => { version: "0.3.0-rc.5", digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/), }), + expect.objectContaining({ id: "obsidian.base", version: "1.0.0" }), + expect.objectContaining({ id: "mdbase.view", version: "1.0.0" }), ]); expect(manifest.requirements.files).toEqual({ required: ["list", "read", "add", "replace", "move", "delete"], @@ -54,7 +56,6 @@ describe("TaskNotes mdbase manifest", () => { "records.create", "records.edit", "records.delete", - "views.manage", "definitions.manage", "background.schedule", ], @@ -66,6 +67,18 @@ describe("TaskNotes mdbase manifest", () => { predicate: "contains", value: "TaskNotes/Views/**/*.base", }, + { + id: "tasknotes-markdown-records", + path: "/settings/record_extensions", + predicate: "contains", + value: "md", + }, + { + id: "tasknotes-base-records", + path: "/settings/record_extensions", + predicate: "contains", + value: "base", + }, ]); expect(manifest.provisions.configuration).toEqual([ { @@ -74,6 +87,18 @@ describe("TaskNotes mdbase manifest", () => { path: "/x-obsidian/bases/include", value: "TaskNotes/Views/**/*.base", }, + { + requirement: "tasknotes-markdown-records", + operation: "set_add", + path: "/settings/record_extensions", + value: "md", + }, + { + requirement: "tasknotes-base-records", + operation: "set_add", + path: "/settings/record_extensions", + value: "base", + }, ]); expect(manifest.provisions.type_packs[0].manifest.resources).toHaveLength( 4, diff --git a/src/cloud/connect.test.ts b/src/cloud/connect.test.ts index c8642a83..68418a83 100644 --- a/src/cloud/connect.test.ts +++ b/src/cloud/connect.test.ts @@ -87,7 +87,6 @@ describe("TaskNotes mdbase session", () => { "update", "rename", "delete", - "delete_view_source", "apply_type_pack", "reconcile_timers", "assess_collection_setup", @@ -95,6 +94,8 @@ describe("TaskNotes mdbase session", () => { ]), ); expect(expectedOperations).not.toContain("sync"); + // Saved views are records written with the record operations. + expect(expectedOperations).not.toContain("delete_view_source"); }); it("evaluates required capabilities through the real SDK without throwing", () => { diff --git a/src/domain/default-view-source.test.ts b/src/domain/default-view-source.test.ts index 4c25847d..28c23c9a 100644 --- a/src/domain/default-view-source.test.ts +++ b/src/domain/default-view-source.test.ts @@ -8,7 +8,10 @@ import { taskNotesDefaultBaseDocument, taskNotesDefaultBaseSources, taskNotesDefaultCanonicalDocument, + newViewSourcePath, taskNotesViewSourcePath, + viewSourceFormat, + viewSourceRecord, } from "./default-view-source"; import { ensureTaskNotesDefaultViewSource } from "../application/ensure-default-view-source"; @@ -395,3 +398,27 @@ function legacyStarterDocument(): TaskViewDocument { ), }; } + +describe("saved-view records", () => { + it("stores Bases as YAML document records and mdbase views as Markdown", () => { + const base = newViewSourcePath("obsidian.base", "Focused work"); + expect(base).toBe("TaskNotes/Views/focused-work.base"); + expect(viewSourceFormat(base)).toBe("obsidian.base"); + expect( + viewSourceRecord(base, "# kept by Obsidian\nviews:\n - type: table\n"), + ).toEqual({ frontmatter: { views: [{ type: "table" }] } }); + + const view = newViewSourcePath("mdbase.view", "Focused work"); + expect(view).toBe("views/focused-work.md"); + expect(viewSourceFormat(view)).toBe("mdbase.view"); + expect( + viewSourceRecord(view, "---\ntype: view\n---\nNotes\n"), + ).toMatchObject({ frontmatter: { type: "view" }, body: "Notes\n" }); + }); + + it("rejects a Base that is not a YAML mapping", () => { + expect(() => + viewSourceRecord("TaskNotes/Views/list.base", "- not a mapping\n"), + ).toThrow("A Base must be a YAML mapping."); + }); +}); diff --git a/src/domain/default-view-source.ts b/src/domain/default-view-source.ts index 898fe2bc..f6380c31 100644 --- a/src/domain/default-view-source.ts +++ b/src/domain/default-view-source.ts @@ -1,4 +1,7 @@ -import { serializeMarkdownDocument } from "@tasknotes/model/frontmatter"; +import { + parseFrontmatter, + serializeMarkdownDocument, +} from "@tasknotes/model/frontmatter"; import { parse, stringify } from "yaml"; import type { TaskCollectionConfiguration } from "./task-configuration"; @@ -387,3 +390,35 @@ export function basesProperty(field: string): string { function literal(value: string): string { return JSON.stringify(value); } + +/** Saved-view sources are records: `.base` Bases and `.md` mdbase views. */ +export function viewSourceFormat( + path: string, +): "obsidian.base" | "mdbase.view" { + return path.endsWith(".base") ? "obsidian.base" : "mdbase.view"; +} + +/** Where a new saved view is stored when the caller names no path. */ +export function newViewSourcePath(format: string, name: string): string { + if (format === "obsidian.base") return taskNotesViewSourcePath(name); + return taskNotesViewSourcePath(name) + .replace(/^TaskNotes\/Views\//u, "views/") + .replace(/\.base$/u, ".md"); +} + +/** The record a view source document stores, in the format its path fixes. */ +export function viewSourceRecord( + path: string, + document: string, +): { + frontmatter: Record; + body?: string; +} { + if (viewSourceFormat(path) === "obsidian.base") { + const value: unknown = parse(document); + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("A Base must be a YAML mapping."); + return { frontmatter: value as Record }; + } + return parseFrontmatter(document); +} diff --git a/src/generated/mdbase-app.json b/src/generated/mdbase-app.json index 05c54ae6..7694c41a 100644 --- a/src/generated/mdbase-app.json +++ b/src/generated/mdbase-app.json @@ -15,6 +15,16 @@ "id": "tasknotes.task", "version": "0.3.0-rc.5", "digest": "sha256:dde71d0d776aa0069ee627fe3c10b1a20f87ba36929a16790598ebfb4e8ebf04" + }, + { + "id": "obsidian.base", + "version": "1.0.0", + "digest": "sha256:37e8a0bfb32bb4795c1b3c5dec613c32429aaa765dc33e4adc0c627a48f5c1ff" + }, + { + "id": "mdbase.view", + "version": "1.0.0", + "digest": "sha256:0918acefce08cddd778fab768fe4f2e4615d263a0eec3e5330b6897c686d744e" } ], "capabilities": { @@ -24,7 +34,6 @@ "records.create", "records.edit", "records.delete", - "views.manage", "definitions.manage", "background.schedule" ] @@ -40,6 +49,18 @@ "path": "/x-obsidian/bases/include", "predicate": "contains", "value": "TaskNotes/Views/**/*.base" + }, + { + "id": "tasknotes-markdown-records", + "path": "/settings/record_extensions", + "predicate": "contains", + "value": "md" + }, + { + "id": "tasknotes-base-records", + "path": "/settings/record_extensions", + "predicate": "contains", + "value": "base" } ] }, @@ -162,6 +183,101 @@ } ], "provides": [] + }, + { + "manifest": { + "kind": "mdbase.type-pack", + "id": "obsidian.base", + "version": "1.0.0", + "name": "Obsidian Bases", + "description": "The obsidian.base contract and an editable type that stores Obsidian Bases as records.", + "resources": [ + { + "kind": "contract", + "mode": "managed", + "source": "contracts/obsidian.base/1.0.0.md", + "target": "_contracts/obsidian.base/1.0.0.md", + "digest": "sha256:fca77a9c845223784d4af7f5e13e2f149295621ad1b794ea9bb4d99f604467e8" + }, + { + "kind": "type", + "mode": "seed", + "source": "types/obsidian-base/1.md", + "target": "_types/obsidian_base.md", + "digest": "sha256:bfeb3880200bc1bef7fd50b777d3f927ce931d08b4fb3c30d4cddb55feaf3f92" + } + ] + }, + "resources": [ + { + "source": "contracts/obsidian.base/1.0.0.md", + "document": "---\nkind: mdbase.contract\ncontract_type: record\nid: obsidian.base\nversion: 1.0.0\nname: Obsidian Base\ndescription: An Obsidian Bases saved-view source stored as a YAML document record.\nrecord_schema:\n dialect: json-schema-2020-12\n value:\n $schema: https://json-schema.org/draft/2020-12/schema\n type: object\n required: [views]\n properties:\n filters: {}\n formulas: { type: object }\n properties: { type: object }\n views:\n type: array\n minItems: 1\n items: { type: object, required: [type], properties: { type: { type: string }, name: { type: string } } }\n---\n\n# Obsidian Base\n\nA record exposed through `obsidian.base` is an Obsidian Bases source: global\nfilters, formulas, property metadata, and one or more views. The schema is\ndeliberately permissive. Obsidian owns the format and adds keys over time;\nunknown keys are preserved and ignored.\n\nTools that advertise `obsidian_bases_views` discover and execute these records\nthrough this contract and evaluate them with the Obsidian Bases expression\ndialect described in the [Obsidian Bases adapter](https://mdbase.dev/spec/adapters/obsidian-bases).\n\nThis artifact is passive. Implementing it grants no authority to read, execute,\nor modify any record.\n" + }, + { + "source": "types/obsidian-base/1.md", + "document": "---\nkind: mdbase.type\nname: obsidian_base\nversion: 1\ndescription: Obsidian Bases saved views stored as records\nmatch:\n path_glob: '**/*.base'\nschema:\n dialect: json-schema-2020-12\n value:\n $schema: https://json-schema.org/draft/2020-12/schema\n title: Obsidian Base\n description: An Obsidian Bases file. Obsidian owns the format; unknown keys are kept.\n type: object\n required:\n - views\n properties:\n filters: {}\n formulas:\n type: object\n properties:\n type: object\n views:\n type: array\n minItems: 1\n items:\n type: object\n required:\n - type\n properties:\n type:\n type: string\n name:\n type: string\nimplements:\n - contract: obsidian.base\n version: 1.0.0\n fields:\n filters: filters\n formulas: formulas\n properties: properties\n views: views\n---\n\n# Obsidian Base\n\nMakes `.base` files discoverable as saved views through the `obsidian.base`\ncontract. It takes effect when the collection lists `base` in\n`settings.record_extensions`; narrow the `path_glob` to the folders that hold\nyour Bases. Edit Bases with whole-document replacement so Obsidian's comments\nand layout survive.\n" + } + ], + "provides": [ + { + "id": "obsidian.base", + "version": "1.0.0", + "digest": "sha256:37e8a0bfb32bb4795c1b3c5dec613c32429aaa765dc33e4adc0c627a48f5c1ff" + } + ] + }, + { + "manifest": { + "kind": "mdbase.type-pack", + "id": "mdbase.view", + "version": "1.0.0", + "name": "Saved views", + "description": "The mdbase.view contract and an editable View type for saved, reusable queries.", + "resources": [ + { + "kind": "schema", + "mode": "managed", + "source": "schemas/mdbase.view/1.0.0.schema.json", + "target": "schemas/mdbase.view/1.0.0.schema.json", + "digest": "sha256:d58b8b691c14d59f0575fc1cc93ab5b46363c485e4469bc58039ec0cece7ca36" + }, + { + "kind": "contract", + "mode": "managed", + "source": "contracts/mdbase.view/1.0.0.md", + "target": "_contracts/mdbase.view/1.0.0.md", + "digest": "sha256:91eae42371c2d411b20d3c976aece3071bbd753963b77b6bcdc26137a8fdcded" + }, + { + "kind": "type", + "mode": "seed", + "source": "types/view/1.md", + "target": "_types/view.md", + "digest": "sha256:480919f6f88f91592abb9fe7971f09e874d536c6883981616b181cf14cbe7ff4" + } + ] + }, + "resources": [ + { + "source": "schemas/mdbase.view/1.0.0.schema.json", + "document": "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://mdbase.dev/schemas/v0.3/view.schema.json\",\n \"title\": \"mdbase.view 1.0.0 record contract view\",\n \"description\": \"The contract view of a saved-view record. A type implements mdbase.view by mapping these fields; the optional type property only lets the canonical view type validate its own membership value.\",\n \"type\": \"object\",\n \"required\": [\"id\", \"version\", \"name\", \"views\"],\n \"properties\": {\n \"type\": { \"type\": \"string\", \"minLength\": 1 },\n \"id\": { \"$ref\": \"#/$defs/identifier\" },\n \"version\": { \"type\": \"integer\", \"minimum\": 1 },\n \"name\": { \"type\": \"string\", \"minLength\": 1 },\n \"description\": { \"type\": \"string\" },\n \"query\": { \"$ref\": \"#/$defs/sharedQuery\" },\n \"properties\": { \"$ref\": \"#/$defs/propertyMetadataSet\" },\n \"summary_functions\": { \"$ref\": \"#/$defs/summaryFunctionSet\" },\n \"views\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"$ref\": \"#/$defs/view\" }\n }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false,\n \"$defs\": {\n \"identifier\": {\n \"type\": \"string\",\n \"pattern\": \"^[A-Za-z][A-Za-z0-9._:-]*$\"\n },\n \"typeName\": {\n \"type\": \"string\",\n \"pattern\": \"^[A-Za-z][A-Za-z0-9_-]{0,127}$\"\n },\n \"fieldName\": {\n \"type\": \"string\",\n \"pattern\": \"^[A-Za-z_][A-Za-z0-9_:-]*$\"\n },\n \"expression\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"extension\": {\n \"type\": \"object\",\n \"additionalProperties\": true\n },\n \"typeList\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"uniqueItems\": true,\n \"items\": { \"$ref\": \"#/$defs/typeName\" }\n },\n \"sharedQuery\": {\n \"type\": \"object\",\n \"properties\": {\n \"types\": { \"$ref\": \"#/$defs/typeList\" },\n \"where\": { \"$ref\": \"#/$defs/expression\" },\n \"context\": { \"$ref\": \"#/$defs/viewContext\" },\n \"projections\": { \"$ref\": \"#/$defs/projectionSet\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"viewContext\": {\n \"type\": \"object\",\n \"required\": [\"this\"],\n \"properties\": {\n \"this\": { \"$ref\": \"#/$defs/thisContext\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"thisContext\": {\n \"type\": \"object\",\n \"properties\": {\n \"on_missing\": {\n \"enum\": [\"view\", \"null\", \"error\"],\n \"default\": \"view\"\n },\n \"types\": { \"$ref\": \"#/$defs/typeList\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"projectionSet\": {\n \"type\": \"object\",\n \"propertyNames\": { \"$ref\": \"#/$defs/fieldName\" },\n \"additionalProperties\": { \"$ref\": \"#/$defs/projection\" }\n },\n \"projection\": {\n \"type\": \"object\",\n \"required\": [\"expr\"],\n \"properties\": {\n \"expr\": { \"$ref\": \"#/$defs/expression\" },\n \"description\": { \"type\": \"string\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"propertyMetadataSet\": {\n \"type\": \"object\",\n \"propertyNames\": { \"type\": \"string\", \"minLength\": 1 },\n \"additionalProperties\": { \"$ref\": \"#/$defs/propertyMetadata\" }\n },\n \"propertyMetadata\": {\n \"type\": \"object\",\n \"properties\": {\n \"label\": { \"type\": \"string\" },\n \"description\": { \"type\": \"string\" },\n \"format\": { \"type\": \"string\" },\n \"hidden\": { \"type\": \"boolean\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"summaryFunctionSet\": {\n \"type\": \"object\",\n \"propertyNames\": { \"$ref\": \"#/$defs/fieldName\" },\n \"additionalProperties\": { \"$ref\": \"#/$defs/summaryFunction\" }\n },\n \"summaryFunction\": {\n \"type\": \"object\",\n \"required\": [\"expr\"],\n \"properties\": {\n \"expr\": { \"$ref\": \"#/$defs/expression\" },\n \"description\": { \"type\": \"string\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"view\": {\n \"type\": \"object\",\n \"required\": [\"id\", \"name\"],\n \"properties\": {\n \"id\": { \"$ref\": \"#/$defs/identifier\" },\n \"name\": { \"type\": \"string\", \"minLength\": 1 },\n \"description\": { \"type\": \"string\" },\n \"types\": { \"$ref\": \"#/$defs/typeList\" },\n \"where\": { \"$ref\": \"#/$defs/expression\" },\n \"context\": { \"$ref\": \"#/$defs/viewContext\" },\n \"projections\": { \"$ref\": \"#/$defs/projectionSet\" },\n \"select\": { \"$ref\": \"#/$defs/select\" },\n \"order_by\": { \"$ref\": \"#/$defs/orderBy\" },\n \"group_by\": { \"$ref\": \"#/$defs/groupBy\" },\n \"summaries\": { \"$ref\": \"#/$defs/summaries\" },\n \"limit\": { \"type\": \"integer\", \"minimum\": 0 },\n \"offset\": { \"type\": \"integer\", \"minimum\": 0 },\n \"include_body\": { \"type\": \"boolean\", \"default\": false },\n \"frontmatter_mode\": {\n \"enum\": [\"effective\", \"persisted\", \"both\"],\n \"default\": \"effective\"\n },\n \"presentation\": { \"$ref\": \"#/$defs/presentation\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"select\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"oneOf\": [\n { \"type\": \"string\", \"minLength\": 1 },\n { \"$ref\": \"#/$defs/selectExpression\" }\n ]\n }\n },\n \"selectExpression\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"expr\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/fieldName\" },\n \"expr\": { \"$ref\": \"#/$defs/expression\" },\n \"label\": { \"type\": \"string\" },\n \"description\": { \"type\": \"string\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"orderBy\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"field\"],\n \"properties\": {\n \"field\": { \"type\": \"string\", \"minLength\": 1 },\n \"direction\": { \"enum\": [\"asc\", \"desc\"], \"default\": \"asc\" }\n },\n \"additionalProperties\": false\n }\n },\n \"groupBy\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"field\"],\n \"properties\": {\n \"field\": { \"type\": \"string\", \"minLength\": 1 },\n \"direction\": { \"enum\": [\"asc\", \"desc\"], \"default\": \"asc\" }\n },\n \"additionalProperties\": false\n }\n },\n \"summaries\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"$ref\": \"#/$defs/summary\" }\n },\n \"summary\": {\n \"type\": \"object\",\n \"required\": [\"field\", \"function\"],\n \"properties\": {\n \"field\": { \"type\": \"string\", \"minLength\": 1 },\n \"function\": { \"$ref\": \"#/$defs/identifier\" },\n \"name\": { \"$ref\": \"#/$defs/fieldName\" },\n \"label\": { \"type\": \"string\" }\n },\n \"additionalProperties\": false\n },\n \"presentation\": {\n \"type\": \"object\",\n \"required\": [\"type\"],\n \"properties\": {\n \"type\": { \"$ref\": \"#/$defs/identifier\" },\n \"fallback\": { \"$ref\": \"#/$defs/identifier\" },\n \"mappings\": {\n \"type\": \"object\",\n \"propertyNames\": { \"$ref\": \"#/$defs/fieldName\" },\n \"additionalProperties\": { \"type\": \"string\", \"minLength\": 1 }\n },\n \"options\": {\n \"type\": \"object\",\n \"additionalProperties\": true\n }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n }\n }\n}\n" + }, + { + "source": "contracts/mdbase.view/1.0.0.md", + "document": "---\nkind: mdbase.contract\ncontract_type: record\nid: mdbase.view\nversion: 1.0.0\nname: Saved view\ndescription: Shared query scope and stable named views executable through the Query profile.\nrecord_schema:\n dialect: json-schema-2020-12\n ref: ../../schemas/mdbase.view/1.0.0.schema.json\n---\n\n# Saved view\n\nA record exposed through `mdbase.view` stores shared query scope and one or more\nstable named views. Each named view resolves to the query model in Chapter 11.\nOptional `presentation` metadata is advisory and does not alter headless query\nresults.\n\nImplementing this contract is how a collection declares which of its records\nare saved views. A tool that advertises `view_records` discovers and executes\nviews through this contract's type implementations, not through a reserved type\nname, path, or frontmatter value.\n\nThis artifact is passive. Implementing it grants no authority to read, execute,\nor modify any record.\n" + }, + { + "source": "types/view/1.md", + "document": "---\nkind: mdbase.type\nname: view\nversion: 1\ndescription: 'Saved views: shared query scope and stable named views'\nmatch:\n where:\n type: view\nschema:\n dialect: json-schema-2020-12\n value:\n $schema: https://json-schema.org/draft/2020-12/schema\n title: View\n description: 'Saved views: shared query scope and stable named views.'\n type: object\n required:\n - type\n - id\n - version\n - name\n - views\n properties:\n type:\n const: view\n id:\n $ref: '#/$defs/identifier'\n version:\n type: integer\n minimum: 1\n name:\n type: string\n minLength: 1\n description:\n type: string\n query:\n $ref: '#/$defs/sharedQuery'\n properties:\n $ref: '#/$defs/propertyMetadataSet'\n summary_functions:\n $ref: '#/$defs/summaryFunctionSet'\n views:\n type: array\n minItems: 1\n items:\n $ref: '#/$defs/view'\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n $defs:\n identifier:\n type: string\n pattern: ^[A-Za-z][A-Za-z0-9._:-]*$\n typeName:\n type: string\n pattern: ^[A-Za-z][A-Za-z0-9_-]{0,127}$\n fieldName:\n type: string\n pattern: ^[A-Za-z_][A-Za-z0-9_:-]*$\n expression:\n type: string\n minLength: 1\n extension:\n type: object\n additionalProperties: true\n typeList:\n type: array\n minItems: 1\n uniqueItems: true\n items:\n $ref: '#/$defs/typeName'\n sharedQuery:\n type: object\n properties:\n types:\n $ref: '#/$defs/typeList'\n where:\n $ref: '#/$defs/expression'\n context:\n $ref: '#/$defs/viewContext'\n projections:\n $ref: '#/$defs/projectionSet'\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n viewContext:\n type: object\n required:\n - this\n properties:\n this:\n $ref: '#/$defs/thisContext'\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n thisContext:\n type: object\n properties:\n on_missing:\n enum:\n - view\n - 'null'\n - error\n default: view\n types:\n $ref: '#/$defs/typeList'\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n projectionSet:\n type: object\n propertyNames:\n $ref: '#/$defs/fieldName'\n additionalProperties:\n $ref: '#/$defs/projection'\n projection:\n type: object\n required:\n - expr\n properties:\n expr:\n $ref: '#/$defs/expression'\n description:\n type: string\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n propertyMetadataSet:\n type: object\n propertyNames:\n type: string\n minLength: 1\n additionalProperties:\n $ref: '#/$defs/propertyMetadata'\n propertyMetadata:\n type: object\n properties:\n label:\n type: string\n description:\n type: string\n format:\n type: string\n hidden:\n type: boolean\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n summaryFunctionSet:\n type: object\n propertyNames:\n $ref: '#/$defs/fieldName'\n additionalProperties:\n $ref: '#/$defs/summaryFunction'\n summaryFunction:\n type: object\n required:\n - expr\n properties:\n expr:\n $ref: '#/$defs/expression'\n description:\n type: string\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n view:\n type: object\n required:\n - id\n - name\n properties:\n id:\n $ref: '#/$defs/identifier'\n name:\n type: string\n minLength: 1\n description:\n type: string\n types:\n $ref: '#/$defs/typeList'\n where:\n $ref: '#/$defs/expression'\n context:\n $ref: '#/$defs/viewContext'\n projections:\n $ref: '#/$defs/projectionSet'\n select:\n $ref: '#/$defs/select'\n order_by:\n $ref: '#/$defs/orderBy'\n group_by:\n $ref: '#/$defs/groupBy'\n summaries:\n $ref: '#/$defs/summaries'\n limit:\n type: integer\n minimum: 0\n offset:\n type: integer\n minimum: 0\n include_body:\n type: boolean\n default: false\n frontmatter_mode:\n enum:\n - effective\n - persisted\n - both\n default: effective\n presentation:\n $ref: '#/$defs/presentation'\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n select:\n type: array\n minItems: 1\n items:\n oneOf:\n - type: string\n minLength: 1\n - $ref: '#/$defs/selectExpression'\n selectExpression:\n type: object\n required:\n - name\n - expr\n properties:\n name:\n $ref: '#/$defs/fieldName'\n expr:\n $ref: '#/$defs/expression'\n label:\n type: string\n description:\n type: string\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n orderBy:\n type: array\n minItems: 1\n items:\n type: object\n required:\n - field\n properties:\n field:\n type: string\n minLength: 1\n direction:\n enum:\n - asc\n - desc\n default: asc\n additionalProperties: false\n groupBy:\n type: array\n minItems: 1\n items:\n type: object\n required:\n - field\n properties:\n field:\n type: string\n minLength: 1\n direction:\n enum:\n - asc\n - desc\n default: asc\n additionalProperties: false\n summaries:\n type: array\n minItems: 1\n items:\n $ref: '#/$defs/summary'\n summary:\n type: object\n required:\n - field\n - function\n properties:\n field:\n type: string\n minLength: 1\n function:\n $ref: '#/$defs/identifier'\n name:\n $ref: '#/$defs/fieldName'\n label:\n type: string\n additionalProperties: false\n presentation:\n type: object\n required:\n - type\n properties:\n type:\n $ref: '#/$defs/identifier'\n fallback:\n $ref: '#/$defs/identifier'\n mappings:\n type: object\n propertyNames:\n $ref: '#/$defs/fieldName'\n additionalProperties:\n type: string\n minLength: 1\n options:\n type: object\n additionalProperties: true\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\nimplements:\n- contract: mdbase.view\n version: 1.0.0\n fields:\n id: id\n version: version\n name: name\n description: description\n query: query\n properties: properties\n summary_functions: summary_functions\n views: views\ncollection:\n display:\n name_field: name\n---\n\n# View\n\nA view record stores shared query scope and one or more stable named views.\nTools that support saved views discover these records through the `mdbase.view`\ncontract and run each named view as an ordinary query.\n\nThis type belongs to your collection once installed. You can rename fields or\nchange how records are matched, as long as the `implements` mapping still\npoints each contract field at the right frontmatter field.\n" + } + ], + "provides": [ + { + "id": "mdbase.view", + "version": "1.0.0", + "digest": "sha256:0918acefce08cddd778fab768fe4f2e4615d263a0eec3e5330b6897c686d744e" + } + ] } ], "configuration": [ @@ -170,6 +286,18 @@ "operation": "set_add", "path": "/x-obsidian/bases/include", "value": "TaskNotes/Views/**/*.base" + }, + { + "requirement": "tasknotes-markdown-records", + "operation": "set_add", + "path": "/settings/record_extensions", + "value": "md" + }, + { + "requirement": "tasknotes-base-records", + "operation": "set_add", + "path": "/settings/record_extensions", + "value": "base" } ] }, diff --git a/src/storage/mdbase-repository.test.ts b/src/storage/mdbase-repository.test.ts index 4a3af2cd..b1d44805 100644 --- a/src/storage/mdbase-repository.test.ts +++ b/src/storage/mdbase-repository.test.ts @@ -1375,7 +1375,7 @@ describe("mdbase task repository", () => { const fixture = mdbaseFixture([]); const connect = { ...fixture.connect, - readViewSource: undefined, + read: undefined, } as unknown as MdbaseConnection; const repository = new MdbaseTaskRepository(connect); await repository.initialize(); @@ -1418,13 +1418,13 @@ describe("mdbase task repository", () => { ifRevision: created.revision, }); expect(updated).toMatchObject({ revision: "view-r3" }); - expect(fixture.updateViewSource).toHaveBeenCalledWith( + expect(fixture.update).toHaveBeenCalledWith( expect.objectContaining({ ifRevision: "view-r2" }), expect.objectContaining({ signal: expect.anything() }), ); await repository.deleteViewSource(updated.path, updated.revision); - expect(fixture.deleteViewSource).toHaveBeenCalledWith( + expect(fixture.remove).toHaveBeenCalledWith( { path: updated.path, ifRevision: "view-r3", diff --git a/src/storage/mdbase-repository.ts b/src/storage/mdbase-repository.ts index c8441b3a..a0db8eb2 100644 --- a/src/storage/mdbase-repository.ts +++ b/src/storage/mdbase-repository.ts @@ -41,6 +41,11 @@ import { runMdbaseMutation, } from "./mdbase-mutation-coordinator"; import { MdbaseCollectionFileStore } from "./mdbase-files"; +import { + newViewSourcePath, + viewSourceFormat, + viewSourceRecord, +} from "../domain/default-view-source"; import { readPeopleDirectory } from "./mdbase-people"; import { activeScratchpad, @@ -1233,8 +1238,13 @@ export class MdbaseTaskRepository implements TaskRepository { async readViewSource(path: string): Promise { try { - return validResult( - await this.connect.readViewSource({ path }, this.requestOptions()), + return viewSourceDocument( + validResult( + await this.connect.read( + { path, includeDocument: true }, + this.requestOptions(), + ), + ), ); } catch (reason) { this.noteOperationFailure(reason); @@ -1242,11 +1252,19 @@ export class MdbaseTaskRepository implements TaskRepository { } } + /** Saved views are ordinary records: Bases through obsidian.base, others through mdbase.view. */ async createViewSource( input: CreateTaskViewSourceInput, ): Promise { + const path = + input.path ?? + newViewSourcePath(input.format ?? "obsidian.base", input.name ?? "view"); + const operationInput = { + path, + ...viewSourceRecord(path, input.document), + includeDocument: true, + }; try { - const operationInput = { ...input }; const applyCreated = (created: TaskViewSourceDocument) => { this.invalidateViewsAfterMutation(); return created; @@ -1255,10 +1273,12 @@ export class MdbaseTaskRepository implements TaskRepository { this.connect, async () => applyCreated( - validResult( - await this.connect.createViewSource( - operationInput, - this.requestOptions(), + viewSourceDocument( + validResult( + await this.connect.create( + operationInput, + this.requestOptions(), + ), ), ), ), @@ -1274,6 +1294,7 @@ export class MdbaseTaskRepository implements TaskRepository { } } + /** A whole-document replacement keeps Obsidian's comments and layout. */ async updateViewSource( input: UpdateTaskViewSourceInput, ): Promise { @@ -1291,10 +1312,12 @@ export class MdbaseTaskRepository implements TaskRepository { this.connect, async () => applyUpdated( - validResult( - await this.connect.updateViewSource( - operationInput, - this.requestOptions(), + viewSourceDocument( + validResult( + await this.connect.update( + operationInput, + this.requestOptions(), + ), ), ), ), @@ -1320,10 +1343,7 @@ export class MdbaseTaskRepository implements TaskRepository { this.connect, async () => { validResult( - await this.connect.deleteViewSource( - operationInput, - this.requestOptions(), - ), + await this.connect.delete(operationInput, this.requestOptions()), ); applyDeleted(); }, @@ -2603,3 +2623,16 @@ function connectionErrorMessage(reason: unknown): string { if (reason instanceof Error && reason.message) return reason.message; return "This collection is not reachable right now."; } + +function viewSourceDocument(record: { + path: string; + revision: string; + document?: string; +}): TaskViewSourceDocument { + return { + path: record.path, + format: viewSourceFormat(record.path), + revision: record.revision, + document: record.document ?? "", + }; +} diff --git a/src/storage/mdbase-scratchpad-stream.test.ts b/src/storage/mdbase-scratchpad-stream.test.ts index df2af8e1..56699968 100644 --- a/src/storage/mdbase-scratchpad-stream.test.ts +++ b/src/storage/mdbase-scratchpad-stream.test.ts @@ -371,7 +371,7 @@ describe("mdbase scratchpad stream", () => { if ( !demotionFailed && input.path === current.path && - input.patch.state === "converted" + input.patch?.state === "converted" ) { demotionFailed = true; throw new Error("Second write failed"); diff --git a/src/test/mdbase-fixture.ts b/src/test/mdbase-fixture.ts index 74907c7e..8c9bfb6d 100644 --- a/src/test/mdbase-fixture.ts +++ b/src/test/mdbase-fixture.ts @@ -19,6 +19,7 @@ import { serializeMarkdownDocument, } from "@tasknotes/model/frontmatter"; import { vi } from "vitest"; +import { stringify } from "yaml"; import { TaskNotesTaskModel } from "../domain/tasknotes-model"; import { MdbaseTaskRepository } from "../storage/mdbase-repository"; @@ -156,7 +157,28 @@ export function mdbaseFixture( ); })(), ); + // Saved views are records; their documents are kept so reads with + // includeDocument and whole-document updates round-trip. + const viewSources = new Map([ + [ + "views/tasks.base", + { + path: "views/tasks.base", + revision: "view-r1", + document: "views:\n - name: Kanban\n type: tasknotesKanban\n", + }, + ], + ]); + let viewRevision = 2; + const isViewSource = (path: string) => + path.endsWith(".base") || path.startsWith("views/"); + const viewSource = (path: string) => { + const source = viewSources.get(path); + if (!source) throw new Error("View source not found."); + return source; + }; const read = vi.fn(async ({ path }: { path: string }) => { + if (isViewSource(path)) return valid(structuredClone(viewSource(path))); const record = records.get(path); if (!record) throw new Error("Task not found."); return valid(record); @@ -169,6 +191,17 @@ export function mdbaseFixture( body?: string; }) => { const path = input.path ?? `tasks/${crypto.randomUUID()}.md`; + if (isViewSource(path)) { + const source = { + path, + revision: `view-r${viewRevision++}`, + document: path.endsWith(".base") + ? stringify(input.frontmatter) + : serializeMarkdownDocument(input.frontmatter, input.body ?? ""), + }; + viewSources.set(path, source); + return valid(structuredClone(source)); + } if (records.has(path)) throw new Error(`Path already exists: ${path}`); const record: TestRecord = { path, @@ -184,16 +217,29 @@ export function mdbaseFixture( const update = vi.fn( async (input: { path: string; - patch: JsonObject; + patch?: JsonObject; + document?: string; body?: string; ifRevision?: string; }) => { + if (isViewSource(input.path)) { + const current = viewSource(input.path); + if (input.ifRevision !== current.revision) + throw new Error("Revision conflict."); + const source = { + ...current, + revision: `view-r${viewRevision++}`, + document: input.document ?? current.document, + }; + viewSources.set(input.path, source); + return valid(structuredClone(source)); + } const current = records.get(input.path); if (!current) throw new Error("Task not found."); if (input.ifRevision !== current.revision) throw new Error("Revision conflict."); const frontmatter = structuredClone(current.frontmatter); - for (const [key, value] of Object.entries(input.patch)) { + for (const [key, value] of Object.entries(input.patch ?? {})) { if (value === null) delete frontmatter[key]; else frontmatter[key] = structuredClone(value); } @@ -208,6 +254,12 @@ export function mdbaseFixture( }, ); const remove = vi.fn(async (input: { path: string; ifRevision?: string }) => { + if (isViewSource(input.path)) { + if (input.ifRevision !== viewSource(input.path).revision) + throw new Error("Revision conflict."); + viewSources.delete(input.path); + return valid({ path: input.path, deleted: true }); + } const current = records.get(input.path); if (!current) throw new Error("Task not found."); if (input.ifRevision !== current.revision) @@ -297,77 +349,6 @@ export function mdbaseFixture( ); })(), ); - const viewSources = new Map< - string, - { - path: string; - format: "obsidian.base" | "mdbase.view"; - revision: string; - document: string; - } - >([ - [ - "views/tasks.base", - { - path: "views/tasks.base", - format: "obsidian.base" as const, - revision: "view-r1", - document: "views:\n - name: Kanban\n type: tasknotesKanban\n", - }, - ], - ]); - let viewRevision = 2; - const readViewSource = vi.fn(async ({ path }: { path: string }) => { - const source = viewSources.get(path); - if (!source) throw new Error("View source not found."); - return valid(structuredClone(source)); - }); - const createViewSource = vi.fn( - async (input: { - path?: string; - format: "obsidian.base" | "mdbase.view"; - name: string; - document: string; - }) => { - const extension = input.format === "obsidian.base" ? "base" : "md"; - const path = - input.path ?? - `views/${input.name.toLowerCase().replaceAll(" ", "-")}.${extension}`; - const source = { - path, - format: input.format, - revision: `view-r${viewRevision++}`, - document: input.document, - }; - viewSources.set(path, source); - return valid(structuredClone(source)); - }, - ); - const updateViewSource = vi.fn( - async (input: { path: string; document: string; ifRevision?: string }) => { - const current = viewSources.get(input.path); - if (!current) throw new Error("View source not found."); - if (input.ifRevision !== current.revision) - throw new Error("Revision conflict."); - const source = { - ...current, - revision: `view-r${viewRevision++}`, - document: input.document, - }; - viewSources.set(input.path, source); - return valid(structuredClone(source)); - }, - ); - const deleteViewSource = vi.fn( - async (input: { path: string; ifRevision?: string }) => { - const current = viewSources.get(input.path); - if (!current) throw new Error("View source not found."); - if (input.ifRevision !== current.revision) - throw new Error("Revision conflict."); - viewSources.delete(input.path); - return valid({ path: input.path, deleted: true }); - }, - ); const readType = vi.fn( async ({ name, path }: { name?: string; path?: string }) => { if (name !== typeDocument.name && path !== typeDocument.path) @@ -436,10 +417,6 @@ export function mdbaseFixture( listViews, executeView, executeViewPages, - readViewSource, - createViewSource, - updateViewSource, - deleteViewSource, readType, updateType, } as unknown as MdbaseConnection; @@ -477,10 +454,6 @@ export function mdbaseFixture( listViews, executeView, executeViewPages, - readViewSource, - createViewSource, - updateViewSource, - deleteViewSource, readType, updateType, stagePendingMutation, diff --git a/vendor/mdbase-contracts/mdbase.view-1.0.0.json b/vendor/mdbase-contracts/mdbase.view-1.0.0.json new file mode 100644 index 00000000..210fa58e --- /dev/null +++ b/vendor/mdbase-contracts/mdbase.view-1.0.0.json @@ -0,0 +1,53 @@ +{ + "manifest": { + "kind": "mdbase.type-pack", + "id": "mdbase.view", + "version": "1.0.0", + "name": "Saved views", + "description": "The mdbase.view contract and an editable View type for saved, reusable queries.", + "resources": [ + { + "kind": "schema", + "mode": "managed", + "source": "schemas/mdbase.view/1.0.0.schema.json", + "target": "schemas/mdbase.view/1.0.0.schema.json", + "digest": "sha256:d58b8b691c14d59f0575fc1cc93ab5b46363c485e4469bc58039ec0cece7ca36" + }, + { + "kind": "contract", + "mode": "managed", + "source": "contracts/mdbase.view/1.0.0.md", + "target": "_contracts/mdbase.view/1.0.0.md", + "digest": "sha256:91eae42371c2d411b20d3c976aece3071bbd753963b77b6bcdc26137a8fdcded" + }, + { + "kind": "type", + "mode": "seed", + "source": "types/view/1.md", + "target": "_types/view.md", + "digest": "sha256:480919f6f88f91592abb9fe7971f09e874d536c6883981616b181cf14cbe7ff4" + } + ] + }, + "resources": [ + { + "source": "schemas/mdbase.view/1.0.0.schema.json", + "document": "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://mdbase.dev/schemas/v0.3/view.schema.json\",\n \"title\": \"mdbase.view 1.0.0 record contract view\",\n \"description\": \"The contract view of a saved-view record. A type implements mdbase.view by mapping these fields; the optional type property only lets the canonical view type validate its own membership value.\",\n \"type\": \"object\",\n \"required\": [\"id\", \"version\", \"name\", \"views\"],\n \"properties\": {\n \"type\": { \"type\": \"string\", \"minLength\": 1 },\n \"id\": { \"$ref\": \"#/$defs/identifier\" },\n \"version\": { \"type\": \"integer\", \"minimum\": 1 },\n \"name\": { \"type\": \"string\", \"minLength\": 1 },\n \"description\": { \"type\": \"string\" },\n \"query\": { \"$ref\": \"#/$defs/sharedQuery\" },\n \"properties\": { \"$ref\": \"#/$defs/propertyMetadataSet\" },\n \"summary_functions\": { \"$ref\": \"#/$defs/summaryFunctionSet\" },\n \"views\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"$ref\": \"#/$defs/view\" }\n }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false,\n \"$defs\": {\n \"identifier\": {\n \"type\": \"string\",\n \"pattern\": \"^[A-Za-z][A-Za-z0-9._:-]*$\"\n },\n \"typeName\": {\n \"type\": \"string\",\n \"pattern\": \"^[A-Za-z][A-Za-z0-9_-]{0,127}$\"\n },\n \"fieldName\": {\n \"type\": \"string\",\n \"pattern\": \"^[A-Za-z_][A-Za-z0-9_:-]*$\"\n },\n \"expression\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"extension\": {\n \"type\": \"object\",\n \"additionalProperties\": true\n },\n \"typeList\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"uniqueItems\": true,\n \"items\": { \"$ref\": \"#/$defs/typeName\" }\n },\n \"sharedQuery\": {\n \"type\": \"object\",\n \"properties\": {\n \"types\": { \"$ref\": \"#/$defs/typeList\" },\n \"where\": { \"$ref\": \"#/$defs/expression\" },\n \"context\": { \"$ref\": \"#/$defs/viewContext\" },\n \"projections\": { \"$ref\": \"#/$defs/projectionSet\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"viewContext\": {\n \"type\": \"object\",\n \"required\": [\"this\"],\n \"properties\": {\n \"this\": { \"$ref\": \"#/$defs/thisContext\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"thisContext\": {\n \"type\": \"object\",\n \"properties\": {\n \"on_missing\": {\n \"enum\": [\"view\", \"null\", \"error\"],\n \"default\": \"view\"\n },\n \"types\": { \"$ref\": \"#/$defs/typeList\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"projectionSet\": {\n \"type\": \"object\",\n \"propertyNames\": { \"$ref\": \"#/$defs/fieldName\" },\n \"additionalProperties\": { \"$ref\": \"#/$defs/projection\" }\n },\n \"projection\": {\n \"type\": \"object\",\n \"required\": [\"expr\"],\n \"properties\": {\n \"expr\": { \"$ref\": \"#/$defs/expression\" },\n \"description\": { \"type\": \"string\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"propertyMetadataSet\": {\n \"type\": \"object\",\n \"propertyNames\": { \"type\": \"string\", \"minLength\": 1 },\n \"additionalProperties\": { \"$ref\": \"#/$defs/propertyMetadata\" }\n },\n \"propertyMetadata\": {\n \"type\": \"object\",\n \"properties\": {\n \"label\": { \"type\": \"string\" },\n \"description\": { \"type\": \"string\" },\n \"format\": { \"type\": \"string\" },\n \"hidden\": { \"type\": \"boolean\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"summaryFunctionSet\": {\n \"type\": \"object\",\n \"propertyNames\": { \"$ref\": \"#/$defs/fieldName\" },\n \"additionalProperties\": { \"$ref\": \"#/$defs/summaryFunction\" }\n },\n \"summaryFunction\": {\n \"type\": \"object\",\n \"required\": [\"expr\"],\n \"properties\": {\n \"expr\": { \"$ref\": \"#/$defs/expression\" },\n \"description\": { \"type\": \"string\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"view\": {\n \"type\": \"object\",\n \"required\": [\"id\", \"name\"],\n \"properties\": {\n \"id\": { \"$ref\": \"#/$defs/identifier\" },\n \"name\": { \"type\": \"string\", \"minLength\": 1 },\n \"description\": { \"type\": \"string\" },\n \"types\": { \"$ref\": \"#/$defs/typeList\" },\n \"where\": { \"$ref\": \"#/$defs/expression\" },\n \"context\": { \"$ref\": \"#/$defs/viewContext\" },\n \"projections\": { \"$ref\": \"#/$defs/projectionSet\" },\n \"select\": { \"$ref\": \"#/$defs/select\" },\n \"order_by\": { \"$ref\": \"#/$defs/orderBy\" },\n \"group_by\": { \"$ref\": \"#/$defs/groupBy\" },\n \"summaries\": { \"$ref\": \"#/$defs/summaries\" },\n \"limit\": { \"type\": \"integer\", \"minimum\": 0 },\n \"offset\": { \"type\": \"integer\", \"minimum\": 0 },\n \"include_body\": { \"type\": \"boolean\", \"default\": false },\n \"frontmatter_mode\": {\n \"enum\": [\"effective\", \"persisted\", \"both\"],\n \"default\": \"effective\"\n },\n \"presentation\": { \"$ref\": \"#/$defs/presentation\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"select\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"oneOf\": [\n { \"type\": \"string\", \"minLength\": 1 },\n { \"$ref\": \"#/$defs/selectExpression\" }\n ]\n }\n },\n \"selectExpression\": {\n \"type\": \"object\",\n \"required\": [\"name\", \"expr\"],\n \"properties\": {\n \"name\": { \"$ref\": \"#/$defs/fieldName\" },\n \"expr\": { \"$ref\": \"#/$defs/expression\" },\n \"label\": { \"type\": \"string\" },\n \"description\": { \"type\": \"string\" }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n },\n \"orderBy\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"field\"],\n \"properties\": {\n \"field\": { \"type\": \"string\", \"minLength\": 1 },\n \"direction\": { \"enum\": [\"asc\", \"desc\"], \"default\": \"asc\" }\n },\n \"additionalProperties\": false\n }\n },\n \"groupBy\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"field\"],\n \"properties\": {\n \"field\": { \"type\": \"string\", \"minLength\": 1 },\n \"direction\": { \"enum\": [\"asc\", \"desc\"], \"default\": \"asc\" }\n },\n \"additionalProperties\": false\n }\n },\n \"summaries\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"items\": { \"$ref\": \"#/$defs/summary\" }\n },\n \"summary\": {\n \"type\": \"object\",\n \"required\": [\"field\", \"function\"],\n \"properties\": {\n \"field\": { \"type\": \"string\", \"minLength\": 1 },\n \"function\": { \"$ref\": \"#/$defs/identifier\" },\n \"name\": { \"$ref\": \"#/$defs/fieldName\" },\n \"label\": { \"type\": \"string\" }\n },\n \"additionalProperties\": false\n },\n \"presentation\": {\n \"type\": \"object\",\n \"required\": [\"type\"],\n \"properties\": {\n \"type\": { \"$ref\": \"#/$defs/identifier\" },\n \"fallback\": { \"$ref\": \"#/$defs/identifier\" },\n \"mappings\": {\n \"type\": \"object\",\n \"propertyNames\": { \"$ref\": \"#/$defs/fieldName\" },\n \"additionalProperties\": { \"type\": \"string\", \"minLength\": 1 }\n },\n \"options\": {\n \"type\": \"object\",\n \"additionalProperties\": true\n }\n },\n \"patternProperties\": {\n \"^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$\": { \"$ref\": \"#/$defs/extension\" }\n },\n \"additionalProperties\": false\n }\n }\n}\n" + }, + { + "source": "contracts/mdbase.view/1.0.0.md", + "document": "---\nkind: mdbase.contract\ncontract_type: record\nid: mdbase.view\nversion: 1.0.0\nname: Saved view\ndescription: Shared query scope and stable named views executable through the Query profile.\nrecord_schema:\n dialect: json-schema-2020-12\n ref: ../../schemas/mdbase.view/1.0.0.schema.json\n---\n\n# Saved view\n\nA record exposed through `mdbase.view` stores shared query scope and one or more\nstable named views. Each named view resolves to the query model in Chapter 11.\nOptional `presentation` metadata is advisory and does not alter headless query\nresults.\n\nImplementing this contract is how a collection declares which of its records\nare saved views. A tool that advertises `view_records` discovers and executes\nviews through this contract's type implementations, not through a reserved type\nname, path, or frontmatter value.\n\nThis artifact is passive. Implementing it grants no authority to read, execute,\nor modify any record.\n" + }, + { + "source": "types/view/1.md", + "document": "---\nkind: mdbase.type\nname: view\nversion: 1\ndescription: 'Saved views: shared query scope and stable named views'\nmatch:\n where:\n type: view\nschema:\n dialect: json-schema-2020-12\n value:\n $schema: https://json-schema.org/draft/2020-12/schema\n title: View\n description: 'Saved views: shared query scope and stable named views.'\n type: object\n required:\n - type\n - id\n - version\n - name\n - views\n properties:\n type:\n const: view\n id:\n $ref: '#/$defs/identifier'\n version:\n type: integer\n minimum: 1\n name:\n type: string\n minLength: 1\n description:\n type: string\n query:\n $ref: '#/$defs/sharedQuery'\n properties:\n $ref: '#/$defs/propertyMetadataSet'\n summary_functions:\n $ref: '#/$defs/summaryFunctionSet'\n views:\n type: array\n minItems: 1\n items:\n $ref: '#/$defs/view'\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n $defs:\n identifier:\n type: string\n pattern: ^[A-Za-z][A-Za-z0-9._:-]*$\n typeName:\n type: string\n pattern: ^[A-Za-z][A-Za-z0-9_-]{0,127}$\n fieldName:\n type: string\n pattern: ^[A-Za-z_][A-Za-z0-9_:-]*$\n expression:\n type: string\n minLength: 1\n extension:\n type: object\n additionalProperties: true\n typeList:\n type: array\n minItems: 1\n uniqueItems: true\n items:\n $ref: '#/$defs/typeName'\n sharedQuery:\n type: object\n properties:\n types:\n $ref: '#/$defs/typeList'\n where:\n $ref: '#/$defs/expression'\n context:\n $ref: '#/$defs/viewContext'\n projections:\n $ref: '#/$defs/projectionSet'\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n viewContext:\n type: object\n required:\n - this\n properties:\n this:\n $ref: '#/$defs/thisContext'\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n thisContext:\n type: object\n properties:\n on_missing:\n enum:\n - view\n - 'null'\n - error\n default: view\n types:\n $ref: '#/$defs/typeList'\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n projectionSet:\n type: object\n propertyNames:\n $ref: '#/$defs/fieldName'\n additionalProperties:\n $ref: '#/$defs/projection'\n projection:\n type: object\n required:\n - expr\n properties:\n expr:\n $ref: '#/$defs/expression'\n description:\n type: string\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n propertyMetadataSet:\n type: object\n propertyNames:\n type: string\n minLength: 1\n additionalProperties:\n $ref: '#/$defs/propertyMetadata'\n propertyMetadata:\n type: object\n properties:\n label:\n type: string\n description:\n type: string\n format:\n type: string\n hidden:\n type: boolean\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n summaryFunctionSet:\n type: object\n propertyNames:\n $ref: '#/$defs/fieldName'\n additionalProperties:\n $ref: '#/$defs/summaryFunction'\n summaryFunction:\n type: object\n required:\n - expr\n properties:\n expr:\n $ref: '#/$defs/expression'\n description:\n type: string\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n view:\n type: object\n required:\n - id\n - name\n properties:\n id:\n $ref: '#/$defs/identifier'\n name:\n type: string\n minLength: 1\n description:\n type: string\n types:\n $ref: '#/$defs/typeList'\n where:\n $ref: '#/$defs/expression'\n context:\n $ref: '#/$defs/viewContext'\n projections:\n $ref: '#/$defs/projectionSet'\n select:\n $ref: '#/$defs/select'\n order_by:\n $ref: '#/$defs/orderBy'\n group_by:\n $ref: '#/$defs/groupBy'\n summaries:\n $ref: '#/$defs/summaries'\n limit:\n type: integer\n minimum: 0\n offset:\n type: integer\n minimum: 0\n include_body:\n type: boolean\n default: false\n frontmatter_mode:\n enum:\n - effective\n - persisted\n - both\n default: effective\n presentation:\n $ref: '#/$defs/presentation'\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n select:\n type: array\n minItems: 1\n items:\n oneOf:\n - type: string\n minLength: 1\n - $ref: '#/$defs/selectExpression'\n selectExpression:\n type: object\n required:\n - name\n - expr\n properties:\n name:\n $ref: '#/$defs/fieldName'\n expr:\n $ref: '#/$defs/expression'\n label:\n type: string\n description:\n type: string\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\n orderBy:\n type: array\n minItems: 1\n items:\n type: object\n required:\n - field\n properties:\n field:\n type: string\n minLength: 1\n direction:\n enum:\n - asc\n - desc\n default: asc\n additionalProperties: false\n groupBy:\n type: array\n minItems: 1\n items:\n type: object\n required:\n - field\n properties:\n field:\n type: string\n minLength: 1\n direction:\n enum:\n - asc\n - desc\n default: asc\n additionalProperties: false\n summaries:\n type: array\n minItems: 1\n items:\n $ref: '#/$defs/summary'\n summary:\n type: object\n required:\n - field\n - function\n properties:\n field:\n type: string\n minLength: 1\n function:\n $ref: '#/$defs/identifier'\n name:\n $ref: '#/$defs/fieldName'\n label:\n type: string\n additionalProperties: false\n presentation:\n type: object\n required:\n - type\n properties:\n type:\n $ref: '#/$defs/identifier'\n fallback:\n $ref: '#/$defs/identifier'\n mappings:\n type: object\n propertyNames:\n $ref: '#/$defs/fieldName'\n additionalProperties:\n type: string\n minLength: 1\n options:\n type: object\n additionalProperties: true\n patternProperties:\n ^x-[A-Za-z][A-Za-z0-9._:-]{0,127}$:\n $ref: '#/$defs/extension'\n additionalProperties: false\nimplements:\n- contract: mdbase.view\n version: 1.0.0\n fields:\n id: id\n version: version\n name: name\n description: description\n query: query\n properties: properties\n summary_functions: summary_functions\n views: views\ncollection:\n display:\n name_field: name\n---\n\n# View\n\nA view record stores shared query scope and one or more stable named views.\nTools that support saved views discover these records through the `mdbase.view`\ncontract and run each named view as an ordinary query.\n\nThis type belongs to your collection once installed. You can rename fields or\nchange how records are matched, as long as the `implements` mapping still\npoints each contract field at the right frontmatter field.\n" + } + ], + "provides": [ + { + "id": "mdbase.view", + "version": "1.0.0", + "digest": "sha256:0918acefce08cddd778fab768fe4f2e4615d263a0eec3e5330b6897c686d744e" + } + ] +} diff --git a/vendor/mdbase-contracts/obsidian.base-1.0.0.json b/vendor/mdbase-contracts/obsidian.base-1.0.0.json new file mode 100644 index 00000000..d2648ab9 --- /dev/null +++ b/vendor/mdbase-contracts/obsidian.base-1.0.0.json @@ -0,0 +1,42 @@ +{ + "manifest": { + "kind": "mdbase.type-pack", + "id": "obsidian.base", + "version": "1.0.0", + "name": "Obsidian Bases", + "description": "The obsidian.base contract and an editable type that stores Obsidian Bases as records.", + "resources": [ + { + "kind": "contract", + "mode": "managed", + "source": "contracts/obsidian.base/1.0.0.md", + "target": "_contracts/obsidian.base/1.0.0.md", + "digest": "sha256:fca77a9c845223784d4af7f5e13e2f149295621ad1b794ea9bb4d99f604467e8" + }, + { + "kind": "type", + "mode": "seed", + "source": "types/obsidian-base/1.md", + "target": "_types/obsidian_base.md", + "digest": "sha256:bfeb3880200bc1bef7fd50b777d3f927ce931d08b4fb3c30d4cddb55feaf3f92" + } + ] + }, + "resources": [ + { + "source": "contracts/obsidian.base/1.0.0.md", + "document": "---\nkind: mdbase.contract\ncontract_type: record\nid: obsidian.base\nversion: 1.0.0\nname: Obsidian Base\ndescription: An Obsidian Bases saved-view source stored as a YAML document record.\nrecord_schema:\n dialect: json-schema-2020-12\n value:\n $schema: https://json-schema.org/draft/2020-12/schema\n type: object\n required: [views]\n properties:\n filters: {}\n formulas: { type: object }\n properties: { type: object }\n views:\n type: array\n minItems: 1\n items: { type: object, required: [type], properties: { type: { type: string }, name: { type: string } } }\n---\n\n# Obsidian Base\n\nA record exposed through `obsidian.base` is an Obsidian Bases source: global\nfilters, formulas, property metadata, and one or more views. The schema is\ndeliberately permissive. Obsidian owns the format and adds keys over time;\nunknown keys are preserved and ignored.\n\nTools that advertise `obsidian_bases_views` discover and execute these records\nthrough this contract and evaluate them with the Obsidian Bases expression\ndialect described in the [Obsidian Bases adapter](https://mdbase.dev/spec/adapters/obsidian-bases).\n\nThis artifact is passive. Implementing it grants no authority to read, execute,\nor modify any record.\n" + }, + { + "source": "types/obsidian-base/1.md", + "document": "---\nkind: mdbase.type\nname: obsidian_base\nversion: 1\ndescription: Obsidian Bases saved views stored as records\nmatch:\n path_glob: '**/*.base'\nschema:\n dialect: json-schema-2020-12\n value:\n $schema: https://json-schema.org/draft/2020-12/schema\n title: Obsidian Base\n description: An Obsidian Bases file. Obsidian owns the format; unknown keys are kept.\n type: object\n required:\n - views\n properties:\n filters: {}\n formulas:\n type: object\n properties:\n type: object\n views:\n type: array\n minItems: 1\n items:\n type: object\n required:\n - type\n properties:\n type:\n type: string\n name:\n type: string\nimplements:\n - contract: obsidian.base\n version: 1.0.0\n fields:\n filters: filters\n formulas: formulas\n properties: properties\n views: views\n---\n\n# Obsidian Base\n\nMakes `.base` files discoverable as saved views through the `obsidian.base`\ncontract. It takes effect when the collection lists `base` in\n`settings.record_extensions`; narrow the `path_glob` to the folders that hold\nyour Bases. Edit Bases with whole-document replacement so Obsidian's comments\nand layout survive.\n" + } + ], + "provides": [ + { + "id": "obsidian.base", + "version": "1.0.0", + "digest": "sha256:37e8a0bfb32bb4795c1b3c5dec613c32429aaa765dc33e4adc0c627a48f5c1ff" + } + ] +} From 152128e77ee37ea6429ade164fbc4e8bff8b87d0 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 29 Sep 2026 17:01:40 +1000 Subject: [PATCH 2/3] Use mdbase connect beta117 beta117 accepts the record_extensions setup target and gates base records on connectors that advertise yaml-document-records-v1. --- package.json | 14 ++++++------- pnpm-lock.yaml | 56 +++++++++++++++++++++++++------------------------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index 2642312b..8435a5c1 100644 --- a/package.json +++ b/package.json @@ -61,8 +61,8 @@ "@fullcalendar/react": "6.1.17", "@fullcalendar/timegrid": "6.1.17", "@lezer/highlight": "^1.2.3", - "@mdbase-dev/connect": "0.1.0-beta.114", - "@mdbase-dev/connect-protocol": "0.1.0-beta.114", + "@mdbase-dev/connect": "0.1.0-beta.117", + "@mdbase-dev/connect-protocol": "0.1.0-beta.117", "@tanstack/react-virtual": "3.14.11", "@tasknotes/model": "file:vendor/tasknotes-model-0.3.0-rc.15.tgz", "codemirror": "^6.0.2", @@ -86,8 +86,8 @@ "@capacitor/cli": "8.4.2", "@capacitor/ios": "8.4.2", "@eslint/js": "10.0.1", - "@mdbase-dev/connect-dev": "0.1.0-beta.114", - "@mdbase-dev/connect-testing": "0.1.0-beta.114", + "@mdbase-dev/connect-dev": "0.1.0-beta.117", + "@mdbase-dev/connect-testing": "0.1.0-beta.117", "@playwright/test": "1.61.1", "@testing-library/jest-dom": "7.0.0", "@testing-library/react": "16.3.2", @@ -112,9 +112,9 @@ }, "pnpm": { "overrides": { - "@mdbase-dev/connect-protocol": "0.1.0-beta.114", - "@mdbase-dev/connect": "0.1.0-beta.114", - "@mdbase-dev/connect-sync": "0.1.0-beta.114" + "@mdbase-dev/connect-protocol": "0.1.0-beta.117", + "@mdbase-dev/connect": "0.1.0-beta.117", + "@mdbase-dev/connect-sync": "0.1.0-beta.117" }, "patchedDependencies": {} } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62886b62..68f8a0b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,9 @@ settings: excludeLinksFromLockfile: false overrides: - '@mdbase-dev/connect-protocol': 0.1.0-beta.114 - '@mdbase-dev/connect': 0.1.0-beta.114 - '@mdbase-dev/connect-sync': 0.1.0-beta.114 + '@mdbase-dev/connect-protocol': 0.1.0-beta.117 + '@mdbase-dev/connect': 0.1.0-beta.117 + '@mdbase-dev/connect-sync': 0.1.0-beta.117 importers: @@ -77,11 +77,11 @@ importers: specifier: ^1.2.3 version: 1.2.3 '@mdbase-dev/connect': - specifier: 0.1.0-beta.114 - version: 0.1.0-beta.114 + specifier: 0.1.0-beta.117 + version: 0.1.0-beta.117 '@mdbase-dev/connect-protocol': - specifier: 0.1.0-beta.114 - version: 0.1.0-beta.114 + specifier: 0.1.0-beta.117 + version: 0.1.0-beta.117 '@tanstack/react-virtual': specifier: 3.14.11 version: 3.14.11(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -147,11 +147,11 @@ importers: specifier: 10.0.1 version: 10.0.1(eslint@10.7.0) '@mdbase-dev/connect-dev': - specifier: 0.1.0-beta.114 - version: 0.1.0-beta.114 + specifier: 0.1.0-beta.117 + version: 0.1.0-beta.117 '@mdbase-dev/connect-testing': - specifier: 0.1.0-beta.114 - version: 0.1.0-beta.114 + specifier: 0.1.0-beta.117 + version: 0.1.0-beta.117 '@playwright/test': specifier: 1.61.1 version: 1.61.1 @@ -902,18 +902,18 @@ packages: '@marijn/find-cluster-break@1.0.4': resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} - '@mdbase-dev/connect-dev@0.1.0-beta.114': - resolution: {integrity: sha512-HlRXwwgPiuKAflaHAMchLOWPBQ+XDTZ9FO///3r9QioSrAQ3CtULHwisAKEGaSJo6S0HxQ3+KflVH8j9Wa3DvA==} + '@mdbase-dev/connect-dev@0.1.0-beta.117': + resolution: {integrity: sha512-liKRrsMkl7fNkJY54wjWKcLDkd0IOWXfYdJAKVmdWbtsfkazg4lXkq6CTi+k4XGTfEuyXT/+6U/qJE4h0FCtqg==} hasBin: true - '@mdbase-dev/connect-protocol@0.1.0-beta.114': - resolution: {integrity: sha512-t9J7wVdoYjEMpQ7osgyEE/b+KRxo3vY1nAwX7x6cYRd41pD7/t69XufddvY18OnmYf1GopXzv3Mgdql4SjO+JQ==} + '@mdbase-dev/connect-protocol@0.1.0-beta.117': + resolution: {integrity: sha512-4LxZvsMTgmrTP8TtVVqydr2oZQpCOi2kwrMPtC/SF4uHeksbbDmxbjBwGG5Npj5DDxBi2Y+cClHf+HdpPB6zVQ==} - '@mdbase-dev/connect-testing@0.1.0-beta.114': - resolution: {integrity: sha512-dWdssxcptoR/mkx4Rv1ZCSZfXKBAfCmfsJJ3zpMGmUIIvpkmJXOJQk/eRIsSsfDl1IKUOOuOx2qXBundXGcb+g==} + '@mdbase-dev/connect-testing@0.1.0-beta.117': + resolution: {integrity: sha512-yqPbYbr3a+25MCOvnXnI/sqDs1f7ZRReqMMa3NtrMsLw8y64+oC8u9NCd964FICQ0hpoz6KQJJLyqfipJKCwRw==} - '@mdbase-dev/connect@0.1.0-beta.114': - resolution: {integrity: sha512-NnES3Y3NC5nJTegp6YmaB+FGHIDB3596nc2SlZqTSzg33q6GJ98USn/ua0JB8qrIbeCJVvqHJrsGxRboBw75BA==} + '@mdbase-dev/connect@0.1.0-beta.117': + resolution: {integrity: sha512-6GyK+6ZWGJQCRsQKg48gU7XS13AHjavJZ7jBj6Q2f9McO/Omo73zWmQlcXQzBNnzz7zhUPhIU3PfTE2z6g21Xg==} '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} @@ -4795,26 +4795,26 @@ snapshots: '@marijn/find-cluster-break@1.0.4': {} - '@mdbase-dev/connect-dev@0.1.0-beta.114': + '@mdbase-dev/connect-dev@0.1.0-beta.117': dependencies: - '@mdbase-dev/connect': 0.1.0-beta.114 - '@mdbase-dev/connect-protocol': 0.1.0-beta.114 + '@mdbase-dev/connect': 0.1.0-beta.117 + '@mdbase-dev/connect-protocol': 0.1.0-beta.117 ajv: 8.20.0 yaml: 2.9.1 - '@mdbase-dev/connect-protocol@0.1.0-beta.114': + '@mdbase-dev/connect-protocol@0.1.0-beta.117': dependencies: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) - '@mdbase-dev/connect-testing@0.1.0-beta.114': + '@mdbase-dev/connect-testing@0.1.0-beta.117': dependencies: - '@mdbase-dev/connect': 0.1.0-beta.114 - '@mdbase-dev/connect-protocol': 0.1.0-beta.114 + '@mdbase-dev/connect': 0.1.0-beta.117 + '@mdbase-dev/connect-protocol': 0.1.0-beta.117 - '@mdbase-dev/connect@0.1.0-beta.114': + '@mdbase-dev/connect@0.1.0-beta.117': dependencies: - '@mdbase-dev/connect-protocol': 0.1.0-beta.114 + '@mdbase-dev/connect-protocol': 0.1.0-beta.117 '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: From e203f3b6a50876846aec79d4225d00ba51192dad Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 29 Sep 2026 17:21:48 +1000 Subject: [PATCH 3/3] Describe the saved-view contracts and view-source reads in relay e2e The relay fixture now describes the obsidian.base and mdbase.view contracts a set-up collection carries, and answers reads of .base view sources, which now use the ordinary read operation, instead of treating them as task reads. --- e2e/cloud-connection.spec.ts | 49 +++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/e2e/cloud-connection.spec.ts b/e2e/cloud-connection.spec.ts index 65e92640..6d9ac394 100644 --- a/e2e/cloud-connection.spec.ts +++ b/e2e/cloud-connection.spec.ts @@ -203,6 +203,8 @@ test("reviews a scratchpad selectively and collapses outline branches", async ({ result = defaultViewDocuments(); } else if (operation === "execute_view") { result = defaultViewExecution([]); + } else if (operation === "read" && isViewSourceRead(request.input)) { + result = viewSourceRecord(request.input); } else if (operation === "read") { result = scratchpad; } else result = {}; @@ -347,6 +349,8 @@ test("acknowledges slow relay creates and prefetches revisions before delete", a }; records.push(created); result = valid(created); + } else if (operation === "read" && isViewSourceRead(request.input)) { + result = valid(viewSourceRecord(request.input)); } else if (operation === "read") { readRequests += 1; await readGate.promise; @@ -505,7 +509,9 @@ test("edits a contract-defined task without collapsing custom status or fields", result = defaultViewDocuments(); } else if (operation === "execute_view") { result = defaultViewExecution([record]); - } else if (operation === "read") result = record; + } else if (operation === "read" && isViewSourceRead(request.input)) + result = viewSourceRecord(request.input); + else if (operation === "read") result = record; else if (operation === "update") { updateInput = request.input as { patch?: JsonObject; @@ -643,6 +649,25 @@ function collectionDescription() { }, ], }, + // A set-up collection also carries the saved-view contracts TaskNotes + // requires; their packs are installed during collection setup. + ...bundledManifest.requirements.contracts + .filter((contract) => contract.id !== "tasknotes.task") + .map((contract) => ({ + contract_type: "record" as const, + id: contract.id, + version: contract.version, + digest: contract.digest, + schema: { type: "object" }, + binding_schema: { type: "object" }, + implementations: [] as Array<{ + type_name: string; + type_version: number; + digest: string; + fields: Record; + binding: JsonObject; + }>, + })), ], }; } @@ -865,6 +890,28 @@ async function fulfillOperation( }); } +// Saved views are `.base` records read with the ordinary record operation. +function isViewSourceRead(input: unknown): input is { path: string } { + const path = (input as { path?: unknown }).path; + return typeof path === "string" && path.endsWith(".base"); +} + +function viewSourceRecord({ path }: { path: string }) { + const id = path + .split("/") + .at(-1)! + .replace(/\.base$/u, ""); + const name = `${id[0].toUpperCase()}${id.slice(1)}`; + return { + path, + types: ["obsidian_base"], + revision: "view-r1", + frontmatter: { views: [{ type: "tasknotesTaskList", name }] }, + body: "", + document: `views:\n - type: tasknotesTaskList\n name: ${name}\n`, + }; +} + function defaultViewDocuments() { return { views: ["today", "upcoming", "calendar", "projects", "archive"].map(