Interchange export/import for Phase 1 (data-model §7): - JSON: canonical, versioned envelope (formatVersion 1) with all sync metadata verbatim, photos embedded as base64, tombstones excluded. Species are re-resolved on import by scientific name (catalog ids are per-install). Reader tolerates unknown fields/enum values (§5.2) and rejects newer format versions with a clear error. - CSV: export-only spreadsheet flatten, one row per lot, RFC 4180 escaping, never any photo bytes. - Import merges by UUIDv7 id in one transaction: insert-if-unknown preserving original stamps, last-writer-wins by packed HLC for known mutable rows, append-only movements; afterwards the local clock receiveEvent()s the newest imported stamp so it never runs behind. - Settings gains a Backup section (export CSV/JSON, import JSON with confirmation); file dialogs behind a FileService interface backed by file_picker (MIT). - Tests: codec round-trip and tolerance, reconciler LWW, repository export→import round-trip (fresh DB, idempotent re-import, newer-local wins, clock monotonicity, species re-resolution), backup widget flows.
245 lines
20 KiB
Markdown
245 lines
20 KiB
Markdown
# Tanemaki — Data model (v0 draft)
|
||
|
||
*Technical spec feeding the Flutter/Drift implementation. Written in English (project convention). Implements the concepts discussed in [data-notes.md](data-notes.md) and PLAN §3 / §5-bis.*
|
||
|
||
> Status: draft for discussion. Nothing here is frozen; it is the starting point for the first Drift schema (`schemaVersion = 1`).
|
||
|
||
> **Core/domain split.** This model is presented *flat* for readability. Which of these tables live in the generic engine (`commons_core`) vs. the seeds domain (`app_seeds`) is decided in [core-domain-boundary.md](core-domain-boundary.md). In short: `Variety`, `Lot`, `Movement`, `Species` and the seed-specific fields are **domain**; `Offer`, `Party`, `SeedBank`→`Group`, trust, identity and the signed-return promise are **core**. Two entities below are the seeds view of a generalized core concept — see the notes on `Movement` (§2.4) and `Plantare` (§2.7).
|
||
|
||
## 0. Design constraints (why the model looks like this)
|
||
|
||
- **Local-first & offline.** The whole model lives in local SQLite. No server is required. Online only enriches.
|
||
- **CRDT-ready from day one.** Every mutable row carries the metadata needed to merge across devices/peers later, even before sync is built. See §4.
|
||
- **Progressive disclosure.** Only `Variety.label` is mandatory. Everything else is optional. The schema must never force a field the UI hides.
|
||
- **Three levels: identity / batch / event.** `Variety` (what it is) → `Lot` (a batch you hold) → `Movement` (append-only log). This makes year, in/out, dates and germination fall into place.
|
||
- **Append-only ledger.** `Movement` rows are immutable events. This is both the bank history (mockup HISTORY tab) and the provenance/Plantare chain (§5-bis).
|
||
|
||
## 1. Conventions for every table
|
||
|
||
All identifiers, column names, comments and commit messages are in **English**. Shared columns on every mutable entity:
|
||
|
||
| Column | Type | Purpose |
|
||
|---|---|---|
|
||
| `id` | TEXT (UUIDv7) | **Client-generated** primary key. Never auto-increment (would collide across peers). UUIDv7 sorts by time. |
|
||
| `created_at` | INTEGER (ms) | Creation timestamp. |
|
||
| `updated_at` | TEXT (HLC) | Hybrid Logical Clock stamp of last change — drives last-writer-wins merge. |
|
||
| `last_author` | TEXT | Public key / device id of who last wrote (for CRDT + trust). |
|
||
| `is_deleted` | BOOLEAN | **Soft delete only.** Never `DELETE FROM`; tombstones must survive to merge correctly. |
|
||
| `schema_row_version` | INTEGER | Per-record format version for distributed migration (§5). |
|
||
|
||
`Movement` is the exception: it is append-only and immutable, so it needs no `updated_at`/`is_deleted` (a correction is a new compensating event).
|
||
|
||
## 2. Entities
|
||
|
||
### 2.1 `Variety` — the identity (accession)
|
||
What you saved; one row per distinct thing in your inventory.
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | UUID | |
|
||
| `label` | TEXT **(required)** | Your own name: "el tomate de la abuela". The only mandatory field. |
|
||
| `species_id` | UUID → `Species` | Nullable. Links to the bundled catalog; unlocks scientific name + Wikidata. |
|
||
| `cultivar_name` | TEXT | Nullable. 'Marmande', 'Cherokee Purple'. Free text — landraces aren't in any authority. |
|
||
| `category` | TEXT/enum | Nullable. Botanical family or user group, for the inventory sections. |
|
||
| `notes` | TEXT (markdown) | Nullable. Free personal notes. |
|
||
| *(+ common columns)* | | |
|
||
|
||
Related (separate tables so concurrent edits merge as sets):
|
||
- `VarietyVernacularName(id, variety_id, name, language, region)` — the multiple common names.
|
||
- `Attachment(id, parent_type, parent_id, uri, kind)` — photos/docs (kind: photo, doc). Polymorphic parent.
|
||
- `ExternalLink(id, parent_type, parent_id, url, title)` — any pasted URL (Wikipedia, forum…).
|
||
|
||
### 2.2 `Species` — bundled name catalog (mostly read-only)
|
||
The lightweight offline catalog (Wikidata CC0 + GBIF CC-BY). Users may add local entries.
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | UUID | |
|
||
| `scientific_name` | TEXT | *Solanum lycopersicum*. |
|
||
| `wikidata_qid` | TEXT | e.g. `Q23501`. Anchor to derive Wikipedia/image/GBIF online. |
|
||
| `gbif_key` | INTEGER | Nullable. GBIF taxon key. |
|
||
| `family` | TEXT | Nullable. |
|
||
| `is_bundled` | BOOLEAN | True = shipped with app (don't sync); false = user-added (syncs). |
|
||
|
||
Localized common names for the catalog live in `SpeciesCommonName(species_id, name, language)` and are bundled too.
|
||
|
||
### 2.3 `Lot` — a batch you hold
|
||
Zero or more per `Variety`, each with its **own year and its own unit** — and units may differ between lots of the same variety. A single maize variety can hold, at once: `[2 cobs · 2024]`, `[a few loose seeds · 2023]`, `[a cup · 2022]`. Even the same variety+year in two forms (2 cobs *and* loose kernels from 2024) is just two lots. A `Lot` = one homogeneous batch. This is exactly why quantity and unit live here, not on `Variety`.
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | UUID | |
|
||
| `variety_id` | UUID → `Variety` | |
|
||
| `harvest_year` | INTEGER | Nullable. **This is where "year" lives** (germination decays with lot age). |
|
||
| `type` | enum `LotType` | The **form** held — see §2.3.2. |
|
||
| `quantity_label` | TEXT + `quantity_kind` | Qualitative by default. **Not only kitchen units** — see §2.3.1. |
|
||
| `quantity_precise` | REAL + `quantity_unit` | Optional precise amount (grams / seed count). |
|
||
| `presentation` | enum `Presentation` | Nullable. How living material is packaged — see §2.3.2. |
|
||
| `storage_location` | TEXT | Nullable. Free text with suggestions ("fridge", "village bank"). |
|
||
| `offer_status` | enum | `private` / `shared` / `exchange` / `sell` — the visibility from PLAN §3 Layer 2. Per lot. |
|
||
| `seedbank_id` | UUID → `SeedBank` | Nullable. If this lot belongs to a collective bank. |
|
||
| *(+ common columns)* | | |
|
||
|
||
`GerminationTest(id, lot_id, date, sample_size, germinated_count, notes)` — optional; `percent` is derived (`germinated_count / sample_size`). One-to-many so history of tests is kept.
|
||
|
||
#### 2.3.1 Quantity units — qualitative and plant-aware (design note)
|
||
|
||
Quantity is deliberately *rough and human*, not precise. But the informal vocabulary must go **beyond kitchen measures** ("cup", "pinch", "packet") — those feel too "andar por casa" and don't fit many seeds. Seeds are naturally counted in the **forms the plant gives them**. So `quantity_kind` is an **extensible, i18n, grouped list**, and the app *suggests* the relevant units from the linked `Species`/`category` (never forces them):
|
||
|
||
- **Informal / generic:** a few, some, plenty, a handful, a pinch, a jar, a **packet/envelope** ("sobre").
|
||
- **Plant-form natural units:** a **cob** (mazorca, maize), a **flower head** (cabezuela, sunflower), a **pod** (vaina, legumes), an **ear/spike** (espiga, cereals), a **fruit's worth** (tomato, pepper, squash), a **bulb**, a **tuber**, a **seed head**, a **bunch**.
|
||
- **Precise (optional):** grams or seed count (`quantity_precise` + `quantity_unit`).
|
||
|
||
Modelling: store `quantity_kind` (a stable enum key like `pod`, `cob`, `head`, `packet`, `handful`, `grams`, `count`) + an optional numeric `quantity_precise` (and a free-text `quantity_label` for a rough amount when no key fits). The **display label is localized** from the key, so "pod"→"vaina"/"beina"… Units are *labels for a rough amount*, generally **not convertible** between each other or to grams — and that's fine. Suggestion logic (e.g. Poaceae → ear/cob, Fabaceae → pod, Helianthus → flower head) lives in the catalog mapping, not hardcoded, so it stays extensible and translatable. **To think through further:** the full starter list per family, and whether users can add their own unit keys (probably yes, as free `quantity_label` text when no key fits).
|
||
|
||
#### 2.3.2 Lot form (`LotType`) and packaging (`Presentation`) — design note
|
||
|
||
A lot isn't always dry seed. Reviewing the vocabulary of real seed shops and nurseries (viveros) — where "plantel" is only the *seedbed/batch*, not a unit — the **form** a lot is held in is a small, append-only enum `LotType` (stored by name, data-model §5):
|
||
|
||
- `seed` — dry seed. **The only form germination tests apply to.**
|
||
- `seedling` — a young plant grown from seed, to transplant (*plantón* / plug).
|
||
- `plant` — an established plant (potted or bare-root).
|
||
- `tree` — a woody plant: tree or shrub.
|
||
- `bulb` — a storage organ: bulb, corm, tuber or rhizome.
|
||
- `cutting` — vegetative propagation material (cutting, scion).
|
||
|
||
**Form drives the offered units**, and only `seed` gets the rich plant-form scale of §2.3.1; living forms are counted as **whole individuals** (`seedling`, `plant`, `tree`, `cutting`; `bulb` also allows grams). The *packaging* is deliberately a **separate optional attribute** `Presentation` (`pot`, `tray`, `plug`, `bareRoot`, `rootBall`) so "how many" is never conflated with "in what format" — that conflation was the original flaw (the old `pot`/`tray` "amounts", now retained only for old rows). Presentation is offered only for `seedling`/`plant`/`tree`. **Left open:** grafts/divisions/forestry seedlings (nicho) fold into `cutting`/`plant` for now, or a free note; revisit if real exchanges demand their own key.
|
||
|
||
### 2.4 `Movement` — the append-only event log
|
||
Immutable events on a `Lot`. Entries/exits are just types. This *is* the history tab and the provenance chain.
|
||
|
||
> The append-only *mechanic* is the generic `LedgerEntry` of `commons_core` (see [core-domain-boundary.md](core-domain-boundary.md) §2, §4.3); `Movement` is its seeds-domain shape. At v0 it lives whole in `app_seeds` (per that doc's §8 conservative start); it may be split later.
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | UUID | |
|
||
| `lot_id` | UUID → `Lot` | |
|
||
| `type` | enum | `received`, `given`, `sown`, `harvested`, `germination_test`, `split`, `discarded`… |
|
||
| `occurred_on` | DATE | The date (in/out/sowing…). |
|
||
| `counterparty_id` | UUID → `Party` | Nullable. From/to whom (for received/given). |
|
||
| `quantity_label` / `quantity_precise` | | How much moved. |
|
||
| `parent_movement_id` | UUID → `Movement` | Nullable. Links a lot to the exchange it came from → **provenance DAG**. |
|
||
| `plantare_id` | UUID → `Plantare` | Nullable. A `given` may carry a Plantare. |
|
||
| `notes` | TEXT | |
|
||
| `created_at`, `last_author`, `schema_row_version` | | (no update/delete — append-only) |
|
||
|
||
### 2.5 `Party` — a person or collective you exchange with
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | UUID | |
|
||
| `display_name` | TEXT | |
|
||
| `public_key` | TEXT | Nullable. The person's single identity key (Duniter/Ğ1-style; a derived subkey serves Nostr transport). Layer 3+. |
|
||
| `kind` | enum | `person` / `collective`. |
|
||
| `note` | TEXT | |
|
||
|
||
### 2.6 `SeedBank` — a collective bank (optional, Layer 2–3)
|
||
`SeedBank(id, name, note)` + `SeedBankMember(seedbank_id, party_id, role)`. A `Lot` may point to a `SeedBank`. Kept minimal now; expands with sharing.
|
||
|
||
### 2.7 `Plantare` — the signed seed-IOU (§5-bis)
|
||
The digital version of the paper Plantare. Bilateral, signed, held by both parties.
|
||
|
||
> `Plantare` is the seeds-domain name for the generic `Pledge` (return promise) of `commons_core` — see [core-domain-boundary.md](core-domain-boundary.md) §2, §5. The generalization uses `Pledge.return_kind = similar` for seeds (return *something alike*) vs. `same_item` for a lent tool. The columns below map onto that core entity.
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | UUID | |
|
||
| `movement_id` | UUID → `Movement` | The `given` event it accompanies. |
|
||
| `debtor_key` | TEXT | Who received and owes a return. |
|
||
| `creditor_key` | TEXT | Who gave. |
|
||
| `owed_description` | TEXT | "a similar amount of free seed". |
|
||
| `due_date` | DATE | Return-by date. |
|
||
| `debtor_signature` / `creditor_signature` | TEXT | Cryptographic signatures (both stubs of the paper). |
|
||
| `status` | enum | `open` / `returned` / `forgiven`. Framed as a promise, not a debt (microcopy). |
|
||
|
||
### 2.8 `Offer` — a published, discoverable listing
|
||
The *shop window*: a signed statement published to the network so others can discover what you share. Distinct from `Lot` on purpose — publishing reveals only what you choose (privacy). Serializes to Nostr NIP-99 (`kind:30402`) or ActivityPub. Full rationale in [sharing-model.md](sharing-model.md).
|
||
|
||
| Column | Type | Notes |
|
||
|---|---|---|
|
||
| `id` | UUID | |
|
||
| `lot_id` | UUID → `Lot` | Nullable (offer without exposing a lot; `wanted` needs none). |
|
||
| `variety_summary` | TEXT/JSON | Only the name + photo ref you choose to publish. |
|
||
| `offer_type` | enum | `gift` / `exchange` / `sale` / `wanted`. |
|
||
| `price_amount` | DECIMAL | Nullable (only `sale`). **No in-app payment** — price is informational; money changes hands off-platform. |
|
||
| `price_currency` | TEXT | Nullable. ISO 4217 or a local/community currency. |
|
||
| `price_negotiable` | BOOLEAN | |
|
||
| `exchange_terms` | TEXT | Nullable (for `exchange`). |
|
||
| `approx_geohash` | TEXT | Low precision only. |
|
||
| `radius_km` | INTEGER | |
|
||
| `status` | enum | `active` / `reserved` / `closed`. |
|
||
| `published_at` / `expires_at` | | Seasonality / freshness. |
|
||
| `transport_ref` | TEXT | Relay event id (NIP-99) / AP object id. |
|
||
| `author_key` | TEXT | Publishing public key. |
|
||
| *(+ common columns)* | | |
|
||
|
||
A closed deal may produce a `Movement` (`given`) and, if it is an exchange with a return promise, a `Plantare`. The app never processes payments, takes commissions, or holds funds (keeps Tanemaki neutral infrastructure, not a central market operator).
|
||
|
||
## 3. Entity map
|
||
|
||
```
|
||
Species (bundled) ──< Variety ──< Lot ──< Movement (append-only) >── Party
|
||
│ │ │ │
|
||
VernacularName │ Offer └── Plantare
|
||
Attachment/Link GerminationTest
|
||
│
|
||
SeedBank
|
||
```
|
||
`Movement.parent_movement_id` forms the cross-device provenance DAG. `Offer` is published (NIP-99/AP) and, when a deal closes, spawns a `Movement` (and optionally a `Plantare`).
|
||
|
||
## 4. CRDT strategy (merge semantics)
|
||
|
||
Chosen libraries (PLAN §3): `crdt_lf` / `crdt_sync` on top of Drift. Merge rules by data shape:
|
||
|
||
- **Scalar fields** (label, harvest_year, offer_status…) → **LWW register** keyed by HLC `updated_at`. Simple, good enough for personal edits.
|
||
- **Collections** (vernacular names, attachments, links, seedbank members) → **OR-Set** (observed-remove set) so concurrent adds on two phones both survive.
|
||
- **`Movement` log** → **grow-only set** (append-only). No conflicts possible; corrections are new events. This is why the ledger is the safest part to sync first.
|
||
- **Identity:** client-generated UUIDv7; **deletes are tombstones** (`is_deleted`), never physical.
|
||
|
||
Adoption order (avoid CRDT-everything at once): sync `Movement` first (append-only, trivial), then `Lot`/`Variety` (LWW), then the set-typed relations.
|
||
|
||
## 5. Migration — two axes (this is the Liquibase/Flyway question)
|
||
|
||
### 5.1 Local schema migration → Drift, natively (the Flyway/Liquibase equivalent)
|
||
Drift ships versioned, testable migrations — exactly the requested capability:
|
||
|
||
- `schemaVersion` integer on the database; bump on every schema change.
|
||
- **Step-by-step migrations**: `dart run drift_dev schema steps drift_schemas/ lib/db/schema_versions.dart` generates `from1To2`, `from2To3`… functions.
|
||
- **Versioned schema exports** stored in `drift_schemas/` (`drift_schema_v1.json`, `v2.json`…) — the migration history, like Flyway's versioned scripts, committed to git.
|
||
- **Migration tests**: `dart run drift_dev schema generate` builds old-version DBs; `validateDatabaseSchema` asserts the migrated schema matches a fresh one. Run in CI.
|
||
|
||
Rule: never edit a released schema version; always add a new one with a forward migration. Keep every `drift_schema_vN.json` in the repo.
|
||
|
||
### 5.2 Distributed / wire migration → compatibility discipline (no tool can do this for you)
|
||
Because peers run **different app versions**, the sync payload must stay backward+forward compatible. This is the harder axis and is a *convention*:
|
||
|
||
- **Additive, optional-only changes** to synced records → both forward and backward compatible. Never repurpose a field's meaning.
|
||
- **`schema_row_version` per record**; readers **ignore unknown fields** and fill missing ones with defaults.
|
||
- **Soft deletes / tombstones** always (already in the model).
|
||
- **Never reuse or renumber** enum values; only append new ones, and tolerate unknown enum values on read (map to a safe default).
|
||
- A new required concept ships as a **new optional table/field**, promoted to "expected" only after most peers have upgraded.
|
||
|
||
Together: Drift handles the *local database* migration; the compatibility rules handle the *distributed data* migration. Both are needed for a P2P local-first app.
|
||
|
||
## 6. Open questions (decide before freezing `schemaVersion = 1`)
|
||
|
||
- `quantity` modelled once and reused on Lot and Movement, or duplicated? (Lean: a shared embedded type.)
|
||
- Is `offer_status` on `Lot` enough, or does a `Variety`-level default help? (Lean: Lot only.)
|
||
- Do we need `Variety`-level provenance, or is per-Lot/Movement provenance sufficient? (Lean: Movement DAG is enough.)
|
||
- `category` as free enum vs linked to `Species.family` when a species is set. (Lean: free, prefilled from family when available.)
|
||
|
||
## 7. Interchange export/import (Phase 1) — decided & implemented
|
||
|
||
The user-triggered **interchange export** (Settings → Backup). Distinct from the future encrypted full backup (`.tanemaki`, [backup-and-recovery.md](backup-and-recovery.md) mechanism 1): the interchange files are the *only* plaintext the app ever writes, and only because the user explicitly asks for them. Import reads the picked file straight into memory — no temp copies.
|
||
|
||
Two formats, one canonical:
|
||
|
||
- **JSON** — canonical, versioned, importable; a precursor of the future sync payload. Envelope: `formatVersion` (currently 1), `schemaVersion` (the writer's DB generation), `appId: "tane"`, then one array per table: `varieties`, `lots`, `vernacularNames`, `externalLinks`, `germinationTests`, `movements`, `parties`, `attachments`. Every row carries its sync metadata verbatim (`id`, `createdAt`, packed-HLC `updatedAt`, `lastAuthor`, `schemaRowVersion`); enums serialize by name. **Photos/docs are embedded as base64** (`bytesBase64`) — the JSON export is the complete copy of the data (~33% size overhead accepted). **Tombstones are excluded**: this is a user-facing export of the live inventory, not the sync wire format (real sync, Block 2, will carry tombstones on its own channel).
|
||
- **CSV** — export-only, for spreadsheets. One row per Lot (a variety with no lots gets one row); vernacular names and links joined into single columns; latest germination rate derived; ISO-8601 dates; never any photo bytes. Not importable — the JSON is.
|
||
|
||
**Species are not exported.** The bundled catalog rows get per-install ids, so `species_id` is not portable. Each exported variety carries a denormalized `speciesScientificName`; the importer re-resolves it against its own catalog by scientific name (no match → `species_id` stays null).
|
||
|
||
**Import = merge by id (UUIDv7), never duplicating.** Mutable rows: unknown id → insert preserving the original metadata (no re-stamping); known id → last-writer-wins on the packed HLC `updatedAt` (§4), incoming wins only if strictly newer — so re-importing the same file is a no-op, and a locally-deleted row only resurrects if the incoming version is newer than the tombstone. `movements` are append-only: insert-if-unknown, never updated. Everything runs in one transaction; afterwards the local HLC clock `receiveEvent`s the newest imported stamp so the next local write out-stamps everything imported.
|
||
|
||
**Reader compatibility (per §5.2):** unknown JSON fields are ignored; unknown enum values map to a safe default (`lot.type → seed`, `offer_status → private`) or drop the row when none exists (an unknown `movement.type`); a `formatVersion` newer than the reader supports fails with a clear "update the app" error. `formatVersion` changes must be additive and documented here.
|
||
|
||
Implementation: `apps/app_seeds/lib/data/export_import/` (codecs + reconciler), `VarietyRepository.exportInventory()/importInventory()`, `lib/services/export_import_service.dart`.
|