Skip to content

feat(api): expose _updatedAt on document metadata - #141

Merged
hsinatfootprintai merged 1 commit into
mainfrom
fix/document-updated-at
Aug 16, 2026
Merged

feat(api): expose _updatedAt on document metadata#141
hsinatfootprintai merged 1 commit into
mainfrom
fix/document-updated-at

Conversation

@hsinatfootprintai

Copy link
Copy Markdown
Contributor

Writing a document that already exists is an upsertCreateDocument with an existing documentId replaces the payload, and there is no Update rpc. But the metadata returned to callers carried only createdAt and deletedAt, so an overwritten document was indistinguishable from one written once, in content and in metadata. A caller could not tell that its data had been replaced, or when.

This is not a migration

ModelDocument has carried updated_at all along, and Write's OnConflict already lists it in DoUpdates while deliberately omitting created_at:

DoUpdates: clause.AssignmentColumns([]string{"updated_at", "deleted_at", "data"})

So the database has been recording this correctly the whole time — only the API was hiding it. The change is one proto field and one line of mapping. No schema change, no backfill, nothing to run against existing data.

Why non-optional

Matching _createdAt rather than _deletedAt. gorm stamps updated_at on insert as well as on update, so every document has one, and on a document never rewritten it equals _createdAt. There is no such thing as a document without an updatedAt, so making it optional would invite callers to handle a case that cannot occur.

Field 13; 12 was the last used.

Generated output

Regenerated with the pinned tooling. Worth noting gen-swagger-go-client.sh refused to run against the go-swagger v0.31.0 that was on my PATH and demanded the pinned v0.33.0 — which is exactly why that guard exists, since the generator's output is not stable across releases. make gen-check passes.

Tests, in two layers

They answer different questions, and the second is the one that makes the field meaningful.

dto_test.go — the mapping. Includes the distinguishing case directly: a rewritten document and a fresh one must now be tellable apart through the API, where before they differed in nothing observable. Also pins that updatedAt survives alongside deletedAt — the previous mapping set XDeletedAt to nil and then conditionally populated it, which is the shape that loses a field silently.

TestUpsertAdvancesUpdatedAtAndPreservesCreatedAt — the behaviour, against real postgres. The second write must advance updated_at, leave created_at alone, replace the payload, and not append a row.

That last test is deliberately pinning storage behaviour this PR does not change, because the new API field is only useful while those two columns move independently. If created_at ever joined the DoUpdates set, _updatedAt would still be populated and would still tell a caller nothing — the feature would silently become decorative. This test fails in that case.

It sleeps 10ms between writes: postgres stores microseconds, and without a pause the two writes can land on the same instant, making the assertion pass or fail on timing rather than on behaviour.

ok  github.com/footprintai/restcol/pkg/models/documents
ok  github.com/footprintai/restcol/pkg/storage/documents
... all packages green

CI runs a real postgres service and deliberately omits -short, so the storage test above runs there rather than skipping.

Context

Found while answering whether we have an e2e covering two pipelines under different user accounts, one creating a restcol document and the other updating it. We don't, and FootprintAI/grandturks#1125 records why. This closes one of the three reasons: even with per-user identity solved, "B updated A's document" would previously have left no trace at all. The identity half remains open there.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BSzNfcVc1FnDCfk9AF68Eh

Writing a document that already exists is an UPSERT - CreateDocument with
an existing documentId replaces the payload, and there is no Update rpc.
The metadata returned to callers carried only createdAt and deletedAt, so
an overwritten document was indistinguishable from one written once, in
content and in metadata alike. A caller could not tell that its data had
been replaced, or when.

NOT A NEW COLUMN, and not a migration. ModelDocument has carried
updated_at all along, and Write's OnConflict already lists it in
DoUpdates while deliberately omitting created_at:

  DoUpdates: clause.AssignmentColumns([]string{"updated_at", "deleted_at", "data"})

So the database has been recording this correctly the whole time and only
the API was hiding it. The fix is one proto field and one line of mapping.

Non-optional, matching _createdAt rather than _deletedAt: gorm stamps
updated_at on insert as well as on update, so every document has one, and
on a document never rewritten it equals _createdAt. There is no such thing
as a document without an updatedAt, so making it optional would invite
callers to handle a case that cannot occur.

Field 13; 12 was the last used.

Generated output regenerated with the pinned tools (buf, and go-swagger
v0.33.0 as gen-swagger-go-client.sh requires - it refuses to run against
the v0.31.0 that was on PATH, which is why the pin exists).

Tests come in two layers because they answer different questions:

  - dto_test.go asserts the MAPPING, including that a rewritten document
    and a fresh one are now distinguishable through the API, and that
    updatedAt survives alongside deletedAt rather than one overwriting
    the other.

  - TestUpsertAdvancesUpdatedAtAndPreservesCreatedAt asserts the
    BEHAVIOUR against real postgres: the second write advances
    updated_at, leaves created_at alone, replaces the payload, and does
    not append a row. This pins what makes the new field meaningful - if
    created_at ever joined the DoUpdates set, _updatedAt would still be
    populated and would still tell a caller nothing.

Refs FootprintAI/grandturks#1125

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSzNfcVc1FnDCfk9AF68Eh
@hsinatfootprintai
hsinatfootprintai merged commit 838d4ca into main Aug 16, 2026
2 checks passed
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.

2 participants