diff --git a/AGENTS.md b/AGENTS.md index 6f4acea6..896f5d99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,8 +26,8 @@ Tests should always pass on `main`. Run `make test` before sending a PR. ``` cmd/ cobra commands (root, init, check, fix, inspect, collection, item, schema, rules) -internal/project project domain layer: the .katalyst/ loader (loader.go: schemas + storage instances, which embed their collections), the whole workspace, selectors, item enumeration -internal/storage backend-kind registry: StorageType, Known, Granularity, Reference +internal/project project domain layer: the .katalyst/ loader (loader.go: schemas + bases, which embed their collections), the whole workspace, selectors, item enumeration +internal/storage backend-kind registry: BaseType, Known, Scope, Reference internal/storage/collection the read stack: CollectionDefinition + the thin Item internal/storage/collection/listing item list filter/grep/sort/skip/limit pipeline internal/storage/collection/predicate metadata predicate grammar (item list --filter, collection variants) @@ -64,8 +64,8 @@ reconstruction), implemented per backend under `storage/collection/` (filesystem today). Don't inline filesystem assumptions (globbing, stem-as-id, path joins) elsewhere, a second backend (SQLite) attaches by implementing that interface. The `internal/project` loader (`loader.go`) owns the `.katalyst/` -*vocabulary*: it reads the workspace, resolves schemas, and assembles storage -instances. Each object type owns the parse of its own config — the storage +*vocabulary*: it reads the workspace, resolves schemas, and assembles bases. +Each object type owns the parse of its own config — the storage registry validates a declared `type` (`storage.Known`), and a collection parses its own block, including variant predicates, in `storage/collection` (which imports the sibling `predicate` grammar intra-subtree). The loader depends on diff --git a/README.md b/README.md index 45fcf673..fd4259b0 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ As your content evolves, Katalyst gives you tools to navigate change. - *Add or change checks* - *Change the structure of your content* -- *Change your storage layer* +- *Change your base* ## Design principles diff --git a/cmd/check.go b/cmd/check.go index 5129a852..cbe379d1 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -25,7 +25,7 @@ func newCheckCmd() *cobra.Command { Use: "check [selector ...]", Short: "Run configured checks against the selected items", Long: `check parses each selected item's frontmatter (YAML, TOML, or JSON) -and runs the checks configured for its collection under .katalyst/storage/. +and runs the checks configured for its collection under .katalyst/bases/. Selectors (see docs/content/deep-dives/domain-model/_index.md): diff --git a/cmd/check_test.go b/cmd/check_test.go index 59d23f01..f0d14fff 100644 --- a/cmd/check_test.go +++ b/cmd/check_test.go @@ -14,9 +14,9 @@ func setupNotesRepo(t *testing.T, notesCollection string) string { t.Helper() dir := t.TempDir() writeProject(t, dir, map[string]string{ - "config.yaml": schemaFormatJSON, - "schemas/book.json": bookSchemaFixture, - "storage/local.yaml": storageLocal(map[string]string{"notes": notesCollection}), + "config.yaml": schemaFormatJSON, + "schemas/book.json": bookSchemaFixture, + "bases/local.yaml": baseLocal(map[string]string{"notes": notesCollection}), }) chdir(t, dir) return dir @@ -167,7 +167,7 @@ func setupVariantRepo(t *testing.T, pagesBody string) string { "schemas/page.yaml": "type: object\nrequired: [title]\nproperties:\n title: {type: string}\n", "schemas/section.yaml": "type: object\n", "schemas/content.yaml": "type: object\nrequired: [weight]\nproperties:\n weight: {type: integer}\n", - "storage/local.yaml": storageLocal(map[string]string{"pages": pagesBody}), + "bases/local.yaml": baseLocal(map[string]string{"pages": pagesBody}), }) chdir(t, dir) return dir @@ -335,7 +335,7 @@ func TestCheck_inlineSchemaKeyTakesPrecedence(t *testing.T) { "config.yaml": schemaFormatJSON, "schemas/book.json": bookSchemaFixture, "schemas/strict-book.json": strictBookSchemaFixture, - "storage/local.yaml": storageLocal(map[string]string{"notes": "path: notes\nschema: book\n"}), + "bases/local.yaml": baseLocal(map[string]string{"notes": "path: notes\nschema: book\n"}), }) chdir(t, dir) @@ -356,7 +356,7 @@ func TestCheck_inlineSchemaKeyTakesPrecedence(t *testing.T) { func TestCheck_markdownAndFilesystemChecks(t *testing.T) { dir := t.TempDir() writeProject(t, dir, map[string]string{ - "storage/local.yaml": storageLocal(map[string]string{"notes": "path: notes\nchecks:\n - kind: markdown_title_matches_h1\n field: title\n"}), + "bases/local.yaml": baseLocal(map[string]string{"notes": "path: notes\nchecks:\n - kind: markdown_title_matches_h1\n field: title\n"}), }) chdir(t, dir) mustWrite(t, filepath.Join(dir, "notes/dune.md"), "---\ntitle: Dune\n---\n# Children of Dune\n") @@ -376,7 +376,7 @@ func TestCheck_markdownAndFilesystemChecks(t *testing.T) { func TestCheck_collectionScoped_rescanFullCollectionForSingleItemSelector(t *testing.T) { dir := t.TempDir() writeProject(t, dir, map[string]string{ - "storage/local.yaml": storageLocal(map[string]string{"notes": "path: notes\nchecks:\n - kind: filesystem_unique_field\n field: slug\n"}), + "bases/local.yaml": baseLocal(map[string]string{"notes": "path: notes\nchecks:\n - kind: filesystem_unique_field\n field: slug\n"}), }) chdir(t, dir) mustWrite(t, filepath.Join(dir, "notes/a.md"), "---\nslug: dune\n---\n# A\n") @@ -395,7 +395,7 @@ func TestCheck_collectionScoped_rescanFullCollectionForSingleItemSelector(t *tes func TestCheck_writingTells_warnButPass(t *testing.T) { dir := t.TempDir() writeProject(t, dir, map[string]string{ - "storage/local.yaml": storageLocal(map[string]string{"notes": "path: notes\nchecks:\n - kind: markdown_writing_tells\n"}), + "bases/local.yaml": baseLocal(map[string]string{"notes": "path: notes\nchecks:\n - kind: markdown_writing_tells\n"}), }) chdir(t, dir) mustWrite(t, filepath.Join(dir, "notes/x.md"), diff --git a/cmd/collection.go b/cmd/collection.go index bcc27208..7a981e7b 100644 --- a/cmd/collection.go +++ b/cmd/collection.go @@ -10,7 +10,7 @@ import ( func newCollectionCmd() *cobra.Command { c := &cobra.Command{ Use: "collection", - Short: "Inspect collections declared by storage instances under .katalyst/storage/", + Short: "Inspect collections declared by bases under .katalyst/bases/", } c.AddCommand(newCollectionListCmd(), newCollectionGetCmd()) return c diff --git a/cmd/fix_test.go b/cmd/fix_test.go index b60c795c..23725f66 100644 --- a/cmd/fix_test.go +++ b/cmd/fix_test.go @@ -17,7 +17,7 @@ func setupFixRepo(t *testing.T) string { t.Helper() dir := t.TempDir() writeProject(t, dir, map[string]string{ - "storage/local.yaml": storageLocal(map[string]string{"notes": fixNotesConfig}), + "bases/local.yaml": baseLocal(map[string]string{"notes": fixNotesConfig}), }) chdir(t, dir) return dir @@ -27,7 +27,7 @@ func setupFixRepoWith(t *testing.T, notesConfig string) string { t.Helper() dir := t.TempDir() writeProject(t, dir, map[string]string{ - "storage/local.yaml": storageLocal(map[string]string{"notes": notesConfig}), + "bases/local.yaml": baseLocal(map[string]string{"notes": notesConfig}), }) chdir(t, dir) return dir diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index 54f78710..a73071c0 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -61,20 +61,20 @@ func chdir(t *testing.T, dir string) { const schemaFormatJSON = "schemas:\n format: json\n" // writeProject scaffolds a .katalyst/ tree. Keys are paths relative to the -// .katalyst/ directory (e.g. "schemas/book.json", "storage/local.yaml", +// .katalyst/ directory (e.g. "schemas/book.json", "bases/local.yaml", // "config.yaml"); values are file contents. func writeProject(t *testing.T, dir string, files map[string]string) { t.Helper() projecttest.WriteProject(t, dir, files) } -// storageLocal builds a .katalyst/storage/local.yaml body: a filesystem -// instance rooted at the project, declaring the given collections. Each value +// baseLocal builds a .katalyst/bases/local.yaml body: a filesystem base rooted +// at the project, declaring the given collections. Each value // is the collection's YAML body, re-indented under its name. Collections now -// live inside their storage instance, so tests scaffold them this way instead +// live inside their base, so tests scaffold them this way instead // of one file per collection. -func storageLocal(collections map[string]string) string { - return projecttest.LocalStorage(collections) +func baseLocal(collections map[string]string) string { + return projecttest.LocalBase(collections) } // writeConfigDir writes the two-schema book-and-person project (book and @@ -87,7 +87,7 @@ func writeConfigDir(t *testing.T) string { "config.yaml": schemaFormatJSON, "schemas/book.json": bookSchemaFixture, "schemas/person.json": personSchemaFixture, - "storage/local.yaml": storageLocal(map[string]string{ + "bases/local.yaml": baseLocal(map[string]string{ "books": "path: notes/books\nschema: book\n", "people": "path: notes/people\nschema: person\n", }), diff --git a/cmd/init.go b/cmd/init.go index 9c0c9e95..630c27f1 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -15,24 +15,24 @@ import ( // the available knobs. const scaffoldConfig = `# katalyst project configuration. # -# Schemas live in .katalyst/schemas/.yaml. Storage instances live in -# .katalyst/storage/.yaml, and each instance declares the collections it -# maps. The settings below are optional and shown at their defaults; uncomment -# to change them. +# Schemas live in .katalyst/schemas/.yaml. Bases live in +# .katalyst/bases/.yaml, and each base declares the collections it maps. +# The settings below are optional and shown at their defaults; uncomment to +# change them. # # schemas: # discovery: convention # convention | explicit # format: yaml # yaml | json | both -# storage: +# bases: # discovery: convention # format: yaml ` -// scaffoldLocalStorage is the default storage instance written by init: the -// local filesystem rooted at the project. There is no implicit instance, -// this file is what makes the default explicit. Collections are declared -// inline here (or split into .katalyst/storage/local/.yaml). -const scaffoldLocalStorage = `# The default storage instance: the local filesystem, rooted at the project. +// scaffoldLocalBase is the default base written by init: the local filesystem +// rooted at the project. There is no implicit base, this file is what makes the +// default explicit. Collections are declared inline here (or split into +// .katalyst/bases/local/.yaml). +const scaffoldLocalBase = `# The default base: the local filesystem, rooted at the project. # Declare collections under "collections:", e.g. # # collections: @@ -68,7 +68,7 @@ func newInitCmd() *cobra.Command { return usageErr(fmt.Sprintf("%s already exists; refusing to overwrite", katalystDir)) } - for _, sub := range []string{"schemas", "storage"} { + for _, sub := range []string{"schemas", "bases"} { rel := filepath.Join(project.Dir, sub) if err := os.MkdirAll(filepath.Join(target, rel), 0o755); err != nil { return err @@ -76,13 +76,13 @@ func newInitCmd() *cobra.Command { fmt.Fprintf(cmd.OutOrStdout(), "created %s/\n", rel) } - // Write the default storage instance explicitly; katalyst never - // synthesizes one at runtime. - storageRel := filepath.Join(project.Dir, "storage", "local.yaml") - if err := os.WriteFile(filepath.Join(target, storageRel), []byte(scaffoldLocalStorage), 0o644); err != nil { + // Write the default base explicitly; katalyst never synthesizes one + // at runtime. + baseRel := filepath.Join(project.Dir, "bases", "local.yaml") + if err := os.WriteFile(filepath.Join(target, baseRel), []byte(scaffoldLocalBase), 0o644); err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "created %s\n", storageRel) + fmt.Fprintf(cmd.OutOrStdout(), "created %s\n", baseRel) cfgRel := filepath.Join(project.Dir, "config.yaml") if err := os.WriteFile(filepath.Join(target, cfgRel), []byte(scaffoldConfig), 0o644); err != nil { diff --git a/cmd/init_test.go b/cmd/init_test.go index e8cd1279..b5c445dd 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -16,8 +16,8 @@ func TestInit_preparesKatalystDir(t *testing.T) { for _, want := range []string{ ".katalyst", ".katalyst/schemas", - ".katalyst/storage", - ".katalyst/storage/local.yaml", + ".katalyst/bases", + ".katalyst/bases/local.yaml", ".katalyst/config.yaml", } { if _, err := os.Stat(filepath.Join(dir, want)); err != nil { @@ -39,7 +39,7 @@ func TestInit_writesNoExampleContent(t *testing.T) { "schemas", "notes", ".katalyst/schemas/book.yaml", - ".katalyst/storage/local/notes.yaml", + ".katalyst/bases/local/notes.yaml", } { if _, err := os.Stat(filepath.Join(dir, unwanted)); err == nil { t.Errorf("did not expect %s to exist", unwanted) diff --git a/cmd/inspect_test.go b/cmd/inspect_test.go index 07687da1..e9456b46 100644 --- a/cmd/inspect_test.go +++ b/cmd/inspect_test.go @@ -35,7 +35,7 @@ func TestInspect_rawPathRunsSourceLayer(t *testing.T) { func TestInspect_collectionLayerWhenConfigured(t *testing.T) { dir := t.TempDir() - writeFile(t, dir, ".katalyst/storage/local.yaml", `type: filesystem + writeFile(t, dir, ".katalyst/bases/local.yaml", `type: filesystem root: . collections: notes: diff --git a/cmd/item_test.go b/cmd/item_test.go index 4af2d7f1..daac1797 100644 --- a/cmd/item_test.go +++ b/cmd/item_test.go @@ -17,7 +17,7 @@ func setupItemRepo(t *testing.T) string { "config.yaml": schemaFormatJSON, "schemas/book.json": bookSchemaFixture, "schemas/strict-book.json": strictBookSchemaFixture, - "storage/local.yaml": storageLocal(map[string]string{"notes": objectNotesConfig}), + "bases/local.yaml": baseLocal(map[string]string{"notes": objectNotesConfig}), }) chdir(t, dir) return dir diff --git a/cmd/testdata/snapshots/help/check.txt b/cmd/testdata/snapshots/help/check.txt index db59ae9d..c106dd9b 100644 --- a/cmd/testdata/snapshots/help/check.txt +++ b/cmd/testdata/snapshots/help/check.txt @@ -1,5 +1,5 @@ check parses each selected item's frontmatter (YAML, TOML, or JSON) -and runs the checks configured for its collection under .katalyst/storage/. +and runs the checks configured for its collection under .katalyst/bases/. Selectors (see docs/content/deep-dives/domain-model/_index.md): diff --git a/docs/content/contributing/how-we-document.md b/docs/content/contributing/how-we-document.md index cecbe9d8..45d31903 100644 --- a/docs/content/contributing/how-we-document.md +++ b/docs/content/contributing/how-we-document.md @@ -34,8 +34,8 @@ The durable home for everything a user needs, organized by - **`reference/`:** information-oriented lookup: configuration, the generated check-type reference, the glossary, the command surface. - **`deep-dives/`:** understanding-oriented "why" (the Diátaxis *explanation* - quadrant): the vision and scope, the domain model, the storage layer, - progressive operations, and **design rationale at the behavioral altitude** - + quadrant): the vision and scope, the domain model, bases, progressive + operations, and **design rationale at the behavioral altitude** - any *why* a user can observe, whatever subsystem it touches. A short **Why Katalyst?** orientation page sits at the top level. The narrower *why* that only matters once you are reading a package's code lives with that code (see diff --git a/docs/content/contributing/how-we-plan.md b/docs/content/contributing/how-we-plan.md index 1bf0e03a..5f219a9f 100644 --- a/docs/content/contributing/how-we-plan.md +++ b/docs/content/contributing/how-we-plan.md @@ -76,7 +76,7 @@ document]({{< relref "how-we-document.md" >}}) for what belongs where: - **`docs/reference/glossary.md`:** new vocabulary. - **`README.md`:** pointer/overview updates. -Evergreen deep-dive docs (the storage layer, progressive operations) and the +Evergreen deep-dive docs (bases, progressive operations) and the per-package `AGENTS.md` files are *not* specs and don't get retired: they're updated in place. diff --git a/docs/content/deep-dives/_index.md b/docs/content/deep-dives/_index.md index 1367ae04..9c137b19 100644 --- a/docs/content/deep-dives/_index.md +++ b/docs/content/deep-dives/_index.md @@ -11,8 +11,8 @@ Understanding-oriented discussion of the *why* behind Katalyst: the [domain model]({{< relref "domain-model/_index.md" >}}) the tool is built on, and the deeper design discussions that no single page or package owns: how [checks work]({{< relref "domain-model/checks.md" >}}) and the libraries that -run them, how the [storage layer]({{< relref "domain-model/storage.md" >}}) -maps stores onto the model, and how operations grow richer as a backend's +run them, how [bases]({{< relref "domain-model/storage.md" >}}) map stores onto +the model, and how operations grow richer as a backend's capabilities increase. For the short version, start with [Welcome]({{< relref "../welcome.md" >}}). diff --git a/docs/content/deep-dives/domain-model/checks.md b/docs/content/deep-dives/domain-model/checks.md index 5e167d9b..9c9b08ce 100644 --- a/docs/content/deep-dives/domain-model/checks.md +++ b/docs/content/deep-dives/domain-model/checks.md @@ -175,7 +175,7 @@ real out-of-process library exists. lifecycle, the schema resolver, and the validation result. - The [glossary]({{< relref "../../reference/glossary.md" >}}) for the canonical terms (check type, check instance, CheckLibrary, schema, violation). -- The [storage layer]({{< relref "storage.md" >}}) for the collection and item +- The [Bases]({{< relref "storage.md" >}}) for the collection and item identities checks run against, and the inspector that is a check's descriptive dual. - `go doc ./internal/checks` for the code-level engine contract. diff --git a/docs/content/deep-dives/domain-model/collections.md b/docs/content/deep-dives/domain-model/collections.md index 0c8b4cf8..eedaa16c 100644 --- a/docs/content/deep-dives/domain-model/collections.md +++ b/docs/content/deep-dives/domain-model/collections.md @@ -6,10 +6,10 @@ weight = 42 # Collections The `internal/project` loader (`loader.go`) is the orchestration hub: it loads a -project's `.katalyst/` directory, resolves named schemas, and assembles storage -instances and their collections (each object type parses its own config — the -storage registry validates a declared `type`, and a collection parses its own -block in `storage/collection`). It decides which schema applies to a given item, +project's `.katalyst/` directory, resolves named schemas, and assembles bases +and their collections (each object type parses its own config — the storage +registry validates a declared `type`, and a collection parses its own block in +`storage/collection`). It decides which schema applies to a given item, and the `check` lifecycle is driven from here. This page is the model and the *why*; for the key-by-key surface see the [configuration reference]({{< relref "../../reference/configuration.md" >}}). @@ -21,22 +21,22 @@ from the working directory to the nearest ancestor that contains one. That ancestor becomes the repo root for all path resolution. The directory holds an optional `config.yaml`, one schema file per definition -under `schemas/`, and one storage-instance file per definition under `storage/`. -A directory (rather than one big file) keeps each schema and instance in its own +under `schemas/`, and one base file per definition under `bases/`. A directory +(rather than one big file) keeps each schema and base in its own reviewable file and lets the name fall out of the filename by convention. A nearest-ancestor lookup mirrors `.git`, `.editorconfig`, and `go.mod`: familiar and predictable. Discovery resolves symlinks on both the root and the input path, because on macOS `$TMPDIR` lives behind `/var` to `/private/var` and relative-path resolution would otherwise produce garbage. -`config.yaml` is YAML; schema and storage files default to YAML/JSON and the +`config.yaml` is YAML; schema and base files default to YAML/JSON and the accepted format is set per kind there. Default discovery is **convention** (one file per definition); a kind can be switched to **explicit** to list its definitions inline in `config.yaml` instead. -Collections are declared *inside* a [storage instance]({{< relref "storage.md" >}}), +Collections are declared *inside* a [base]({{< relref "storage.md" >}}), which owns the backend-to-collection mapping. This page covers the collection -model and schema resolution; the storage layer covers how an instance maps a +model and schema resolution; the base layer covers how a base maps a backend onto those collections. ## The model @@ -44,10 +44,10 @@ backend onto those collections. - **Collection** - a named group of items backed by a directory; the unit you select on the command line and the unit that owns a set of checks. `path` defaults to the collection name; `pattern` defaults to `*.md`. Collection - names are unique project-wide, since a selector carries no instance qualifier. + names are unique project-wide, since a selector carries no base qualifier. ```yaml - # inside .katalyst/storage/local.yaml + # inside .katalyst/bases/local.yaml collections: books: path: notes/books # directory, relative to the repo root @@ -86,7 +86,7 @@ The collection model is intentionally broader than "a directory of markdown files." A collection is the named group Katalyst can list, select, inspect, and check, even when the backing storage has a different native vocabulary. -| System | Storage | Collection | Item | Attribute | +| System | Base | Collection | Item | Attribute | |----------------------|---------------|-----------------|------------|------------------| | Postgres | The database | A table | A row | A column | | MongoDB | The database | A collection | A document | A field | @@ -137,7 +137,7 @@ The discriminator is metadata, not a glob, on purpose: metadata is the one property every item yields on every backend (frontmatter for a file, columns for a future row), so routing stays portable and the engine never depends on the storage type. Selecting by *path* is a storage-type-scoped condition, deferred. -(The storage layer covers [how variants route checks rather than +(The base layer covers [how variants route checks rather than membership]({{< relref "storage.md" >}}).) ## Why a file inside a collection must match @@ -146,7 +146,7 @@ A file that sits inside a collection's directory but does not match its `pattern` is reported as an **error**, not silently skipped. Silent skips hide config drift: a typo'd pattern or a misfiled document would simply disappear from validation. Opt-outs (`--allow-unmatched` and a config knob) are deferred -until real usage shows the need. The storage layer frames the same decision as +until real usage shows the need. The base layer frames the same decision as [unmatched references being first-class]({{< relref "storage.md" >}}). ## Why named collections replaced the old `rules:` list @@ -207,8 +207,8 @@ The data flow per item, end to end: - The [configuration reference]({{< relref "../../reference/configuration.md" >}}) for the precise `.katalyst/` surface. -- The [storage layer]({{< relref "storage.md" >}}) for how a backend maps onto - collections, and the instance model. +- The [base layer]({{< relref "storage.md" >}}) for how a backend maps onto + collections, and the base model. - The [domain model]({{< relref "_index.md" >}}) for the cross-subsystem entity map and invariants. - `go doc ./internal/project` for the code-level contract. diff --git a/docs/content/deep-dives/domain-model/inspectors.md b/docs/content/deep-dives/domain-model/inspectors.md index 8b22af6d..6bb08bba 100644 --- a/docs/content/deep-dives/domain-model/inspectors.md +++ b/docs/content/deep-dives/domain-model/inspectors.md @@ -31,7 +31,7 @@ Inspectors come in two layers, distinguished by *how they reference the data*: The two are **distinct interfaces, not one type at two scopes**, precisely because they reference the data through different machinery. This mirrors the -seam in the [storage layer]({{< relref "storage.md" >}}). +seam in the [Bases]({{< relref "storage.md" >}}). ## Built from primitives diff --git a/docs/content/deep-dives/domain-model/storage.md b/docs/content/deep-dives/domain-model/storage.md index 8ef2b918..975c2e5f 100644 --- a/docs/content/deep-dives/domain-model/storage.md +++ b/docs/content/deep-dives/domain-model/storage.md @@ -5,12 +5,13 @@ weight = 40 # Bases -The **storage layer** is how Katalyst reaches a backend store and maps that -store into the domain model. +The **base layer** is how Katalyst reaches a backend store and maps that store +into the domain model. -Every base must have include configuration for **raw** access. Raw access gives Katalyst a stable way to -locate content in the store. For a filesystem, that can be a root directory. -For SQL, that can be connection information for a specific instance. +Every base includes configuration for **raw** access. Raw access gives Katalyst +a stable way to locate content in the store. For a filesystem, that can be a +root directory. For SQL, that can be connection information for a specific +instance. A **collectionized** base keeps that raw access and adds collection definitions. Those definitions map backend-native references into named @@ -29,16 +30,16 @@ and *how does its content map to the model*, so it was split: | Concept | Meaning | |---|---| | **BaseType** | A known backend kind capable of holding collections and items: `filesystem` today; `sqlite`, `postgresql`, `mongodb` later. | -| **BaseInstance** | A specific, connectable instance of a StorageType, plus the information needed to reach it (for `filesystem`, a root directory). | -| **CollectionDefinition** | The two-way mapping from a StorageInstance's contents to collections and items. `FilesystemCollectionDefinition` is the first; one definition may yield **more than one** collection. | +| **BaseInstance** | A specific, connectable instance of a BaseType, plus the information needed to reach it (for `filesystem`, a root directory). | +| **CollectionDefinition** | The two-way mapping from a BaseInstance's contents to collections and items. `FilesystemCollectionDefinition` is the first; one definition may yield **more than one** collection. | -In config, a StorageInstance declares the collections it maps, the instance -file *is* where the CollectionDefinition lives (see +In config, a BaseInstance declares the collections it maps, the base file *is* +where the CollectionDefinition lives (see [Configuration]({{< relref "../../reference/configuration.md" >}})). In code, the seam is `internal/storage/collection.CollectionDefinition`; `internal/project` consumes it rather than implementing the filesystem mapping inline. -Storage readers use codecs to decode a matched unit's content into the shape +Base readers use codecs to decode a matched unit's content into the shape checks and inspectors consume. The markdown filesystem reader uses `internal/codec/markdownbodytext` for frontmatter/body parsing; codecs are shared content adapters, not storage backends. @@ -59,7 +60,7 @@ path-reconstruction problem. Today it is the degenerate, stem-only case ## The scope principle **"What does one matched store unit become?" has no global answer, it is a -property each StorageType declares for its backend.** +property each BaseType declares for its backend.** - **Markdown filesystem:** one file = one **Item**; a directory of files = a **Collection**. @@ -98,7 +99,7 @@ that matched nothing. A collection may run different checks on different items via [variants]({{< relref "../../reference/configuration.md" >}}#variants), but that is a *check-engine* concern, not a storage one. A variant's discriminator is a -predicate over an item's **metadata**: portable across every StorageType, since +predicate over an item's **metadata**: portable across every BaseType, since each yields a metadata map (frontmatter for a file, columns for a row). It never touches the seam: membership, `Unmatched`, and `Reference` stay governed by the definition's `pattern`. Discriminating by *path* would be a storage-type-scoped @@ -134,8 +135,9 @@ the definition's pattern are two views of the same thing. ## Seam and extension points -- **Core seam:** `internal/storage` defines `StorageType`, - `StorageInstance`, `CollectionDefinition`, and `Reference`. The filesystem +- **Core seam:** `internal/storage` defines `BaseType`, `Scope`, and + `Reference`; `internal/project` assembles `BaseInstance` values, and + `internal/storage/collection` defines `CollectionDefinition`. The filesystem implementation maps a directory to a collection and each `*.md` file to an item with a stem id. - **Extension point:** anything that turns a path into an item identity (or back) @@ -148,11 +150,11 @@ the definition's pattern are two views of the same thing. | Term | Meaning | |---|---| -| **StorageType** | A known backend kind (filesystem, sqlite, ...). | -| **StorageInstance** | A configured instance of a StorageType plus how to reach it. | +| **BaseType** | A known backend kind (filesystem, sqlite, ...). | +| **BaseInstance** | A configured instance of a BaseType plus how to reach it. | | **CollectionDefinition** | The backend↔domain two-way mapping; yields one or more collections. | | **Data reference** | A backend-native locator (file path, S3 key, table name). | | **Coordinates** | The captured fields that identify a unit within its collection. | -| **Scope** | The domain level, item or collection, at which a StorageType attaches a store's units to the model. | +| **Scope** | The domain level, item or collection, at which a BaseType attaches a store's units to the model. | [addressing]: {{< relref "_index.md" >}} diff --git a/docs/content/deep-dives/vision.md b/docs/content/deep-dives/vision.md index 415cf4db..20f8bd71 100644 --- a/docs/content/deep-dives/vision.md +++ b/docs/content/deep-dives/vision.md @@ -7,8 +7,8 @@ weight = 10 Traditional data management often forces teams into binary choices: structured or unstructured, rigid or chaotic. Katalyst is an experimental -framework aimed at enabling fast, low-risk evolution through progressive -typing in the storage layer. +framework aimed at enabling fast, low-risk evolution through progressive typing +across bases and operations. ## Database management is risky and rigid diff --git a/docs/content/getting-started.md b/docs/content/getting-started.md index a1aea513..f75d6621 100644 --- a/docs/content/getting-started.md +++ b/docs/content/getting-started.md @@ -25,11 +25,11 @@ katalyst check - `.katalyst/config.yaml`, commented project settings - `.katalyst/schemas/`, one schema per file (empty to start) -- `.katalyst/storage/local.yaml`, the default storage instance (the local +- `.katalyst/bases/local.yaml`, the default base (the local filesystem), where you declare collections It writes no example content. Add a schema under `.katalyst/schemas/` and -declare a collection inside `.katalyst/storage/local.yaml`, then run +declare a collection inside `.katalyst/bases/local.yaml`, then run `katalyst check`. Next: diff --git a/docs/content/how-to/add-a-schema.md b/docs/content/how-to/add-a-schema.md index 0b6c7e06..20258bbc 100644 --- a/docs/content/how-to/add-a-schema.md +++ b/docs/content/how-to/add-a-schema.md @@ -39,7 +39,7 @@ The shortest way is the `schema:` shorthand, which adds a single `object` check: ```yaml -# .katalyst/storage/local.yaml +# .katalyst/bases/local.yaml type: filesystem root: . collections: @@ -52,7 +52,7 @@ Equivalently, add an explicit object check to `checks`, useful when you mix it with markdown or filesystem checks: ```yaml -# .katalyst/storage/local.yaml — under collections: books: +# .katalyst/bases/local.yaml — under collections: books: path: notes/books checks: - kind: object diff --git a/docs/content/how-to/configure-rules.md b/docs/content/how-to/configure-rules.md index 7c52d42b..004195ae 100644 --- a/docs/content/how-to/configure-rules.md +++ b/docs/content/how-to/configure-rules.md @@ -10,13 +10,13 @@ them. This guide adds a collection and attaches checks to it. ## 1. Point a collection at the directory -Collections are declared inside a storage instance. In a fresh project that is -`.katalyst/storage/local.yaml` (the default filesystem instance). Add the +Collections are declared inside a base. In a fresh project that is +`.katalyst/bases/local.yaml` (the default filesystem base). Add the collection under `collections:`, keyed by its name; `path` is the directory -relative to the instance root: +relative to the base root: ```yaml -# .katalyst/storage/local.yaml +# .katalyst/bases/local.yaml type: filesystem root: . collections: @@ -34,7 +34,7 @@ the [check types reference]({{< relref "../reference/check-types/_index.md" >}}) for every check type: ```yaml -# .katalyst/storage/local.yaml +# .katalyst/bases/local.yaml type: filesystem root: . collections: diff --git a/docs/content/how-to/profile-an-existing-wiki-by-hand.md b/docs/content/how-to/profile-an-existing-wiki-by-hand.md index e3af96aa..07f18baf 100644 --- a/docs/content/how-to/profile-an-existing-wiki-by-hand.md +++ b/docs/content/how-to/profile-an-existing-wiki-by-hand.md @@ -43,7 +43,7 @@ Point a collection at the directory so the field-level layer can run. Minimal config: ```yaml -# .katalyst/storage/local.yaml +# .katalyst/bases/local.yaml type: filesystem root: . collections: @@ -103,7 +103,7 @@ properties: ``` ```yaml -# .katalyst/storage/local.yaml (extend the collection from step 2) +# .katalyst/bases/local.yaml (extend the collection from step 2) type: filesystem root: . collections: diff --git a/docs/content/how-to/profile-an-existing-wiki-with-an-agent.md b/docs/content/how-to/profile-an-existing-wiki-with-an-agent.md index 67d5b8d2..4d0cd8ea 100644 --- a/docs/content/how-to/profile-an-existing-wiki-with-an-agent.md +++ b/docs/content/how-to/profile-an-existing-wiki-with-an-agent.md @@ -39,7 +39,7 @@ A capable agent then: 1. **Clusters** the `document_shape` classes into candidate collections. `inspect` groups files with *matching* fingerprints; the agent decides when two near-but-distinct classes are really one collection, and names them. It - drafts `.katalyst/storage/*` pointing each collection at its directory. + drafts `.katalyst/bases/*` pointing each collection at its directory. 2. **Profiles the fields** by inspecting each new collection, `katalyst inspect --json` runs the collection layer, whose `object_fields` record is the per-field data dictionary (presence, types, values). diff --git a/docs/content/reference/configuration.md b/docs/content/reference/configuration.md index 8a072947..65040163 100644 --- a/docs/content/reference/configuration.md +++ b/docs/content/reference/configuration.md @@ -21,20 +21,24 @@ collection]({{< relref "../how-to/configure-rules.md" >}}). config.yaml # optional: listing defaults and discovery settings schemas/ # one JSON Schema file per named schema book.json - storage/ # one file per storage instance - local.yaml # an instance + the collections it declares + bases/ # one file per base + local.yaml # a base + the collections it declares local/ # optional: one file per collection (escape hatch) books.yaml ``` -By default, schemas and storage instances are discovered by **convention**: +By default, schemas and bases are discovered by **convention**: every file under `schemas/` is a schema whose name is its filename stem -(`book.json` → `book`), and every file under `storage/` is a -[storage instance](#storage-instances) named for its filename stem -(`local.yaml` → `local`). `config.yaml` is optional; it carries `listing:` +(`book.json` → `book`), and every file under `bases/` is a +[base](#bases) named for its filename stem (`local.yaml` → `local`). +`config.yaml` is optional; it carries `listing:` defaults and can switch a kind to **explicit** discovery, listing definitions inline instead of as files. +Legacy projects that still use `storage:` in `config.yaml` or +`.katalyst/storage/` continue to load. Do not mix legacy and new forms in the +same project; move legacy base files to `.katalyst/bases/` when you edit them. + ## Schemas Each file under `.katalyst/schemas/` is a JSON Schema. Its **name**, the @@ -45,21 +49,21 @@ collection's `schema:` shorthand. The path can move; the name should not. Schemas are stored flat; the check library that compiles a schema is determined by the referencing check type's `kind` (the `object` check uses JSON Schema). -## Storage instances +## Bases -A **storage instance** is one configured backend store, today always the local -filesystem, plus the collections it maps onto the domain model. Each file under -`.katalyst/storage/` is one instance, named for its filename stem. There is no -implicit instance; `katalyst init` writes a default `local` one. +A **base** is one configured backend store, today always the local filesystem, +plus the collections it maps onto the domain model. Each file under +`.katalyst/bases/` is one base, named for its filename stem. There is no +implicit base; `katalyst init` writes a default `local` one. | Key | Required | Default | Meaning | |---|---|---|---| | `type` | no | `filesystem` | Backend kind. `filesystem` is the only kind today. | -| `root` | no | `.` | Instance root directory, relative to the repo root. Collection paths resolve against it. | +| `root` | no | `.` | Base root directory, relative to the repo root. Collection paths resolve against it. | | `collections` | no | - | Map of collection name → definition (see below). | ```yaml -# .katalyst/storage/local.yaml +# .katalyst/bases/local.yaml type: filesystem root: . collections: @@ -71,16 +75,16 @@ collections: ``` Collection names are unique across the whole project (selectors are -`/`, with no instance qualifier). +`/`, with no base qualifier). ## Collections A **collection** is a directory of items plus the checks every item must pass. -Collections are declared inside their storage instance, under `collections:`. +Collections are declared inside their base, under `collections:`. | Key | Required | Default | Meaning | |---|---|---|---| -| `path` | no | the collection name | Directory, relative to the instance `root`. | +| `path` | no | the collection name | Directory, relative to the base `root`. | | `pattern` | no | `*.md` | Filename glob selecting items in the directory. | | `schema` | no | - | Schema name; shorthand for a leading `object` check. | | `checks` | no | - | List of checks (see below). | @@ -92,13 +96,13 @@ non-empty `checks` list, or both. Files in the directory that do not match ### Per-collection files -An instance whose `collections:` block grows unwieldy may split collections into -one file each under `.katalyst/storage//.yaml`, named for +A base whose `collections:` block grows unwieldy may split collections into +one file each under `.katalyst/bases//.yaml`, named for its filename stem. Inline and per-file collections coexist; a name declared both inline and in a file is an error. ```yaml -# .katalyst/storage/local/books.yaml +# .katalyst/bases/local/books.yaml path: notes/books schema: book ``` @@ -215,7 +219,7 @@ listing: ``` ```yaml -# under a storage instance's collections: — override for one collection +# under a base's collections: override for one collection books: path: notes/books schema: book @@ -244,8 +248,8 @@ variant), even when `--schema` is used. ## See also - [Check types reference]({{< relref "check-types/_index.md" >}}), every check type. -- [Storage layer]({{< relref "../deep-dives/domain-model/storage.md" >}}), the storage - instance / collection-definition model and its lineage. +- [Bases]({{< relref "../deep-dives/domain-model/storage.md" >}}), the base / + collection-definition model and its lineage. - [Collections]({{< relref "../deep-dives/domain-model/collections.md" >}}), the config/collection model and rationale: schema resolution, variants, unmatched-as-error. diff --git a/docs/content/reference/glossary.md b/docs/content/reference/glossary.md index 3ceb83b9..e3a7f580 100644 --- a/docs/content/reference/glossary.md +++ b/docs/content/reference/glossary.md @@ -15,6 +15,9 @@ how each term maps onto today's code is documented in the per-package |---|---| | **Aggregate** | The descriptive operation an inspector realizes: measuring a distribution across a collection's items rather than fetching or asserting. See **Inspector**. | | **Attribute** | A named characteristic of an item: a frontmatter key, but also its filename, path, or extension. The general term; a key in the structured object specifically is a **Field**. | +| **Base** | One configured backend store plus the operations Katalyst can perform on its content. A raw base gives Katalyst backend-native access; a collectionized base adds collection definitions. | +| **BaseInstance** | A configured instance of a BaseType plus how to reach it (for `filesystem`, a root directory). Declared under `.katalyst/bases/`; it embeds the collections it maps. | +| **BaseType** | A known backend kind capable of holding content Katalyst can operate on (`filesystem` today; `sqlite`, `postgresql`, `mongodb` later). | | **Body** | Everything after the closing frontmatter fence. Preserved verbatim except by `fix`. | | **Check** | Shorthand for a check instance when context is unambiguous. | | **Check instance** | One configured check attached to a collection: a check type plus its arguments (one YAML object under `checks:`). It runs against each item (object, markdown, or filesystem family). | @@ -23,8 +26,8 @@ how each term maps onto today's code is documented in the per-package | **Collection** | A named entry in `collections:`: a directory, a filename `pattern`, and the checks its items must pass. | | **Collection layer** | Inspectors that profile a configured collection's items, addressed by domain identity (collection + item id) and probing through the same substrate the checks use. | | **Collection-scoped check** | A check type that runs once per collection over all its items (e.g. `filesystem_unique_filename`), rather than per item. It re-scans the full collection even under a single-item selector. | -| **CollectionDefinition** | The two-way mapping from a StorageInstance's contents to collections and items. Yields one or more collections; the filesystem is the only backend today. See [storage layer]({{< relref "../deep-dives/domain-model/storage.md" >}}). | -| **Config** | A **Project**'s configuration: the schemas, storage instances, and collection definitions that declare what the project contains and how its items are checked. Katalyst's config is the `.katalyst/` directory; it is loaded by the `project` package's loader (`internal/project/loader.go`). Each object type owns the parse of its own config — the storage registry validates a declared `type`, and a collection parses its own block in `storage/collection`. | +| **CollectionDefinition** | The two-way mapping from a BaseInstance's contents to collections and items. Yields one or more collections; the filesystem is the only backend today. See [Bases]({{< relref "../deep-dives/domain-model/storage.md" >}}). | +| **Config** | A **Project**'s configuration: the schemas, bases, and collection definitions that declare what the project contains and how its items are checked. Katalyst's config is the `.katalyst/` directory; it is loaded by the `project` package's loader (`internal/project/loader.go`). Each object type owns the parse of its own config: the storage registry validates a declared `type`, and a collection parses its own block in `storage/collection`. | | **Discriminator** | The `when` predicate that selects a variant: a list of `item list --filter` expressions over an item's metadata, ANDed together. | | **Document** | The markdown file-form of an **Item**: a parsed markdown file (frontmatter metadata + body + a line map). Use it where parsing or the on-disk file is the subject; elsewhere prefer **Item**. | | **Evidence** | The structured result of one inspector: counts and distributions with the unit count `n` as denominator. Never a recommendation or verdict. | @@ -35,19 +38,17 @@ how each term maps onto today's code is documented in the per-package | **Item** | The unit of data in a collection, addressed by a selector and operated on by `check`, `fix`, and the `item` subcommands. In the filesystem backend an item is one file matching the collection's pattern, its id the filename stem; its markdown file-form is a **Document**. | | **Measurement primitive** | A reusable building block the inspectors are built from: `object_fields` (a data dictionary over object maps), `markdown_body` (body structure), and file-metadata. | | **Metadata** | The parsed, in-memory structure of the frontmatter (a `map[string]any`). | -| **Operation** | Something a storage backend lets you do with its data: read, list, query, aggregate, write. Each has a scope (item, collection, across collections) and structural requirements the backend must satisfy. See [progressive operations]({{< relref "../deep-dives/progressive-operations.md" >}}). | +| **Operation** | Something a base lets you do with its data: read, list, query, aggregate, write. Each has a scope (item, collection, across collections) and structural requirements the backend must satisfy. See [progressive operations]({{< relref "../deep-dives/progressive-operations.md" >}}). | | **Profile class** | A group of near-identical profiles the summarizer collapses together, so output is proportional to the number of distinct profiles, not directories. | -| **Project** | The whole katalyst workspace: a repo root with a `.katalyst/` **Config** that declares the storage instances, collections, and checks katalyst operates over. The top-level scope an empty selector addresses, and what `katalyst init` creates. Collections live within a project; the `project` package (`internal/project`) is its code home, holding the `.katalyst/` loader while the `collection` layer lives under `storage/`. | +| **Project** | The whole katalyst workspace: a repo root with a `.katalyst/` **Config** that declares the bases, collections, and checks katalyst operates over. The top-level scope an empty selector addresses, and what `katalyst init` creates. Collections live within a project; the `project` package (`internal/project`) is its code home, holding the `.katalyst/` loader while the collection layer lives under `storage/`. | | **Raw-source layer** | Inspectors that profile a backend store directly, before any collection configuration, addressed by backend-native reference (a path today). The onboarding case: "what's in this store?" | | **Repo root** | The directory containing the `.katalyst/` config directory; the base for all path resolution. | | **Resolver** | The runtime object that decides which object schema applies to an item and caches compiled schemas per `(library, path)`. | | **Schema** | The definition of a collection's shape, expressed in a CheckLibrary's format (JSON Schema today; a Vale style config later). Named in `schemas:`; located by path. The katalyst concept, not the JSON Schema document specifically. | | **Schema directive** | The inline `schema:` key inside a document's frontmatter, opting it into a named schema. | | **Selector** | How a command names what to operate on: nothing (whole project), ``, or `/`. | -| **Scope** | The level an operation or backend mapping applies to: item, collection, project, or across collections. In the storage layer, scope answers whether one matched backend unit becomes an item or a collection. | +| **Scope** | The level an operation or backend mapping applies to: item, collection, project, or across collections. In the base layer, scope answers whether one matched backend unit becomes an item or a collection. | | **Span** | The slice of body text a text rule is evaluated against, chosen by its `target`: the whole `body`, each `line`, the `first-line`, or `matched-lines` (lines matching a `select` regex). | -| **StorageInstance** | A configured instance of a StorageType plus how to reach it (for `filesystem`, a root directory). Declared under `.katalyst/storage/`; it embeds the collections it maps. | -| **StorageType** | A known backend kind capable of holding collections and items (`filesystem` today; `sqlite`, `postgresql`, `mongodb` later). | | **Target** | The slice of a path a filesystem name/path check type tests: `filename`, `filename-ext`, `parent-dir`, or `path-segments` (every directory segment plus the basename). For a text rule, the slice of body it tests, see Span. | | **Text rule** | A `text_*` check (`text_requires`, `text_forbids`, `text_denylist`) that tests the body as raw text, a regex or a literal denylist, independent of markdown structure. Applies to plain-text items too. | | **Validation result** | The product of running an item's checks: either `path: OK`, or a flat list of violations. | diff --git a/docs/content/welcome.md b/docs/content/welcome.md index afa2a116..d680df20 100644 --- a/docs/content/welcome.md +++ b/docs/content/welcome.md @@ -65,7 +65,7 @@ Common updates include: - *Rules*: add or change checks. - *Content shape*: change the structure of your content. -- *Storage*: change your storage layer. +- *Bases*: change where content lives. ## Design principles diff --git a/docs/generated/examples/check-collection-rules.full.md b/docs/generated/examples/check-collection-rules.full.md index a870187d..e29f3318 100644 --- a/docs/generated/examples/check-collection-rules.full.md +++ b/docs/generated/examples/check-collection-rules.full.md @@ -20,7 +20,7 @@ title: Bad title # A different heading ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/docs/generated/examples/check-schema-missing-field.full.md b/docs/generated/examples/check-schema-missing-field.full.md index c1dac4d6..f2af4eb2 100644 --- a/docs/generated/examples/check-schema-missing-field.full.md +++ b/docs/generated/examples/check-schema-missing-field.full.md @@ -21,7 +21,7 @@ title: Foundation # Foundation ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/docs/generated/examples/check-title-h1-mismatch.full.md b/docs/generated/examples/check-title-h1-mismatch.full.md index a56a047b..be7825d9 100644 --- a/docs/generated/examples/check-title-h1-mismatch.full.md +++ b/docs/generated/examples/check-title-h1-mismatch.full.md @@ -11,7 +11,7 @@ title: Dune # Children of Dune ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/docs/generated/examples/check-type-error.full.md b/docs/generated/examples/check-type-error.full.md index 0c509f3d..24bc60c6 100644 --- a/docs/generated/examples/check-type-error.full.md +++ b/docs/generated/examples/check-type-error.full.md @@ -12,7 +12,7 @@ year: "not a number" # Dune ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/docs/generated/examples/check-valid-item.full.md b/docs/generated/examples/check-valid-item.full.md index f0aeabd5..8d0a02a0 100644 --- a/docs/generated/examples/check-valid-item.full.md +++ b/docs/generated/examples/check-valid-item.full.md @@ -12,7 +12,7 @@ year: 1965 # Dune ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/docs/generated/examples/ci-check-fails.full.md b/docs/generated/examples/ci-check-fails.full.md index 125ef661..ff612a4f 100644 --- a/docs/generated/examples/ci-check-fails.full.md +++ b/docs/generated/examples/ci-check-fails.full.md @@ -20,7 +20,7 @@ title: Draft No heading here. ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/docs/generated/examples/ci-fix-check.full.md b/docs/generated/examples/ci-fix-check.full.md index 8bd68d97..ed2744c9 100644 --- a/docs/generated/examples/ci-fix-check.full.md +++ b/docs/generated/examples/ci-fix-check.full.md @@ -21,7 +21,7 @@ author: Ada # Messy ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/docs/generated/examples/fix-normalize-frontmatter.full.md b/docs/generated/examples/fix-normalize-frontmatter.full.md index 62295396..885766df 100644 --- a/docs/generated/examples/fix-normalize-frontmatter.full.md +++ b/docs/generated/examples/fix-normalize-frontmatter.full.md @@ -13,7 +13,7 @@ apple: 2 verbatim ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/docs/generated/examples/fix-text-forbids.full.md b/docs/generated/examples/fix-text-forbids.full.md index 62a0f68d..43c86335 100644 --- a/docs/generated/examples/fix-text-forbids.full.md +++ b/docs/generated/examples/fix-text-forbids.full.md @@ -12,7 +12,7 @@ t: 1 keep this. ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/docs/generated/examples/inspect-collection-fields.full.md b/docs/generated/examples/inspect-collection-fields.full.md index 6202bb21..0bbf531f 100644 --- a/docs/generated/examples/inspect-collection-fields.full.md +++ b/docs/generated/examples/inspect-collection-fields.full.md @@ -65,7 +65,7 @@ status: read # Dune Messiah ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/internal/examples/AGENTS.md b/internal/examples/AGENTS.md index 8a5c68dc..e217e1fc 100644 --- a/internal/examples/AGENTS.md +++ b/internal/examples/AGENTS.md @@ -27,8 +27,8 @@ for where this fits the wider testing strategy. ## Corpus house style -- Data files first, then the storage config, then a schema file if one is kept. -- Name the storage config `.katalyst/storage/my_directory.yaml`. +- Data files first, then the base config, then a schema file if one is kept. +- Name the base config `.katalyst/bases/my_directory.yaml`. - Prefer inline `checks:` over a schema file; keep a schema only when the example is specifically about schema binding. diff --git a/internal/examples/examples.go b/internal/examples/examples.go index 5c34bade..0f09b39c 100644 --- a/internal/examples/examples.go +++ b/internal/examples/examples.go @@ -52,8 +52,8 @@ properties: year: { type: integer } ` -// notesStorage declares a single `notes` collection bound to the book schema. -const notesStorage = `type: filesystem +// notesBase declares a single `notes` collection bound to the book schema. +const notesBase = `type: filesystem root: . collections: notes: @@ -61,10 +61,10 @@ collections: schema: book ` -// notesFieldTypeStorage declares the `notes` collection with an inline +// notesFieldTypeBase declares the `notes` collection with an inline // object_field_type check instead of a schema, so the type-error example needs // no separate schema file and matches the field-type reference page it sits on. -const notesFieldTypeStorage = `type: filesystem +const notesFieldTypeBase = `type: filesystem root: . collections: notes: @@ -85,8 +85,8 @@ properties: status: { enum: [read, reading, to-read] } ` -// wikiStorage binds a `books` collection over the wiki/ tree. -const wikiStorage = `type: filesystem +// wikiBase binds a `books` collection over the wiki/ tree. +const wikiBase = `type: filesystem root: . collections: books: @@ -94,9 +94,9 @@ collections: schema: book ` -// postsRulesStorage is the `posts` collection from the configure-rules how-to: +// postsRulesBase is the `posts` collection from the configure-rules how-to: // the three structural/markdown/filesystem checks that guide attaches. -const postsRulesStorage = `type: filesystem +const postsRulesBase = `type: filesystem root: . collections: posts: @@ -122,9 +122,9 @@ properties: year: { type: integer, minimum: 0 } ` -// booksAtNotesStorage binds the `book` schema to a `books` collection at +// booksAtNotesBase binds the `book` schema to a `books` collection at // notes/books, matching the add-a-schema how-to. -const booksAtNotesStorage = `type: filesystem +const booksAtNotesBase = `type: filesystem root: . collections: books: @@ -132,10 +132,10 @@ collections: schema: book ` -// ciStorage is the small project the validate-in-ci how-to gates: a `notes` +// ciBase is the small project the validate-in-ci how-to gates: a `notes` // collection that only requires an H1, so the failing item fails on structure // alone and the canonical-frontmatter gate is easy to read. -const ciStorage = `type: filesystem +const ciBase = `type: filesystem root: . collections: notes: @@ -156,12 +156,12 @@ var wikiCorpus = []File{ } // withWikiProject appends the .katalyst project files to the wiki corpus so the -// data files lead and the storage config (then the schema) trail in the +// data files lead and the base config (then the schema) trail in the // rendered input. func withWikiProject() []File { out := append([]File{}, wikiCorpus...) return append(out, - File{Path: ".katalyst/storage/my_directory.yaml", Content: wikiStorage}, + File{Path: ".katalyst/bases/my_directory.yaml", Content: wikiBase}, File{Path: ".katalyst/schemas/book.yaml", Content: wikiBookSchema}, ) } @@ -177,7 +177,7 @@ func All() []Example { Weight: 10, Files: []File{ {Path: "notes/dune.md", Content: "---\ntitle: Dune\nyear: 1965\n---\n# Dune\n"}, - {Path: ".katalyst/storage/my_directory.yaml", Content: notesStorage}, + {Path: ".katalyst/bases/my_directory.yaml", Content: notesBase}, {Path: ".katalyst/schemas/book.yaml", Content: bookSchema}, }, Args: []string{"check", "notes/dune"}, @@ -190,7 +190,7 @@ func All() []Example { Weight: 20, Files: []File{ {Path: "notes/dune.md", Content: "---\ntitle: Dune\nyear: \"not a number\"\n---\n# Dune\n"}, - {Path: ".katalyst/storage/my_directory.yaml", Content: notesFieldTypeStorage}, + {Path: ".katalyst/bases/my_directory.yaml", Content: notesFieldTypeBase}, }, Args: []string{"check", "notes/dune"}, }, @@ -202,7 +202,7 @@ func All() []Example { Weight: 30, Files: []File{ {Path: "notes/dune.md", Content: "---\ntitle: Dune\n---\n# Children of Dune\n"}, - {Path: ".katalyst/storage/my_directory.yaml", Content: "type: filesystem\nroot: .\ncollections:\n notes:\n path: notes\n checks:\n - kind: markdown_title_matches_h1\n field: title\n"}, + {Path: ".katalyst/bases/my_directory.yaml", Content: "type: filesystem\nroot: .\ncollections:\n notes:\n path: notes\n checks:\n - kind: markdown_title_matches_h1\n field: title\n"}, }, Args: []string{"check", "notes/dune"}, }, @@ -215,7 +215,7 @@ func All() []Example { ResultFiles: []string{"notes/doc.md"}, Files: []File{ {Path: "notes/doc.md", Content: "---\nzebra: 1\napple: 2\n---\n# Body\nverbatim\n"}, - {Path: ".katalyst/storage/my_directory.yaml", Content: "type: filesystem\nroot: .\ncollections:\n notes:\n path: notes\n checks:\n - kind: markdown_requires_h1\n"}, + {Path: ".katalyst/bases/my_directory.yaml", Content: "type: filesystem\nroot: .\ncollections:\n notes:\n path: notes\n checks:\n - kind: markdown_requires_h1\n"}, }, Args: []string{"fix", "notes/doc"}, }, @@ -228,7 +228,7 @@ func All() []Example { ResultFiles: []string{"notes/doc.md"}, Files: []File{ {Path: "notes/doc.md", Content: "---\nt: 1\n---\n# Title.\nkeep this.\n"}, - {Path: ".katalyst/storage/my_directory.yaml", Content: "type: filesystem\nroot: .\ncollections:\n notes:\n path: notes\n checks:\n - kind: text_forbids\n target: first-line\n pattern: '\\.(\\s*)$'\n fix: '$1'\n"}, + {Path: ".katalyst/bases/my_directory.yaml", Content: "type: filesystem\nroot: .\ncollections:\n notes:\n path: notes\n checks:\n - kind: text_forbids\n target: first-line\n pattern: '\\.(\\s*)$'\n fix: '$1'\n"}, }, Args: []string{"fix", "notes/doc"}, }, @@ -259,7 +259,7 @@ func All() []Example { Files: []File{ {Path: "notes/books/dune.md", Content: "---\ntitle: Dune\nyear: 1965\n---\n# Dune\n"}, {Path: "notes/books/foundation.md", Content: "---\ntitle: Foundation\n---\n# Foundation\n"}, - {Path: ".katalyst/storage/my_directory.yaml", Content: booksAtNotesStorage}, + {Path: ".katalyst/bases/my_directory.yaml", Content: booksAtNotesBase}, {Path: ".katalyst/schemas/book.yaml", Content: bookConstrainedSchema}, }, Args: []string{"check", "books"}, @@ -273,7 +273,7 @@ func All() []Example { Files: []File{ {Path: "content/posts/hello-world.md", Content: "---\ntitle: Hello world\n---\n# Hello world\n"}, {Path: "content/posts/Bad_Title.md", Content: "---\ntitle: Bad title\n---\n# A different heading\n"}, - {Path: ".katalyst/storage/my_directory.yaml", Content: postsRulesStorage}, + {Path: ".katalyst/bases/my_directory.yaml", Content: postsRulesBase}, }, Args: []string{"check", "posts"}, }, @@ -286,7 +286,7 @@ func All() []Example { Files: []File{ {Path: "notes/intro.md", Content: "---\ntitle: Intro\n---\n# Intro\n"}, {Path: "notes/draft.md", Content: "---\ntitle: Draft\n---\nNo heading here.\n"}, - {Path: ".katalyst/storage/my_directory.yaml", Content: ciStorage}, + {Path: ".katalyst/bases/my_directory.yaml", Content: ciBase}, }, Args: []string{"check"}, }, @@ -299,7 +299,7 @@ func All() []Example { Files: []File{ {Path: "notes/tidy.md", Content: "---\ntitle: Tidy\n---\n# Tidy\n"}, {Path: "notes/messy.md", Content: "---\ntitle: Messy\nauthor: Ada\n---\n# Messy\n"}, - {Path: ".katalyst/storage/my_directory.yaml", Content: ciStorage}, + {Path: ".katalyst/bases/my_directory.yaml", Content: ciBase}, }, Args: []string{"fix", "--check"}, }, diff --git a/internal/examples/testdata/check-collection-rules.md b/internal/examples/testdata/check-collection-rules.md index 864a7ee4..06b84140 100644 --- a/internal/examples/testdata/check-collection-rules.md +++ b/internal/examples/testdata/check-collection-rules.md @@ -20,7 +20,7 @@ title: Bad title # A different heading ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/internal/examples/testdata/check-schema-missing-field.md b/internal/examples/testdata/check-schema-missing-field.md index b4577844..297d0f97 100644 --- a/internal/examples/testdata/check-schema-missing-field.md +++ b/internal/examples/testdata/check-schema-missing-field.md @@ -21,7 +21,7 @@ title: Foundation # Foundation ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/internal/examples/testdata/check-title-h1-mismatch.md b/internal/examples/testdata/check-title-h1-mismatch.md index fbfda9cc..328ee6eb 100644 --- a/internal/examples/testdata/check-title-h1-mismatch.md +++ b/internal/examples/testdata/check-title-h1-mismatch.md @@ -11,7 +11,7 @@ title: Dune # Children of Dune ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/internal/examples/testdata/check-type-error.md b/internal/examples/testdata/check-type-error.md index 60d4f9a0..676d9529 100644 --- a/internal/examples/testdata/check-type-error.md +++ b/internal/examples/testdata/check-type-error.md @@ -12,7 +12,7 @@ year: "not a number" # Dune ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/internal/examples/testdata/check-valid-item.md b/internal/examples/testdata/check-valid-item.md index a53b53ff..5f20a45c 100644 --- a/internal/examples/testdata/check-valid-item.md +++ b/internal/examples/testdata/check-valid-item.md @@ -12,7 +12,7 @@ year: 1965 # Dune ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/internal/examples/testdata/ci-check-fails.md b/internal/examples/testdata/ci-check-fails.md index 016bc081..71d2bec3 100644 --- a/internal/examples/testdata/ci-check-fails.md +++ b/internal/examples/testdata/ci-check-fails.md @@ -20,7 +20,7 @@ title: Draft No heading here. ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/internal/examples/testdata/ci-fix-check.md b/internal/examples/testdata/ci-fix-check.md index 1d7170f8..8c138cfe 100644 --- a/internal/examples/testdata/ci-fix-check.md +++ b/internal/examples/testdata/ci-fix-check.md @@ -21,7 +21,7 @@ author: Ada # Messy ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/internal/examples/testdata/fix-normalize-frontmatter.md b/internal/examples/testdata/fix-normalize-frontmatter.md index d24a4a38..e67d1b23 100644 --- a/internal/examples/testdata/fix-normalize-frontmatter.md +++ b/internal/examples/testdata/fix-normalize-frontmatter.md @@ -13,7 +13,7 @@ apple: 2 verbatim ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/internal/examples/testdata/fix-text-forbids.md b/internal/examples/testdata/fix-text-forbids.md index 8d42880e..e600f7ec 100644 --- a/internal/examples/testdata/fix-text-forbids.md +++ b/internal/examples/testdata/fix-text-forbids.md @@ -12,7 +12,7 @@ t: 1 keep this. ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/internal/examples/testdata/inspect-collection-fields.md b/internal/examples/testdata/inspect-collection-fields.md index e3895527..7c7adb03 100644 --- a/internal/examples/testdata/inspect-collection-fields.md +++ b/internal/examples/testdata/inspect-collection-fields.md @@ -65,7 +65,7 @@ status: read # Dune Messiah ``` -`.katalyst/storage/my_directory.yaml` +`.katalyst/bases/my_directory.yaml` ```yaml type: filesystem diff --git a/internal/inspect/collection_test.go b/internal/inspect/collection_test.go index 77b35fd7..2940bcba 100644 --- a/internal/inspect/collection_test.go +++ b/internal/inspect/collection_test.go @@ -12,7 +12,7 @@ import ( func TestCollectionView_objectFieldsAndMarkdownBody(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "storage/local.yaml": projecttest.LocalStorage(map[string]string{ + "bases/local.yaml": projecttest.LocalBase(map[string]string{ "notes": "path: notes\nchecks:\n - kind: markdown_requires_h1\n", }), }) diff --git a/internal/inspect/inspect.go b/internal/inspect/inspect.go index 75113723..23838490 100644 --- a/internal/inspect/inspect.go +++ b/internal/inspect/inspect.go @@ -30,11 +30,11 @@ type CollectionInspector interface { // SourceInspector measures a raw backend store before any collection // configuration, addressed by backend-native reference (a path today) through a // SourceView. AppliesTo gates backend-specific inspectors: one returns false for -// a StorageType it cannot describe, so it is simply absent there. It is the +// a BaseType it cannot describe, so it is simply absent there. It is the // raw-source half of the two-layer model; the collection half is // CollectionInspector. type SourceInspector interface { Name() string - AppliesTo(storage.StorageType) bool + AppliesTo(storage.BaseType) bool Inspect(SourceView, Params) Evidence } diff --git a/internal/inspect/inspectors_source.go b/internal/inspect/inspectors_source.go index 857de8ab..b47be65a 100644 --- a/internal/inspect/inspectors_source.go +++ b/internal/inspect/inspectors_source.go @@ -14,7 +14,7 @@ type FileTree struct{} func (FileTree) Name() string { return "file_tree" } -func (FileTree) AppliesTo(t storage.StorageType) bool { return t == storage.Filesystem } +func (FileTree) AppliesTo(t storage.BaseType) bool { return t == storage.Filesystem } func (FileTree) Inspect(v SourceView, p Params) Evidence { byDir := v.refsByDir() @@ -33,7 +33,7 @@ type FileTreeContent struct{} func (FileTreeContent) Name() string { return "file_tree_content" } -func (FileTreeContent) AppliesTo(t storage.StorageType) bool { return t == storage.Filesystem } +func (FileTreeContent) AppliesTo(t storage.BaseType) bool { return t == storage.Filesystem } func (FileTreeContent) Inspect(v SourceView, p Params) Evidence { byDir := map[string][]sourceDoc{} @@ -55,7 +55,7 @@ type DocumentShape struct{} func (DocumentShape) Name() string { return "document_shape" } -func (DocumentShape) AppliesTo(t storage.StorageType) bool { return t == storage.Filesystem } +func (DocumentShape) AppliesTo(t storage.BaseType) bool { return t == storage.Filesystem } func (DocumentShape) Inspect(v SourceView, p Params) Evidence { docs := v.markdown() diff --git a/internal/inspect/source.go b/internal/inspect/source.go index 817b947d..8647b98d 100644 --- a/internal/inspect/source.go +++ b/internal/inspect/source.go @@ -37,8 +37,8 @@ type mdCache struct { // walked once into per-file metadata, addressed by backend-native reference // (the relative path). Path-level inspectors (file_tree) read only this // metadata and open no files; content inspectors trigger a one-time markdown -// parse. Filesystem-only for now; generalizing the walk into the storage layer -// is future work. +// parse. Filesystem-only for now; generalizing the walk across base types is +// future work. type SourceView struct { root string files []sourceFile diff --git a/internal/project/AGENTS.md b/internal/project/AGENTS.md index 2f528793..859760ad 100644 --- a/internal/project/AGENTS.md +++ b/internal/project/AGENTS.md @@ -1,21 +1,20 @@ # internal/project -The project domain layer: finds `.katalyst/`, loads schemas and storage -instances, exposes collections, resolves selectors, and enumerates concrete -items for the CLI. +The project domain layer: finds `.katalyst/`, loads schemas and bases, exposes +collections, resolves selectors, and enumerates concrete items for the CLI. Architecture and rationale live in the [domain model](../../docs/content/deep-dives/domain-model/_index.md), [configuration](../../docs/content/reference/configuration.md), and -[storage](../../docs/content/deep-dives/domain-model/storage.md) docs. This file keeps only +[Bases](../../docs/content/deep-dives/domain-model/storage.md) docs. This file keeps only local code conventions. ## Conventions - The loader owns the `.katalyst/` vocabulary: discovery mode, config format, - schema names, storage instance names, collection uniqueness, and selector + schema names, base names, collection uniqueness, and selector parsing. Do not duplicate that parsing in `cmd/`. -- Storage and collection details stay below the storage boundary. This package +- Base and collection details stay below the storage boundary. This package assembles `storage/collection.Collection` values and calls a `CollectionDefinition`; it should not inline globbing, path joins, or filename-as-id assumptions. diff --git a/internal/project/loader.go b/internal/project/loader.go index c04f844c..f354fd90 100644 --- a/internal/project/loader.go +++ b/internal/project/loader.go @@ -2,16 +2,16 @@ // and answers two questions: // // 1. Which schemas exist (by name → absolute file path)? -// 2. Which storage instances exist, what collections does each declare, and +// 2. Which bases exist, what collections does each declare, and // what checks does each collection run? // // A project is the nearest ancestor directory that contains a .katalyst/ // subdirectory. Schemas are defined one named file per definition under -// .katalyst/schemas/; storage instances one named file per instance under -// .katalyst/storage/ (discovery: convention, the default), or listed -// explicitly in .katalyst/config.yaml (discovery: explicit). A storage -// instance embeds the collections it maps. The file format (yaml, json, or -// both) is set per kind in config.yaml. See +// .katalyst/schemas/; bases are defined one named file per definition under +// .katalyst/bases/ (discovery: convention, the default), or listed explicitly +// in .katalyst/config.yaml (discovery: explicit). A base embeds the collections +// it maps. Legacy projects may still use storage: and .katalyst/storage/. The +// file format (yaml, json, or both) is set per kind in config.yaml. See // docs/content/reference/configuration.md. package project @@ -38,6 +38,7 @@ const configFile = "config.yaml" // Subdirectories of Dir holding one named file per definition. const ( schemasSubdir = "schemas" + basesSubdir = "bases" storageSubdir = "storage" ) @@ -62,28 +63,28 @@ type Config struct { Root string // Schemas is name → absolute path. Schemas map[string]string - // Storage holds the configured storage instances, in name order. Each - // instance declares its own collections. - Storage []StorageInstance - // Collections is the flattened view across all instances, in name order. - // Collection names are unique project-wide (selectors carry no instance + // Bases holds the configured bases, in name order. Each base declares its + // own collections. + Bases []BaseInstance + // Collections is the flattened view across all bases, in name order. + // Collection names are unique project-wide (selectors carry no base // qualifier), so this is the canonical lookup most callers use. Collections []Collection } -// StorageInstance is one configured backend store plus the collections it maps -// onto the domain model. For StorageType filesystem, Root is a directory. -type StorageInstance struct { - // Name is the public handle (filename stem under .katalyst/storage/, or - // the key in the inline `storage.defs` map). +// BaseInstance is one configured backend store plus the collections it maps +// onto the domain model. For BaseType filesystem, Root is a directory. +type BaseInstance struct { + // Name is the public handle (filename stem under .katalyst/bases/, or + // the key in the inline `bases.defs` map). Name string // Type is the backend kind, validated against the storage registry // (storage.Known). Type string - // Root is the absolute, resolved instance root. Relative roots in the + // Root is the absolute, resolved base root. Relative roots in the // source resolve against the repo Root. Root string - // Collections this instance declares, in name order. + // Collections this base declares, in name order. Collections []Collection } @@ -103,7 +104,8 @@ type ( // default YAML format. type rawConfig struct { Schemas rawSchemaKind `yaml:"schemas"` - Storage rawStorageKind `yaml:"storage"` + Bases *rawBaseKind `yaml:"bases"` + Storage *rawBaseKind `yaml:"storage"` Listing *collection.RawListingDefaults `yaml:"listing"` Query *collection.RawListingDefaults `yaml:"query"` } @@ -116,18 +118,18 @@ type rawSchemaKind struct { Defs map[string]string `yaml:"defs"` } -// rawStorageKind configures how storage instances are discovered. Defs is -// consulted only when Discovery is "explicit" (name → instance). -type rawStorageKind struct { - Discovery string `yaml:"discovery"` - Format string `yaml:"format"` - Defs map[string]rawStorageInstance `yaml:"defs"` +// rawBaseKind configures how bases are discovered. Defs is consulted only when +// Discovery is "explicit" (name → base). +type rawBaseKind struct { + Discovery string `yaml:"discovery"` + Format string `yaml:"format"` + Defs map[string]rawBaseInstance `yaml:"defs"` } -// rawStorageInstance mirrors one storage instance: its backend type, its root, -// and the collections it declares (name → definition). The collection mirror -// lives with the Collection type in internal/storage/collection. -type rawStorageInstance struct { +// rawBaseInstance mirrors one base: its backend type, its root, and the +// collections it declares (name → definition). The collection mirror lives with +// the Collection type in internal/storage/collection. +type rawBaseInstance struct { Type string `yaml:"type"` Root string `yaml:"root"` Collections map[string]collection.RawCollection `yaml:"collections"` @@ -161,7 +163,7 @@ func Load(start string) (*Config, error) { if raw.Query != nil { return nil, errors.New("config: query is no longer a config block; use listing") } - if err := cfg.loadStorage(raw.Storage, raw.Listing); err != nil { + if err := cfg.loadBases(raw.Bases, raw.Storage, raw.Listing); err != nil { return nil, err } return cfg, nil @@ -215,39 +217,57 @@ func (c *Config) loadSchemas(k rawSchemaKind) error { return nil } -// loadStorage populates c.Storage and the flattened c.Collections (both sorted -// by name) from either the storage directory (convention: one file per -// instance) or an explicit defs map in config.yaml. Collection names are -// validated unique across every instance. -func (c *Config) loadStorage(k rawStorageKind, projectListing *collection.RawListingDefaults) error { +// loadBases populates c.Bases and the flattened c.Collections (both sorted by +// name) from either the bases directory (convention: one file per base) or an +// explicit defs map in config.yaml. Collection names are validated unique +// across every base. The legacy storage block and directory stay readable, but +// cannot be mixed with the new bases form. +func (c *Config) loadBases(bases, legacy *rawBaseKind, projectListing *collection.RawListingDefaults) error { + if bases != nil && legacy != nil { + return errors.New("config: use bases, not both bases and storage") + } + label := "bases" + k := rawBaseKind{} + if bases != nil { + k = *bases + } else if legacy != nil { + k = *legacy + label = "storage" + } + discovery, err := normDiscovery(k.Discovery) if err != nil { - return fmt.Errorf("storage: %w", err) + return fmt.Errorf("%s: %w", label, err) } exts, err := formatExts(k.Format) if err != nil { - return fmt.Errorf("storage: %w", err) + return fmt.Errorf("%s: %w", label, err) + } + + baseSubdir, err := c.baseSubdir(label) + if err != nil { + return err } - defs := map[string]rawStorageInstance{} + defs := map[string]rawBaseInstance{} if discovery == discoveryExplicit { if len(k.Defs) == 0 { - return errors.New(`storage: discovery "explicit" requires a non-empty "defs" map`) + return fmt.Errorf(`%s: discovery "explicit" requires a non-empty "defs" map`, label) } defs = k.Defs } else { - found, err := scanKindDir(filepath.Join(c.Root, Dir, storageSubdir), exts) + found, err := scanKindDir(filepath.Join(c.Root, Dir, baseSubdir), exts) if err != nil { - return fmt.Errorf("storage: %w", err) + return fmt.Errorf("%s: %w", label, err) } for name, path := range found { src, err := os.ReadFile(path) if err != nil { - return fmt.Errorf("storage %q: %w", name, err) + return fmt.Errorf("%s %q: %w", label, name, err) } - var ri rawStorageInstance + var ri rawBaseInstance if err := yaml.Unmarshal(src, &ri); err != nil { - return fmt.Errorf("storage %q: %w", name, err) + return fmt.Errorf("%s %q: %w", label, name, err) } defs[name] = ri } @@ -259,22 +279,22 @@ func (c *Config) loadStorage(k rawStorageKind, projectListing *collection.RawLis } sort.Strings(names) - // instanceOf records which instance first claimed a collection name, so a - // collision across instances is reported with both sides. - instanceOf := map[string]string{} + // baseOf records which base first claimed a collection name, so a collision + // across bases is reported with both sides. + baseOf := map[string]string{} for _, name := range names { - inst, err := c.buildInstance(name, defs[name], exts, projectListing) + inst, err := c.buildInstance(name, defs[name], exts, projectListing, baseSubdir, label) if err != nil { return err } for _, col := range inst.Collections { - if prev, dup := instanceOf[col.Name]; dup { - return fmt.Errorf("collection %q is declared by two storage instances (%q and %q); collection names must be unique across the project", col.Name, prev, name) + if prev, dup := baseOf[col.Name]; dup { + return fmt.Errorf("collection %q is declared by two bases (%q and %q); collection names must be unique across the project", col.Name, prev, name) } - instanceOf[col.Name] = name + baseOf[col.Name] = name c.Collections = append(c.Collections, col) } - c.Storage = append(c.Storage, inst) + c.Bases = append(c.Bases, inst) } sort.Slice(c.Collections, func(i, j int) bool { return c.Collections[i].Name < c.Collections[j].Name @@ -282,20 +302,46 @@ func (c *Config) loadStorage(k rawStorageKind, projectListing *collection.RawLis return nil } -// buildInstance turns one raw storage instance into a validated -// StorageInstance, building each of its collections against the instance root. -// Collections come from the instance's inline `collections:` block and, as an -// escape hatch for instances that outgrow inline, from one file per collection -// under .katalyst/storage//. A name declared in both places is an error. -// The instance name comes from the source (filename stem or map key), never the -// body. -func (c *Config) buildInstance(name string, ri rawStorageInstance, exts []string, projectListing *collection.RawListingDefaults) (StorageInstance, error) { +// baseSubdir chooses the directory that holds base definition files. New config +// uses .katalyst/bases/. Legacy .katalyst/storage/ remains readable, but the +// two directories cannot be mixed. +func (c *Config) baseSubdir(label string) (string, error) { + hasBases, err := dirExists(filepath.Join(c.Root, Dir, basesSubdir)) + if err != nil { + return "", fmt.Errorf("bases: %w", err) + } + hasStorage, err := dirExists(filepath.Join(c.Root, Dir, storageSubdir)) + if err != nil { + return "", fmt.Errorf("storage: %w", err) + } + if hasBases && hasStorage { + return "", errors.New("config: use .katalyst/bases, not both .katalyst/bases and .katalyst/storage") + } + if hasBases { + return basesSubdir, nil + } + if hasStorage { + return storageSubdir, nil + } + if label == "storage" { + return storageSubdir, nil + } + return basesSubdir, nil +} + +// buildInstance turns one raw base into a validated +// BaseInstance, building each of its collections against the base root. +// Collections come from the base's inline `collections:` block and, as an +// escape hatch for bases that outgrow inline, from one file per collection +// under .katalyst/bases//. A name declared in both places is an error. +// The base name comes from the source (filename stem or map key), never the body. +func (c *Config) buildInstance(name string, ri rawBaseInstance, exts []string, projectListing *collection.RawListingDefaults, baseSubdir, label string) (BaseInstance, error) { typ := ri.Type if typ == "" { typ = string(storage.Filesystem) } - if !storage.Known(storage.StorageType(typ)) { - return StorageInstance{}, fmt.Errorf("storage %q: unknown type %q", name, ri.Type) + if !storage.Known(storage.BaseType(typ)) { + return BaseInstance{}, fmt.Errorf("%s %q: unknown type %q", label, name, ri.Type) } rootRel := ri.Root @@ -309,22 +355,22 @@ func (c *Config) buildInstance(name string, ri rawStorageInstance, exts []string for cn, rc := range ri.Collections { raws[cn] = rc } - instDir := filepath.Join(c.Root, Dir, storageSubdir, name) + instDir := filepath.Join(c.Root, Dir, baseSubdir, name) found, err := scanKindDir(instDir, exts) if err != nil { - return StorageInstance{}, fmt.Errorf("storage %q: %w", name, err) + return BaseInstance{}, fmt.Errorf("%s %q: %w", label, name, err) } for cn, path := range found { if _, dup := raws[cn]; dup { - return StorageInstance{}, fmt.Errorf("storage %q: collection %q is declared both inline and in a file", name, cn) + return BaseInstance{}, fmt.Errorf("%s %q: collection %q is declared both inline and in a file", label, name, cn) } src, err := os.ReadFile(path) if err != nil { - return StorageInstance{}, fmt.Errorf("storage %q: collection %q: %w", name, cn, err) + return BaseInstance{}, fmt.Errorf("%s %q: collection %q: %w", label, name, cn, err) } var rc collection.RawCollection if err := yaml.Unmarshal(src, &rc); err != nil { - return StorageInstance{}, fmt.Errorf("storage %q: collection %q: %w", name, cn, err) + return BaseInstance{}, fmt.Errorf("%s %q: collection %q: %w", label, name, cn, err) } raws[cn] = rc } @@ -340,17 +386,28 @@ func (c *Config) buildInstance(name string, ri rawStorageInstance, exts []string col, err := collection.Build(collection.BuildInput{ Name: cn, Raw: raws[cn], - InstRoot: instRoot, - InstName: name, + BaseRoot: instRoot, + BaseName: name, ProjectListing: projectListing, SchemaKnown: c.schemaKnown, }) if err != nil { - return StorageInstance{}, err + return BaseInstance{}, err } cols = append(cols, col) } - return StorageInstance{Name: name, Type: typ, Root: instRoot, Collections: cols}, nil + return BaseInstance{Name: name, Type: typ, Root: instRoot, Collections: cols}, nil +} + +func dirExists(dir string) (bool, error) { + info, err := os.Stat(dir) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return info.IsDir(), nil } // schemaKnown reports whether a schema name is defined. The collection builder diff --git a/internal/project/loader_test.go b/internal/project/loader_test.go index c0d4ea36..ffe498bf 100644 --- a/internal/project/loader_test.go +++ b/internal/project/loader_test.go @@ -27,7 +27,7 @@ func TestLoad_convention_discoversSchemasAndCollections(t *testing.T) { projecttest.WriteProject(t, dir, map[string]string{ "schemas/book.yaml": projecttest.MinimalSchema, "schemas/person.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{ + "bases/local.yaml": projecttest.LocalBase(map[string]string{ "books": "path: notes/books\nschema: book\n", "people": "path: notes/people\npattern: \"*.markdown\"\nschema: person\n", }), @@ -49,12 +49,12 @@ func TestLoad_convention_discoversSchemasAndCollections(t *testing.T) { t.Errorf("SchemaPath(book) = %q, want %q", got, want) } - // One filesystem instance named "local". - if len(cfg.Storage) != 1 || cfg.Storage[0].Name != "local" || cfg.Storage[0].Type != "filesystem" { - t.Fatalf("expected one filesystem instance 'local', got %+v", cfg.Storage) + // One filesystem base named "local". + if len(cfg.Bases) != 1 || cfg.Bases[0].Name != "local" || cfg.Bases[0].Type != "filesystem" { + t.Fatalf("expected one filesystem base 'local', got %+v", cfg.Bases) } - if cfg.Storage[0].Root != wantRoot { - t.Errorf("instance Root = %q, want %q", cfg.Storage[0].Root, wantRoot) + if cfg.Bases[0].Root != wantRoot { + t.Errorf("base Root = %q, want %q", cfg.Bases[0].Root, wantRoot) } // Collections are flattened and sorted by name: books, people. @@ -69,8 +69,8 @@ func TestLoad_convention_discoversSchemasAndCollections(t *testing.T) { if books.Schema != "book" { t.Errorf("books.Schema = %q, want book", books.Schema) } - if books.Storage != "local" { - t.Errorf("books.Storage = %q, want local", books.Storage) + if books.Base != "local" { + t.Errorf("books.Base = %q, want local", books.Base) } if books.Pattern != "*.md" { t.Errorf("books.Pattern = %q, want default *.md", books.Pattern) @@ -94,8 +94,8 @@ func TestLoad_convention_discoversSchemasAndCollections(t *testing.T) { func TestLoad_defaultsPathToCollectionName(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "schema: book\n"}), + "schemas/book.yaml": projecttest.MinimalSchema, + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "schema: book\n"}), }) cfg, err := project.Load(dir) if err != nil { @@ -108,11 +108,11 @@ func TestLoad_defaultsPathToCollectionName(t *testing.T) { } func TestLoad_instanceRoot_resolvesCollectionDirs(t *testing.T) { - // A non-default instance root is the base for its collections' Dir. + // A non-default base root is the base for its collections' Dir. dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ "schemas/book.yaml": projecttest.MinimalSchema, - "storage/vault.yaml": "type: filesystem\nroot: content\ncollections:\n" + + "bases/vault.yaml": "type: filesystem\nroot: content\ncollections:\n" + " notes:\n path: notes\n schema: book\n", }) cfg, err := project.Load(dir) @@ -121,19 +121,19 @@ func TestLoad_instanceRoot_resolvesCollectionDirs(t *testing.T) { } notes, _ := cfg.Collection("notes") if want := filepath.Join(projecttest.RealPath(t, dir), "content/notes"); notes.Dir != want { - t.Errorf("notes.Dir = %q, want %q (resolved against instance root)", notes.Dir, want) + t.Errorf("notes.Dir = %q, want %q (resolved against base root)", notes.Dir, want) } } func TestLoad_perCollectionFiles_inInstanceDir(t *testing.T) { - // A collection may live in its own file under storage//, the - // escape hatch for instances that outgrow an inline block. + // A collection may live in its own file under bases//, the escape + // hatch for bases that outgrow an inline block. dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": "type: filesystem\nroot: .\ncollections: {}\n", - "storage/local/books.yaml": "path: notes/books\nschema: book\n", - "storage/local/people.yaml": "path: notes/people\nschema: book\n", + "schemas/book.yaml": projecttest.MinimalSchema, + "bases/local.yaml": "type: filesystem\nroot: .\ncollections: {}\n", + "bases/local/books.yaml": "path: notes/books\nschema: book\n", + "bases/local/people.yaml": "path: notes/people\nschema: book\n", }) cfg, err := project.Load(dir) if err != nil { @@ -143,17 +143,17 @@ func TestLoad_perCollectionFiles_inInstanceDir(t *testing.T) { t.Fatalf("CollectionNames = %v, want [books people]", got) } books, _ := cfg.Collection("books") - if books.Storage != "local" { - t.Errorf("books.Storage = %q, want local", books.Storage) + if books.Base != "local" { + t.Errorf("books.Base = %q, want local", books.Base) } } func TestLoad_perCollectionFiles_coexistWithInline(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"books": "path: notes/books\nschema: book\n"}), - "storage/local/notes.yaml": "path: notes\nschema: book\n", + "schemas/book.yaml": projecttest.MinimalSchema, + "bases/local.yaml": projecttest.LocalBase(map[string]string{"books": "path: notes/books\nschema: book\n"}), + "bases/local/notes.yaml": "path: notes\nschema: book\n", }) cfg, err := project.Load(dir) if err != nil { @@ -167,9 +167,9 @@ func TestLoad_perCollectionFiles_coexistWithInline(t *testing.T) { func TestLoad_perCollectionFiles_rejectInlineCollision(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "path: notes\nschema: book\n"}), - "storage/local/notes.yaml": "path: other\nschema: book\n", + "schemas/book.yaml": projecttest.MinimalSchema, + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "path: notes\nschema: book\n"}), + "bases/local/notes.yaml": "path: other\nschema: book\n", }) _, err := project.Load(dir) if err == nil || !strings.Contains(err.Error(), "both inline and in a file") { @@ -195,9 +195,9 @@ func TestLoad_ascendsToFindProject(t *testing.T) { } } -func TestLoad_noStorage_isEmptyButValid(t *testing.T) { - // A project with schemas but no storage instances loads with zero - // collections. There is no implicit instance synthesized. +func TestLoad_noBases_isEmptyButValid(t *testing.T) { + // A project with schemas but no bases loads with zero + // collections. There is no implicit base synthesized. dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ "schemas/book.yaml": projecttest.MinimalSchema, @@ -206,8 +206,8 @@ func TestLoad_noStorage_isEmptyButValid(t *testing.T) { if err != nil { t.Fatalf("Load: %v", err) } - if len(cfg.Storage) != 0 { - t.Errorf("expected no storage instances, got %d", len(cfg.Storage)) + if len(cfg.Bases) != 0 { + t.Errorf("expected no bases, got %d", len(cfg.Bases)) } if len(cfg.Collections) != 0 { t.Errorf("expected no collections, got %d", len(cfg.Collections)) @@ -219,8 +219,8 @@ func TestLoad_noConfigFile_usesConventionDefaults(t *testing.T) { // default convention + yaml discovery. dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "schema: book\n"}), + "schemas/book.yaml": projecttest.MinimalSchema, + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "schema: book\n"}), }) cfg, err := project.Load(dir) if err != nil { @@ -239,10 +239,10 @@ func TestLoad_notFound(t *testing.T) { } } -func TestLoad_rejectsUnknownStorageType(t *testing.T) { +func TestLoad_rejectsUnknownBaseType(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "storage/db.yaml": "type: sqlite\ncollections:\n notes:\n path: notes\n checks:\n - kind: markdown_requires_h1\n", + "bases/db.yaml": "type: sqlite\ncollections:\n notes:\n path: notes\n checks:\n - kind: markdown_requires_h1\n", }) _, err := project.Load(dir) if err == nil || !strings.Contains(err.Error(), "unknown type") { @@ -253,8 +253,8 @@ func TestLoad_rejectsUnknownStorageType(t *testing.T) { func TestLoad_rejectsDuplicateCollectionAcrossInstances(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "storage/a.yaml": "type: filesystem\ncollections:\n notes:\n path: a\n checks:\n - kind: markdown_requires_h1\n", - "storage/b.yaml": "type: filesystem\ncollections:\n notes:\n path: b\n checks:\n - kind: markdown_requires_h1\n", + "bases/a.yaml": "type: filesystem\ncollections:\n notes:\n path: a\n checks:\n - kind: markdown_requires_h1\n", + "bases/b.yaml": "type: filesystem\ncollections:\n notes:\n path: b\n checks:\n - kind: markdown_requires_h1\n", }) _, err := project.Load(dir) if err == nil || !strings.Contains(err.Error(), "unique") { @@ -265,8 +265,8 @@ func TestLoad_rejectsDuplicateCollectionAcrossInstances(t *testing.T) { func TestLoad_rejectsUnknownSchemaInCollection(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "path: notes\nschema: nonexistent\n"}), + "schemas/book.yaml": projecttest.MinimalSchema, + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "path: notes\nschema: nonexistent\n"}), }) _, err := project.Load(dir) if err == nil { @@ -280,7 +280,7 @@ func TestLoad_rejectsUnknownSchemaInCollection(t *testing.T) { func TestLoad_rejectsCollectionWithNoChecks(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "path: notes\n"}), + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "path: notes\n"}), }) _, err := project.Load(dir) if err == nil { @@ -306,7 +306,7 @@ func TestLoad_variantsParsed(t *testing.T) { "schemas/page.yaml": projecttest.MinimalSchema, "schemas/section.yaml": projecttest.MinimalSchema, "schemas/content.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"pages": body}), + "bases/local.yaml": projecttest.LocalBase(map[string]string{"pages": body}), }) cfg, err := project.Load(dir) @@ -358,8 +358,8 @@ func TestLoad_whenShorthandDesugars(t *testing.T) { " checks:\n" + " - kind: markdown_requires_h1\n" projecttest.WriteProject(t, dir, map[string]string{ - "schemas/page.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"pages": body}), + "schemas/page.yaml": projecttest.MinimalSchema, + "bases/local.yaml": projecttest.LocalBase(map[string]string{"pages": body}), }) cfg, err := project.Load(dir) if err != nil { @@ -386,7 +386,7 @@ func TestLoad_variantOnlyCollectionIsValid(t *testing.T) { " checks:\n" + " - kind: markdown_requires_h1\n" projecttest.WriteProject(t, dir, map[string]string{ - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"pages": body}), + "bases/local.yaml": projecttest.LocalBase(map[string]string{"pages": body}), }) if _, err := project.Load(dir); err != nil { t.Fatalf("variant-only collection should load: %v", err) @@ -399,8 +399,8 @@ func TestLoad_rejectsInvalidVariantPredicate(t *testing.T) { "variants:\n" + " - when: \"=nofield\"\n" projecttest.WriteProject(t, dir, map[string]string{ - "schemas/page.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"pages": body}), + "schemas/page.yaml": projecttest.MinimalSchema, + "bases/local.yaml": projecttest.LocalBase(map[string]string{"pages": body}), }) _, err := project.Load(dir) if err == nil || !strings.Contains(err.Error(), "variants[0]") { @@ -415,8 +415,8 @@ func TestLoad_rejectsUnknownVariantSchema(t *testing.T) { " - when: \"kind=section\"\n" + " schema: nonexistent\n" projecttest.WriteProject(t, dir, map[string]string{ - "schemas/page.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"pages": body}), + "schemas/page.yaml": projecttest.MinimalSchema, + "bases/local.yaml": projecttest.LocalBase(map[string]string{"pages": body}), }) _, err := project.Load(dir) if err == nil || !strings.Contains(err.Error(), "nonexistent") { @@ -435,8 +435,8 @@ func TestLoad_rejectsEmptyWhen(t *testing.T) { " checks:\n" + " - kind: markdown_requires_h1\n" projecttest.WriteProject(t, dir, map[string]string{ - "schemas/page.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"pages": body}), + "schemas/page.yaml": projecttest.MinimalSchema, + "bases/local.yaml": projecttest.LocalBase(map[string]string{"pages": body}), }) _, err := project.Load(dir) if err == nil || !strings.Contains(err.Error(), "at least one predicate") { @@ -447,8 +447,8 @@ func TestLoad_rejectsEmptyWhen(t *testing.T) { func TestLoad_useExhaustiveVariantsDefaultsFalse(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "path: notes\nschema: book\n"}), + "schemas/book.yaml": projecttest.MinimalSchema, + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "path: notes\nschema: book\n"}), }) cfg, err := project.Load(dir) if err != nil { @@ -467,7 +467,7 @@ func TestLoad_parsesChecks(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": `path: notes + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": `path: notes checks: - kind: object schema: book @@ -540,7 +540,7 @@ func TestLoad_rejectsUnknownCheckType(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": `path: notes + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": `path: notes checks: - kind: not-real `}), @@ -557,7 +557,7 @@ checks: func TestLoad_rejectsUnknownCheckKey(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": `path: notes + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": `path: notes checks: - kind: markdown_requires_h1 typo: true @@ -576,7 +576,7 @@ func TestLoad_rejectsMalformedCheckPayload(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": `path: notes + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": `path: notes checks: - kind: object `}), @@ -594,7 +594,7 @@ func TestLoad_rejectsObjectCheckField(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": `path: notes + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": `path: notes checks: - kind: object schema: book @@ -647,7 +647,7 @@ func TestLoad_rejectsInvalidFilesystemCheckConfig(t *testing.T) { t.Run(name, func(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "path: notes\nchecks:\n" + tc.checks}), + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "path: notes\nchecks:\n" + tc.checks}), }) _, err := project.Load(dir) if err == nil { @@ -663,7 +663,7 @@ func TestLoad_rejectsInvalidFilesystemCheckConfig(t *testing.T) { func TestLoad_parsesTextChecks(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": `path: notes + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": `path: notes checks: - kind: text_requires pattern: Sources @@ -746,7 +746,7 @@ func TestLoad_rejectsInvalidTextCheckConfig(t *testing.T) { t.Run(name, func(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "path: notes\nchecks:\n" + tc.checks}), + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "path: notes\nchecks:\n" + tc.checks}), }) _, err := project.Load(dir) if err == nil { @@ -769,7 +769,7 @@ func TestLoad_explicitDiscovery_readsDefs(t *testing.T) { discovery: explicit defs: book: ./.katalyst/my-schemas/book.yaml -storage: +bases: discovery: explicit defs: local: @@ -781,8 +781,8 @@ storage: schema: book `, // Stray files in the convention dirs must be ignored. - "schemas/ignored.yaml": projecttest.MinimalSchema, - "storage/ignored-inst.yaml": "type: filesystem\ncollections: {}\n", + "schemas/ignored.yaml": projecttest.MinimalSchema, + "bases/ignored-inst.yaml": "type: filesystem\ncollections: {}\n", }) cfg, err := project.Load(dir) if err != nil { @@ -791,9 +791,9 @@ storage: if _, ok := cfg.Schemas["ignored"]; ok { t.Errorf("explicit discovery must ignore the schemas/ dir scan") } - for _, inst := range cfg.Storage { + for _, inst := range cfg.Bases { if inst.Name != "local" { - t.Errorf("explicit discovery must ignore the storage/ dir scan, saw instance %q", inst.Name) + t.Errorf("explicit discovery must ignore the bases/ dir scan, saw base %q", inst.Name) } } wantRoot := projecttest.RealPath(t, dir) @@ -805,10 +805,75 @@ storage: } } +func TestLoad_legacyStorageBlock_readsDefs(t *testing.T) { + dir := t.TempDir() + projecttest.WriteProject(t, dir, map[string]string{ + "schemas/book.yaml": projecttest.MinimalSchema, + "config.yaml": `storage: + discovery: explicit + defs: + local: + type: filesystem + root: . + collections: + notes: + schema: book +`, + }) + cfg, err := project.Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.Bases) != 1 || cfg.Bases[0].Name != "local" { + t.Fatalf("expected legacy storage block to load one base, got %+v", cfg.Bases) + } + if _, ok := cfg.Collection("notes"); !ok { + t.Errorf("expected notes collection from legacy storage block") + } +} + +func TestLoad_legacyStorageDir_readsConventionFiles(t *testing.T) { + dir := t.TempDir() + projecttest.WriteProject(t, dir, map[string]string{ + "schemas/book.yaml": projecttest.MinimalSchema, + "storage/local.yaml": projecttest.LocalBase(map[string]string{"notes": "schema: book\n"}), + }) + cfg, err := project.Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.Bases) != 1 || cfg.Bases[0].Name != "local" { + t.Fatalf("expected legacy storage dir to load one base, got %+v", cfg.Bases) + } +} + +func TestLoad_rejectsBasesAndStorageBlocks(t *testing.T) { + dir := t.TempDir() + projecttest.WriteProject(t, dir, map[string]string{ + "config.yaml": "bases:\n discovery: convention\nstorage:\n discovery: convention\n", + }) + _, err := project.Load(dir) + if err == nil || !strings.Contains(err.Error(), "both bases and storage") { + t.Fatalf("expected mixed config block error, got: %v", err) + } +} + +func TestLoad_rejectsBasesAndStorageDirs(t *testing.T) { + dir := t.TempDir() + projecttest.WriteProject(t, dir, map[string]string{ + "bases/local.yaml": "type: filesystem\ncollections: {}\n", + "storage/local.yaml": "type: filesystem\ncollections: {}\n", + }) + _, err := project.Load(dir) + if err == nil || !strings.Contains(err.Error(), ".katalyst/bases") || !strings.Contains(err.Error(), ".katalyst/storage") { + t.Fatalf("expected mixed config dir error, got: %v", err) + } +} + func TestLoad_explicitDiscovery_requiresDefs(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "config.yaml": "storage:\n discovery: explicit\n", + "config.yaml": "bases:\n discovery: explicit\n", }) _, err := project.Load(dir) if err == nil || !strings.Contains(err.Error(), "defs") { @@ -819,9 +884,9 @@ func TestLoad_explicitDiscovery_requiresDefs(t *testing.T) { func TestLoad_formatJSON_scansJSONFiles(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "schemas/book.json": `{"type":"object"}`, - "config.yaml": "schemas:\n format: json\n", - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "schema: book\n"}), + "schemas/book.json": `{"type":"object"}`, + "config.yaml": "schemas:\n format: json\n", + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "schema: book\n"}), }) cfg, err := project.Load(dir) if err != nil { @@ -856,7 +921,7 @@ func TestLoad_perKindIndependence(t *testing.T) { defs: book: ./.katalyst/schemas/book.json `, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "schema: book\n"}), + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "schema: book\n"}), }) cfg, err := project.Load(dir) if err != nil { @@ -884,8 +949,8 @@ func TestLoad_rejectsBadDiscovery(t *testing.T) { func TestLoad_listingDefaults_whenUnset(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "schema: book\n"}), + "schemas/book.yaml": projecttest.MinimalSchema, + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "schema: book\n"}), }) cfg, err := project.Load(dir) if err != nil { @@ -908,7 +973,7 @@ func TestLoad_listing_projectDefaultApplies(t *testing.T) { filterTypeMismatch: error sortMissing: lowest `, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "schema: book\n"}), + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "schema: book\n"}), }) cfg, err := project.Load(dir) if err != nil { @@ -932,7 +997,7 @@ func TestLoad_listing_collectionOverridesPerKey(t *testing.T) { "config.yaml": `listing: sortMissing: lowest `, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": `schema: book + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": `schema: book listing: filterTypeMismatch: error `}), @@ -954,7 +1019,7 @@ func TestLoad_listing_rejectsUnknownValue(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": `schema: book + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": `schema: book listing: filterTypeMismatch: bogus `}), @@ -972,7 +1037,7 @@ func TestLoad_rejectsProjectQueryConfigBlock(t *testing.T) { "config.yaml": `query: sortMissing: lowest `, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "schema: book\n"}), + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "schema: book\n"}), }) _, err := project.Load(dir) if err == nil || !strings.Contains(err.Error(), "query is no longer a config block; use listing") { @@ -984,7 +1049,7 @@ func TestLoad_rejectsCollectionQueryConfigBlock(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": `schema: book + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": `schema: book query: sortMissing: lowest `}), @@ -998,8 +1063,8 @@ query: func TestCollection_unknownReturnsFalse(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "schemas/book.yaml": projecttest.MinimalSchema, - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "schema: book\n"}), + "schemas/book.yaml": projecttest.MinimalSchema, + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "schema: book\n"}), }) cfg, err := project.Load(dir) if err != nil { @@ -1031,7 +1096,7 @@ func TestSchemaNames_returnsSortedNames(t *testing.T) { func TestLoad_parsesWritingTells(t *testing.T) { dir := t.TempDir() projecttest.WriteProject(t, dir, map[string]string{ - "storage/local.yaml": projecttest.LocalStorage(map[string]string{"notes": "path: notes\nchecks:\n - kind: markdown_writing_tells\n"}), + "bases/local.yaml": projecttest.LocalBase(map[string]string{"notes": "path: notes\nchecks:\n - kind: markdown_writing_tells\n"}), }) cfg, err := project.Load(dir) if err != nil { diff --git a/internal/project/project.go b/internal/project/project.go index 1d595e46..89d333c5 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -32,8 +32,8 @@ func (p *Project) Config() *Config { return p.cfg } type Item = collection.Item // def builds the filesystem CollectionDefinition for this project's config. -// Today every configured storage instance is filesystem-backed, and the loaded -// collection directories have already been resolved against their instance root. +// Today every configured base is filesystem-backed, and the loaded collection +// directories have already been resolved against their base root. func (p *Project) def() *filesystem.Definition { return filesystem.New(p.cfg.Root, p.cfg.Collections) } diff --git a/internal/project/project_test.go b/internal/project/project_test.go index e1e42d0a..f269a30e 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -28,7 +28,7 @@ func setup(t *testing.T) *project.Project { } projecttest.WriteProject(t, dir, map[string]string{ - "storage/local.yaml": projecttest.LocalStorage(map[string]string{ + "bases/local.yaml": projecttest.LocalBase(map[string]string{ "notes": "path: notes\nchecks:\n - kind: markdown_requires_h1\n", "people": "path: people\nchecks:\n - kind: markdown_requires_h1\n", }), diff --git a/internal/project/projecttest/projecttest.go b/internal/project/projecttest/projecttest.go index 1f669500..5e9d004e 100644 --- a/internal/project/projecttest/projecttest.go +++ b/internal/project/projecttest/projecttest.go @@ -32,9 +32,9 @@ func WriteProject(t *testing.T, dir string, files map[string]string) { } } -// LocalStorage builds a .katalyst/storage/local.yaml body with a filesystem -// instance rooted at the project and the given collection YAML bodies. -func LocalStorage(collections map[string]string) string { +// LocalBase builds a .katalyst/bases/local.yaml body with a filesystem base +// rooted at the project and the given collection YAML bodies. +func LocalBase(collections map[string]string) string { var b strings.Builder b.WriteString("type: filesystem\nroot: .\ncollections:\n") names := make([]string, 0, len(collections)) diff --git a/internal/storage/AGENTS.md b/internal/storage/AGENTS.md index 423c4e24..08895b85 100644 --- a/internal/storage/AGENTS.md +++ b/internal/storage/AGENTS.md @@ -1,11 +1,11 @@ # internal/storage -The backend boundary. This package names storage backend kinds and keeps the +The backend boundary. This package names base backend kinds and keeps the small registry of implemented backends; `collection/` holds the mapping from a backend store to Katalyst collections and items. Architecture and rationale live in the -[storage deep-dive](../../docs/content/deep-dives/domain-model/storage.md). The collection +[Bases deep-dive](../../docs/content/deep-dives/domain-model/storage.md). The collection read stack has its own local guide in [`collection/AGENTS.md`](collection/AGENTS.md). @@ -13,10 +13,10 @@ read stack has its own local guide in - Add a backend kind here only when its `CollectionDefinition` implementation exists. `Known` is the source of truth the project loader uses to validate - configured storage types. + configured base types. - `Reference` is opaque. Treat it as a backend-native locator, not always a filesystem path; filesystem interpretation belongs in `collection/filesystem`. -- Scope is a property of the storage type, not user configuration. Keep that +- Scope is a property of the base type, not user configuration. Keep that decision in code so collection/item roles stay portable across backends. - Keep this package small and dependency-light. Backend-specific parsing, discovery, IO, and persistence belong under `collection//`. diff --git a/internal/storage/collection/AGENTS.md b/internal/storage/collection/AGENTS.md index 7f4dc66f..630c0ff3 100644 --- a/internal/storage/collection/AGENTS.md +++ b/internal/storage/collection/AGENTS.md @@ -12,7 +12,7 @@ is the in-memory `item list` pipeline. Architecture and rationale — why a collection owns the read, why items are thin, and how a backend attaches — live in the -[storage layer](../../../docs/content/deep-dives/domain-model/storage.md) and +[Bases](../../../docs/content/deep-dives/domain-model/storage.md) and [collections](../../../docs/content/deep-dives/domain-model/collections.md) deep-dives. ## Conventions diff --git a/internal/storage/collection/collection.go b/internal/storage/collection/collection.go index da5909f0..30a43f76 100644 --- a/internal/storage/collection/collection.go +++ b/internal/storage/collection/collection.go @@ -29,8 +29,8 @@ type Item struct { // (Collections, Items, Unmatched); the reverse direction reconstructs a backend // locator from an item identity (Reference). Both directions are mandatory. type CollectionDefinition interface { - // Granularity reports the scope where this backend's units attach to the model. - Granularity() storage.Granularity + // Scope reports the scope where this backend's units attach to the model. + Scope() storage.Scope // Collections returns the collections this definition maps. One definition // may yield more than one collection. diff --git a/internal/storage/collection/filesystem/collection.go b/internal/storage/collection/filesystem/collection.go index 91a27d5c..6bffecbf 100644 --- a/internal/storage/collection/filesystem/collection.go +++ b/internal/storage/collection/filesystem/collection.go @@ -19,12 +19,12 @@ import ( // Definition maps a directory tree onto collections of markdown files: one file // is one item, its id is the filename stem. It is the CollectionDefinition for -// StorageType filesystem. +// BaseType filesystem. // // The per-collection methods operate on the absolute Dir already resolved on // each collection.Collection, so root is unused today; it is retained because a -// filesystem instance is identified by its root and Phase 2's BuildInstance -// resolves collection directories against it. +// filesystem base is identified by its root and the project loader resolves +// collection directories against it. type Definition struct { root string collections []collection.Collection @@ -35,8 +35,8 @@ func New(root string, collections []collection.Collection) *Definition { return &Definition{root: root, collections: collections} } -// Granularity reports item scope for the markdown filesystem. -func (f *Definition) Granularity() storage.Granularity { return storage.FileIsItem } +// Scope reports item scope for the markdown filesystem. +func (f *Definition) Scope() storage.Scope { return storage.FileIsItem } // Collections returns the collections this definition maps. func (f *Definition) Collections() []collection.Collection { return f.collections } diff --git a/internal/storage/collection/filesystem/collection_test.go b/internal/storage/collection/filesystem/collection_test.go index f50d3fe9..858537d6 100644 --- a/internal/storage/collection/filesystem/collection_test.go +++ b/internal/storage/collection/filesystem/collection_test.go @@ -89,9 +89,9 @@ func TestFilesystem_Reference_reverseResolution(t *testing.T) { } } -func TestFilesystem_Granularity_fileIsItem(t *testing.T) { - if g := filesystem.New("", nil).Granularity(); g != storage.FileIsItem { - t.Fatalf("Granularity = %v, want FileIsItem", g) +func TestFilesystem_Scope_fileIsItem(t *testing.T) { + if g := filesystem.New("", nil).Scope(); g != storage.FileIsItem { + t.Fatalf("Scope = %v, want FileIsItem", g) } } diff --git a/internal/storage/collection/parse.go b/internal/storage/collection/parse.go index 05008b21..6fa8ea33 100644 --- a/internal/storage/collection/parse.go +++ b/internal/storage/collection/parse.go @@ -29,9 +29,9 @@ type Collection struct { // ListingDefaults holds the resolved `item list` behavior for this // collection (collection config over project config over defaults). ListingDefaults ListingDefaults - // Storage is the name of the storage instance that declares this + // Base is the name of the base that declares this // collection. - Storage string + Base string // Variants are discriminated check groups: an item runs the first // variant (in order) whose Where predicates it all satisfies, in // addition to the base Checks. Empty for a collection without variants. @@ -75,7 +75,7 @@ const ( ) // RawCollection mirrors one collection definition in YAML. The loader -// unmarshals it (inline under a storage instance, or one file per collection) +// unmarshals it (inline under a base, or one file per collection) // and hands it to Build. type RawCollection struct { Path string `yaml:"path"` @@ -207,20 +207,20 @@ func (rc *RawCheck) UnmarshalYAML(value *yaml.Node) error { } // BuildInput carries everything Build needs to validate and resolve one -// collection: its raw definition and name, the owning storage instance's root +// collection: its raw definition and name, the owning base's root // and name, the project-level listing defaults, and a predicate that reports // whether a schema name is defined (schema resolution belongs to the loader). type BuildInput struct { Name string Raw RawCollection - InstRoot string - InstName string + BaseRoot string + BaseName string ProjectListing *RawListingDefaults SchemaKnown func(string) bool } // Build turns one raw collection definition into a validated Collection, -// resolving its directory against the owning instance's root. The name comes +// resolving its directory against the owning base's root. The name comes // from the source (map key), never the file body. func Build(in BuildInput) (Collection, error) { dirRel := in.Raw.Path @@ -268,12 +268,12 @@ func Build(in BuildInput) (Collection, error) { return Collection{ Name: in.Name, Path: dirRel, - Dir: resolveDir(in.InstRoot, dirRel), + Dir: resolveDir(in.BaseRoot, dirRel), Pattern: pattern, Schema: schemaName, Checks: cks, ListingDefaults: ld, - Storage: in.InstName, + Base: in.BaseName, Variants: variants, UseExhaustiveVariants: in.Raw.UseExhaustiveVariants, }, nil diff --git a/internal/storage/doc.go b/internal/storage/doc.go index c4e17e52..12510dd7 100644 --- a/internal/storage/doc.go +++ b/internal/storage/doc.go @@ -3,9 +3,9 @@ // // # Three concepts // -// - StorageType: a known backend kind (filesystem today; sqlite, postgresql, +// - BaseType: a known backend kind (filesystem today; sqlite, postgresql, // mongodb later). The registry here is the extension point. -// - StorageInstance (assembled by the internal/project loader): one configured +// - BaseInstance (assembled by the internal/project loader): one configured // store of a type plus how to reach it, embedding the collections it maps. // - CollectionDefinition: the two-way mapping from a store's contents to // collections and items. FilesystemCollectionDefinition is the first. @@ -23,7 +23,7 @@ // # Scope // // Whether a matched store unit becomes an Item or a Collection is a property of -// the StorageType, not user configuration. A markdown file is an Item; a SQL +// the BaseType, not user configuration. A markdown file is an Item; a SQL // table would be a Collection. Item and Collection are therefore roles, not file // counts. See docs/content/deep-dives/domain-model/storage.md. package storage diff --git a/internal/storage/storage.go b/internal/storage/storage.go index e90cedec..0a2c3e1a 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -1,32 +1,32 @@ package storage -// StorageType is a known backend kind capable of holding collections and items. -type StorageType string +// BaseType is a known backend kind capable of holding collections and items. +type BaseType string // Filesystem is the only backend implemented today. -const Filesystem StorageType = "filesystem" +const Filesystem BaseType = "filesystem" // registered is the set of backend kinds with an implementation. It is the -// extension point: a new StorageType is added here when its +// extension point: a new BaseType is added here when its // CollectionDefinition lands. -var registered = map[StorageType]bool{ +var registered = map[BaseType]bool{ Filesystem: true, } -// Known reports whether a StorageType has an implementation. The project loader +// Known reports whether a BaseType has an implementation. The project loader // carries the type as a plain string and leaves this validation to the storage // layer, so the storage registry remains the source of truth for backend kinds. -func Known(t StorageType) bool { return registered[t] } +func Known(t BaseType) bool { return registered[t] } -// Granularity records the scope at which a backend's matched units attach to -// the domain model. It is a property of the StorageType, not user +// Scope records the scope at which a backend's matched units attach to +// the domain model. It is a property of the BaseType, not user // configuration: a markdown filesystem makes each file an Item, while a tabular // backend would make each table a Collection and each row an Item. -type Granularity int +type Scope int const ( // FileIsItem: one file is one Item; a directory of files is a Collection. - FileIsItem Granularity = iota + FileIsItem Scope = iota // UnitIsCollection: one store unit (a table/file) is a Collection; its // rows are Items. Reserved for future tabular backends. UnitIsCollection diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index e95c7474..0aa07d75 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -10,7 +10,7 @@ func TestKnown_onlyFilesystem(t *testing.T) { if !storage.Known(storage.Filesystem) { t.Errorf("filesystem should be a known storage type") } - if storage.Known(storage.StorageType("sqlite")) { + if storage.Known(storage.BaseType("sqlite")) { t.Errorf("sqlite is not implemented yet and should not be known") } }