Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions api/go-openapiv2/models/api_data_metadata.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions api/openapiv2/restcol.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,11 @@
},
"dataformat": {
"$ref": "#/definitions/apiDataFormat"
},
"UpdatedAt": {
"type": "string",
"format": "date-time",
"description": "ts when the record was last written.\n\nWriting a document that already exists is an UPSERT - CreateDocument with\nan existing documentId replaces the payload - and there is no separate\nUpdate rpc. Without this field an overwritten document is\nindistinguishable from one written once, in both content and metadata, so\na caller cannot tell that its data was replaced or when.\n\nNOT a new column: ModelDocument has carried `updated_at` all along and\ngorm has been maintaining it on every write. This only surfaces what the\ndatabase already records.\n\nNon-optional, matching _createdAt rather than _deletedAt: gorm sets\nupdated_at on create as well as on update, so every document has one. On\na document that has never been rewritten it equals _createdAt."
}
}
},
Expand Down
684 changes: 356 additions & 328 deletions api/pb/restcol.pb.go

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions api/restcol.proto
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,23 @@ message DataMetadata {
google.protobuf.Timestamp _createdAt = 10; // ts when the record was created
optional google.protobuf.Timestamp _deletedAt = 11; // ts when the record was deleted
optional DataFormat dataformat = 12;

// ts when the record was last written.
//
// Writing a document that already exists is an UPSERT - CreateDocument with
// an existing documentId replaces the payload - and there is no separate
// Update rpc. Without this field an overwritten document is
// indistinguishable from one written once, in both content and metadata, so
// a caller cannot tell that its data was replaced or when.
//
// NOT a new column: ModelDocument has carried `updated_at` all along and
// gorm has been maintaining it on every write. This only surfaces what the
// database already records.
//
// Non-optional, matching _createdAt rather than _deletedAt: gorm sets
// updated_at on create as well as on update, so every document has one. On
// a document that has never been rewritten it equals _createdAt.
google.protobuf.Timestamp _updatedAt = 13;
}


Expand Down
6 changes: 6 additions & 0 deletions pkg/models/documents/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ func NewPbDocumentMetadata(md *ModelDocument) *apppb.DataMetadata {
//Dataformat: nil;
// FIXME(hsiny): need to fix dataformat
XCreatedAt: timestamppb.New(md.CreatedAt),
// Surfaced so a caller can tell an overwritten document from one written
// once. Writing an existing documentId is an upsert and there is no
// Update rpc, so before this the two were indistinguishable in both
// content and metadata. gorm has always maintained the column; only the
// API was hiding it.
XUpdatedAt: timestamppb.New(md.UpdatedAt),
XDeletedAt: nil,
}
if deletedAt, _ := md.DeletedAt.Value(); deletedAt != nil {
Expand Down
96 changes: 96 additions & 0 deletions pkg/models/documents/dto_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package modeldocuments

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"

modelcollections "github.com/footprintai/restcol/pkg/models/collections"
modelprojects "github.com/footprintai/restcol/pkg/models/projects"
)

// The metadata a caller sees has to say when a document was last written.
//
// Writing an existing documentId is an UPSERT - there is no Update rpc - so
// without _updatedAt an overwritten document is indistinguishable from one
// written once, in content and in metadata alike. A caller cannot tell that its
// data was replaced, or when.
//
// The column was never missing: ModelDocument has always carried updated_at and
// the upsert in DocumentCURD.Write lists it in DoUpdates. Only the API hid it,
// which is why this is a mapping fix rather than a migration.

func fixture(created, updated time.Time) *ModelDocument {
return &ModelDocument{
ID: NewDocumentID(),
CreatedAt: created,
UpdatedAt: updated,
Data: NewModelDocumentData(map[string]interface{}{"k": "v"}),
ModelCollectionID: modelcollections.CollectionID("col-1"),
ModelProjectID: modelprojects.ProjectID("proj-1"),
}
}

func TestUpdatedAtIsSurfaced(t *testing.T) {
created := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
updated := time.Date(2026, 8, 9, 17, 30, 0, 0, time.UTC)

md := NewPbDocumentMetadata(fixture(created, updated))

require.NotNil(t, md.XUpdatedAt, "_updatedAt must be populated; a nil here is the gap this fixes")
assert.Equal(t, updated.UTC(), md.XUpdatedAt.AsTime().UTC())
assert.Equal(t, created.UTC(), md.XCreatedAt.AsTime().UTC(),
"_createdAt must not be overwritten by the new field")
}

// The distinguishing case, and the whole point: two documents that differ ONLY
// in having been rewritten must be distinguishable through the API.
func TestARewrittenDocumentIsDistinguishableFromAFreshOne(t *testing.T) {
created := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)

fresh := NewPbDocumentMetadata(fixture(created, created))
rewritten := NewPbDocumentMetadata(fixture(created, created.Add(48*time.Hour)))

assert.Equal(t, fresh.XCreatedAt.AsTime(), rewritten.XCreatedAt.AsTime(),
"createdAt is identical - it is the only thing these two shared before")
assert.True(t,
rewritten.XUpdatedAt.AsTime().After(fresh.XUpdatedAt.AsTime()),
"the rewritten document must report a later _updatedAt; if these compare "+
"equal the metadata still cannot tell an overwrite from a first write")
}

