Skip to content

feat: keyed upsert for large loads - #33

Merged
cjohnhanson merged 4 commits into
mainfrom
feat/bulk-create-and-upsert
Oct 10, 2026
Merged

cjohnhanson merged 4 commits into
mainfrom
feat/bulk-create-and-upsert

Conversation

@cjohnhanson

Copy link
Copy Markdown
Owner

Changes

create_note listed the note directory twice for each note and checked prefixes with a linear search, so a loop of creates grew faster than the square of its size.

Repo::keyed_batch(key) returns KeyedBatch. It lists the store once, reads each note once, and claims new IDs from an in-memory set. create_note now lists once per call.

KeyedBatch::upsert(NoteRecord) matches frontmatter key values. It returns Upserted::Created(id), Updated(id), Unchanged(id), or Conflict { id, reason }. ConflictReason is NotLoaded or BodyChanged.

NoteRecord contains title, body, provenance, tags, links, and fields. Fields are a YAML mapping and must contain the selected key with a string value.

Stored notes

A frontmatter record mapping tracks digests and source-supplied metadata.

An unchanged record writes nothing. Metadata changes keep the body, provenance spec, and stamps. Content changes replace an untouched body and its spec and remove stamps.

A body changed on disk is never overwritten. The upsert reports a conflict and still applies any metadata change. While the conflict stands, a run with no metadata change writes nothing.

To resolve BodyChanged, set the body to the new source text. The next upsert accepts it and keeps its stamp. Alternatively, remove record to keep the hand text. A matching note without record returns NotLoaded.

The source removes only fields, tags, and links it supplied before. Personal additions stay.

Caller requirements

Use one writer per run. Each write replaces the file at its path. Normalize key values first. Values match byte for byte. Deleted loaded notes return on the next run.

DuplicateKey fails only that record. An unparseable note or malformed record mapping fails keyed_batch with UnreadableNote { id, reason }.

New IDs use the bare slug when free and a key hash suffix on collision. New slugs stop at 100 bytes at a word.

create_note listed the note directory twice per note and checked
prefixes with a linear search, so a loop of creates grew faster than
the square of its size. A batch now lists the store once and claims
each id from an in-memory set. create_note runs as a one-note batch.

Repo::keyed_batch opens a batch keyed on one frontmatter field, such as
url. KeyedBatch::upsert creates the note for a new record, updates the
note for a changed record, and writes nothing for an unchanged record.
A loaded note carries a record mapping with separate digests for
content and metadata. A metadata change keeps the body and its review
stamps. A body changed on disk is not overwritten: the upsert reports a
conflict until the body equals the source text or the mapping is
removed. A note without the mapping is never taken over. The source
removes only the fields, tags and links that it supplied.

Two records with one title get distinct slugs: the bare slug when it
is free, otherwise a suffix from the key value's digest. A new slug is
cut to 100 bytes at a word.
A change to only the title, the tags, the links, or the provenance spec
now has its own test, so a field dropped from a digest fails the suite.
A record that was updated reruns as unchanged, in the same batch and in
a new one. A standing conflict leaves the file's mtime as it was.
A record revised twice updates both times, and a body taken from the
source accepts the next revision without a conflict. The new spec is
written when a person takes the source body. Tags, links and fields
that the source added in one run go when it drops them in the next.
Field order does not change a record, and an update moves the
updated timestamp.
@cjohnhanson
cjohnhanson marked this pull request as ready for review October 10, 2026 23:56
@cjohnhanson
cjohnhanson merged commit c45b4df into main Oct 10, 2026
3 of 4 checks passed
@cjohnhanson cjohnhanson mentioned this pull request Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant