feat(api): expose _updatedAt on document metadata - #141
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Writing a document that already exists is an upsert —
CreateDocumentwith an existingdocumentIdreplaces the payload, and there is noUpdaterpc. But the metadata returned to callers carried onlycreatedAtanddeletedAt, 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
ModelDocumenthas carriedupdated_atall along, andWrite'sOnConflictalready lists it inDoUpdateswhile deliberately omittingcreated_at: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
_createdAtrather than_deletedAt. gorm stampsupdated_aton 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 anupdatedAt, 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.shrefused 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-checkpasses.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 thatupdatedAtsurvives alongsidedeletedAt— the previous mapping setXDeletedAtto 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 advanceupdated_at, leavecreated_atalone, 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_atever joined theDoUpdatesset,_updatedAtwould 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.
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