// On a document nobody has rewritten, updatedAt equals createdAt - gorm sets
// both on insert. Pinned because it justifies the field being non-optional:
// there is no such thing as a document without an updatedAt.
func TestNeverRewrittenReportsUpdatedEqualToCreated(t *testing.T) {
at := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)

md := NewPbDocumentMetadata(fixture(at, at))

require.NotNil(t, md.XUpdatedAt)
assert.Equal(t, md.XCreatedAt.AsTime(), md.XUpdatedAt.AsTime())
}

// A soft-deleted document still reports when it was last written. The three
// timestamps are independent and must not overwrite one another - an earlier
// version of this mapping set XDeletedAt to nil unconditionally and then
// populated it, which is exactly the shape that loses a field silently.
func TestDeletedAtAndUpdatedAtCoexist(t *testing.T) {
created := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
updated := created.Add(time.Hour)
deleted := created.Add(2 * time.Hour)

md := fixture(created, updated)
md.DeletedAt = gorm.DeletedAt{Time: deleted, Valid: true}

pb := NewPbDocumentMetadata(md)

require.NotNil(t, pb.XDeletedAt, "deletedAt must still be populated")
require.NotNil(t, pb.XUpdatedAt, "updatedAt must survive alongside deletedAt")
assert.Equal(t, deleted.UTC(), pb.XDeletedAt.AsTime().UTC())
assert.Equal(t, updated.UTC(), pb.XUpdatedAt.AsTime().UTC())
assert.Equal(t, created.UTC(), pb.XCreatedAt.AsTime().UTC())
}
69 changes: 69 additions & 0 deletions pkg/storage/documents/documents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,72 @@ func TestDocumentCURD_DeleteByCollection_IsPermanent(t *testing.T) {
Count(&remaining).Error)
assert.Zero(t, remaining, "the cascade must remove the rows, not tombstone them")
}

// Writing an existing documentId is an UPSERT - there is no Update rpc - and
// the metadata has to record that it happened. Before FootprintAI/restcol#141
// the API exposed only createdAt and deletedAt, so an overwritten document was
// indistinguishable from one written once.
//
// The storage layer was already correct: Write's OnConflict lists updated_at in
// DoUpdates and deliberately omits created_at. This pins that behaviour, because
// the new API field is only meaningful if these two move independently - if
// created_at ever joined the DoUpdates set, _updatedAt would still be populated
// and still tell a caller nothing.
func TestUpsertAdvancesUpdatedAtAndPreservesCreatedAt(t *testing.T) {
if testing.Short() {
t.Skip("needs postgres")
return
}
ctx := context.Background()
postgrescli, err := storagetestutils.NewTestPostgresCli(logger.NewLogger(false))
assert.NoError(t, err)

regularProject, _, err := storageprojects.TestProjectSuite(postgrescli)
assert.Nil(t, err)
collection, err := storagecollectionstestutils.TestCollectionSuite(postgrescli, regularProject)
assert.NoError(t, err)

dcrud := NewDocumentCURD(postgrescli)
assert.Nil(t, dcrud.AutoMigrate())

docID := appmodeldocuments.NewDocumentID()
write := func(v string) {
assert.NoError(t, dcrud.Write(ctx, "", &appmodeldocuments.ModelDocument{
ID: docID,
Data: appmodeldocuments.NewModelDocumentData(map[string]interface{}{"v": v}),
ModelCollectionID: collection.ID,
ModelProjectID: regularProject.ID,
}))
}

write("first")
first, err := dcrud.Get(ctx, "", regularProject.ID, collection.ID, docID)
assert.NoError(t, err)

// A fresh row: gorm stamps both columns on insert, which is what makes
// _updatedAt safe to expose as non-optional.
assert.False(t, first.UpdatedAt.IsZero(), "updated_at must be set on insert")
assert.WithinDuration(t, first.CreatedAt, first.UpdatedAt, time.Second,
"on a document written once, updated_at should match created_at")

// Postgres stores microseconds, so without a pause the two writes can land
// on the same instant and the assertion below would pass or fail on timing
// rather than on behaviour.
time.Sleep(10 * time.Millisecond)

write("second")
second, err := dcrud.Get(ctx, "", regularProject.ID, collection.ID, docID)
assert.NoError(t, err)

assert.True(t, second.UpdatedAt.After(first.UpdatedAt),
"the upsert must advance updated_at; without it an overwrite leaves no trace")
assert.WithinDuration(t, first.CreatedAt, second.CreatedAt, time.Millisecond,
"created_at must survive the upsert - it is what _updatedAt is compared against")

// And the upsert really did replace the payload rather than insert a second
// row, which is the premise the timestamps are describing.
assert.Equal(t, "second", second.Data.MapValue["v"])
count, err := dcrud.CountByCollection(ctx, "", regularProject.ID, collection.ID)
assert.NoError(t, err)
assert.EqualValues(t, 1, count, "upsert must not append a second row")
}
Loading