# databases

Reference for ingesting Notion databases — both the database (schema) and its rows.

## Shape on disk

A Notion database lands as a `.csv` file plus a sibling directory of the same basename. The CSV is the table-view export; the directory contains one `.md` per row, with the row's properties as YAML front-matter and the row body as markdown below.

```
Projects 7a1b2c…/
  Projects 7a1b2c….csv        ← schema + row table
  Projects 7a1b2c…/             ← row markdowns
    Phoenix Migration 9f3a…md
    Q3 Strategy d4e5…md
```

The CSV columns are the database's properties. Column types are not encoded in the CSV directly — they are inferred from the values (date-shaped, URL-shaped, person-mention-shaped, relation-shaped). A relation column's cells contain the human title plus Notion ID of the linked row.

## What the database itself becomes

Each Notion database is written as one `:KnowledgeDocument { kind: 'notion-database' }` node:

- `notionId` — the 32-hex ID from the CSV basename.
- `name` — human title from the CSV basename.
- `schema` — JSON-stringified list of `{ name, inferredType }` per column.
- `parentPageNotionId` — the page that contains the database, if any.

The database is attached to the workspace root via `(:NotionWorkspace)-[:HAS_SECTION]->(:KnowledgeDocument)` and, when nested under a page, also via `(:KnowledgeDocument {page-parent})-[:HAS_SECTION]->(:KnowledgeDocument {database})`.

## Label selection for rows

Per-row label is decided by the database's name and column set together — not by either alone.

- **`:Person`** when the database name matches `^(people|contacts|crm|team|members?)$` (case-insensitive) AND the columns contain at least one of `email`, `phone`, `telephone`, `linkedin`. Properties map onto schema-base camelCase: `givenName` / `familyName` from a single `name` column split on first space (preserve `name` only if no canonical Person property fits); `email`, `telephone`, `jobTitle`, etc.
- **`:Organization`** when the name matches `^(companies|orgs|organizations?|accounts)$` AND the columns contain `website` or `domain`.
- **`:Project`** when the name matches `^projects?$` AND the columns contain at least one of `status`, `startDate`, `dueDate`, `owner`.
- **`:Task`** when the name matches `^(tasks?|todo|to-?dos?|action items?)$` AND the columns contain at least one of `done`, `status`, `assignee`, `dueDate`.
- **`:Event`** when the name matches `^(events?|meetings?|calendar)$` AND the columns contain `startDate` / `start` / `date`.
- **`:KnowledgeDocument { kind: 'notion-row' }`** — fallback. The CSV columns are preserved on the node as properties. Surface to admin in the completion log if any database falls entirely into this bucket — it usually means the workspace has a custom DB shape worth handling explicitly in a future iteration.

When admin's brief is ambiguous about which label a database should land in, the database-operator's `{ambiguity, proposed}` protocol applies — ask, don't guess.

## Row write recipe

For each row in the parsed CSV:

1. **Confirm the parent database exists.** The database itself (`:KnowledgeDocument { kind: 'notion-database', notionId }`) is MERGEd via `memory-write` once, before any of its rows are queued. The `notion-database-rows` handler refuses the batch if the parent is missing.
2. **Select the label** per the rules above. Allowlist: `Person`, `Organization`, `Project`, `Task`, `Event`, `KnowledgeDocument`.
3. **Map CSV columns to schema-base properties.** Use canonical camelCase. Unknown columns land as properties on the node with their original column name preserved.
4. **Buffer the row.** Append `{ notionId, properties }` to `dbRowBufferByLabel[label][databaseNotionId]`. The row carries node properties only; the HAS_SECTION edge to the parent database is written server-side at flush time.
5. **Bucket relation columns into `deferredRelations`.** A relation column cell is `"Linked Row Title <32-hex-notionId>"`. For each cell, append a relation entry with `sourceNotionId` = this row's `notionId`, `targetNotionId` = the linked row's `notionId`. When the relation name maps to a canonical edge (table below), set `edgeType` accordingly; otherwise set `edgeType: 'MENTIONS'` and `properties: { mentionContext: '<relation-column-name>' }`. Pass-2 resolves targets — links to rows outside the import set are counted as `skippedUnknownTarget`, not errors.

The actual writes happen at flush time: one `memory-archive-write` call per `(label, databaseNotionId)` key, then one final `memory-archive-write archiveType='notion-relations-pass-2'` call carrying every relation across all databases and pages.

## Notion-relation → canonical-edge map

Examples — the table is extensible per workspace, but the principle is fixed: a relation maps to a canonical edge only when the schema vocabulary in `schema-base.md` (Relationship Patterns + Edges introduced by document-ingest, plus any vertical-schema edges loaded at runtime) names a matching one.

| Notion relation column name | Canonical edge |
|-----------------------------|----------------|
| `works_at`, `company`, `employer` | `(:Person)-[:WORKS_FOR]->(:Organization)` |
| `assignee`, `owner` | `(:Person)-[:OWNS]->(:Project)` / `(:Person)-[:ASSIGNED_TO]->(:Task)` per row label |
| `project`, `belongs_to_project` | `(:Task)-[:PART_OF]->(:Project)` |
| `attended`, `participants` | `(:Person)-[:ATTENDED]->(:Event)` |
| `mentions` (Notion's own typed mention relation) | `:MENTIONS` |
| anything else | `:MENTIONS { mentionContext: '<relation-column-name>' }` |

The mapping table lives in this reference — not in a server-side enum — so a workspace with custom relation names is handled by extending this file, not by code change.

## Forward-reference resolution

A row's relation column may point at a row not yet written (later in the walk or in a later batch). The buffered execution model handles this naturally:

- **Pass 1 — node writes.** Pages flush via `notion-pages`; database rows flush via one `notion-database-rows` call per `(label, databaseNotionId)` key. No relations are written in pass 1.
- **Pass 2 — relations.** Once every page and row is MERGEd, `notion-relations-pass-2` flushes `deferredRelations`. Source and target are located by `(accountId, notionId)`; missing endpoints are counted as `skippedUnknownTarget`, not errors.

Two passes are deterministic and keep every write idempotent. The completion log reports the `relationsCreated` / `relationsMatched` counters from pass 2.

## Failure modes

- `[notion-import:error] reason=row-schema-mismatch file=<path>` — the row's front-matter has properties not in the parent CSV's schema. Write the row with the union of properties; log the mismatch; continue.
- `[notion-import:error] reason=relation-target-not-found notionId=<id>` — pass 2 reported `skippedUnknownTarget > 0` (a row outside the operator's filter, or an externally-linked workspace row). The pass-2 counter captures the total; per-edge detail is not logged because the server only sees the bucketed list, not the source manifest.
- `[notion-import:error] reason=label-ambiguous db=<name>` — the name + column heuristic matched no canonical label and the operator's brief did not specify. Fall back to `:KnowledgeDocument { kind: 'notion-row' }` and surface the gap in the completion log.
