diff --git a/data/model.go b/data/model.go index baf3e21..fb5293f 100644 --- a/data/model.go +++ b/data/model.go @@ -106,7 +106,15 @@ func (model *BaseModel) BeforeCreate(db *gorm.DB) error { if model.Version <= 0 { created, err := createdAtFromID(model.ID) if err != nil { - return err + // Caller-supplied non-xid IDs — typically from test fixtures or + // legacy migrations — cannot derive a deterministic timestamp. + // Fall back to time.Now() and warn so the invariant violation is + // visible without blocking the insert. + util.Log(db.Statement.Context). + WithError(err). + WithField("id", model.ID). + Warn("BaseModel.ID is not a valid xid; falling back to time.Now() for CreatedAt") + created = time.Now() } model.CreatedAt = created model.ModifiedAt = created @@ -116,8 +124,9 @@ func (model *BaseModel) BeforeCreate(db *gorm.DB) error { } // createdAtFromID returns the time component embedded in a generated xid. -// All BaseModel IDs must be valid xids so sort-by-id ≡ sort-by-created_at -// and hypertable promotions retain monotonic time ordering. +// Production IDs are always xids (via util.IDString), so this is the hot +// path. Non-xid IDs return an error so callers — e.g. BeforeCreate — can +// decide whether to bail out or fall back. func createdAtFromID(id string) (time.Time, error) { parsed, err := xid.FromString(id) if err != nil { diff --git a/datastore/repository_test.go b/datastore/repository_test.go index 4e32061..dcccb97 100644 --- a/datastore/repository_test.go +++ b/datastore/repository_test.go @@ -370,26 +370,25 @@ func (s *RepositoryTestSuite) TestCreate() { name: "create entity with pre-set xid", setupEntity: func(_ context.Context) *TestEntity { entity := &TestEntity{ - Name: "Entity with ID", + Name: "Entity with xid", } - // Caller-supplied IDs must be valid xids so CreatedAt can be - // derived deterministically from the embedded timestamp. entity.ID = util.IDString() return entity }, expectError: false, }, { - name: "create entity with non-xid ID should fail", + name: "create entity with non-xid ID falls back to time.Now()", setupEntity: func(_ context.Context) *TestEntity { entity := &TestEntity{ Name: "Entity with legacy id", } + // Non-xid IDs (typically test fixtures) are accepted with a + // WARN log; CreatedAt falls back to time.Now(). entity.ID = fmt.Sprintf("custom-id-%d", time.Now().UnixNano()) return entity }, - expectError: true, - errorMsg: "is not a valid xid", + expectError: false, }, { name: "create entity with version > 0 should fail",