Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e5a0d7e
feat(orm): store PropertyDefinition labels as lang maps
buke Oct 28, 2026
557dc04
feat(web): merge-safe Properties definition label editing
buke Oct 28, 2026
2fdfc5c
feat(sandbox): expose Record Properties configuration entry
buke Oct 28, 2026
3a7d751
fix(schema): skip FKs for non-stored compute ManyToOne fields
buke Oct 28, 2026
ce8a8cc
feat(web): PropertyDefinition List→Form editor with selection list an…
buke Oct 28, 2026
cfd9403
fix(web): show PropertyDefinition form-help and coalesce FieldsGet RPCs
buke Oct 28, 2026
b6e885e
feat(web): add per-property help with labeled definition editor
buke Oct 28, 2026
100f95c
fix(web): keep property dialog open on commit failure and raise patch…
buke Oct 28, 2026
d31ce76
fix(web): preserve property translation maps and harden FieldsGet ove…
buke Oct 28, 2026
c6118cc
fix(web): harden FieldsGet inflight cleanup and reach patch coverage …
buke Oct 28, 2026
cfc1bc5
fix(web): settle FieldsGet waiters across cache clear and sync RPC th…
buke Oct 28, 2026
b8d8e88
fix(web): preserve selection maps on clear-retype and deep-merge Fiel…
buke Oct 28, 2026
b258e4a
fix(web): update draft expectations and harden commit/error paths
buke Oct 28, 2026
1bc90b8
fix(web): avoid caching incomplete FieldsGet slices and cover dialog/…
buke Oct 28, 2026
4686e48
test(web): cover definitionItemsToDrafts selectionText stringify failure
buke Oct 28, 2026
be74db0
fix(web): trim property name onto draft before dialog save emit
buke Oct 28, 2026
2a3229c
fix(web): harden definition draft sync, translate rows, and helper cl…
buke Oct 28, 2026
75d41f8
fix(web): avoid wipe on failed translate load; harden dialog/FieldsGe…
buke Oct 28, 2026
aba4e99
fix(web): surface writeError when property edit index goes stale
buke Oct 28, 2026
94c0e51
fix(web): keep translate labels, ignore stale loads, pin coalesce gen…
buke Oct 28, 2026
42cfa6f
fix(web): invalidate stale definition edit dialogs and cover translat…
buke Oct 29, 2026
8553e5a
fix(web): skip list-handle drag image when reorder API is unavailable
buke Oct 29, 2026
6226b3e
fix(web): clarify handle drag API guard and cover en_US save seed
buke Oct 29, 2026
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
61 changes: 58 additions & 3 deletions internal/module/evolution/schema/foreignkey.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,11 @@ func (m *foreignKeyMigrator) getManyToOneKeys() ([]ForeignKeyInfo, error) {
continue
}

// Treat fields with a select clause as virtual and skip FK creation.
if _, hasSelect := opts["select"]; hasSelect {
skip, skipErr := shouldSkipManyToOneForeignKey(field, opts)
if skipErr != nil {
return nil, skipErr
}
if skip {
continue
}

Expand Down Expand Up @@ -139,6 +142,51 @@ func (m *foreignKeyMigrator) getManyToOneKeys() ([]ForeignKeyInfo, error) {
return foreignKeys, nil
}

// shouldSkipManyToOneForeignKey skips virtual / non-column ManyToOne fields.
// Prefer ResolvedSpec.Migration.ShouldCreateColumn (COMPUTE_STORE_FALSE, related,
// sqlCompute, …); also skip @Field({ select }) and bare @Compute({ store: false })
// when ResolvedSpec is absent.
func shouldSkipManyToOneForeignKey(field *meta.Field, opts map[string]interface{}) (bool, error) {
if _, hasSelect := opts["select"]; hasSelect {
return true, nil
}
if field == nil {
return false, nil
}
resolved, err := field.GetResolvedSpec()
if err != nil {
return false, fmt.Errorf("error unmarshal field resolved spec for %s: %w", field.Name, err)
}
if resolved != nil {
return !resolved.Migration.ShouldCreateColumn, nil
}
return fieldHasNonStoredComputeDecorator(field), nil
}

func fieldHasNonStoredComputeDecorator(field *meta.Field) bool {
if field == nil {
return false
}
for _, decorator := range field.Decorators {
if decorator == nil || decorator.Name != "Compute" {
continue
}
for _, arg := range decorator.Arguments {
if arg == nil || arg.Type != "ObjectLiteral" || arg.Value == "" {
continue
}
var computeOpts map[string]interface{}
if err := json.Unmarshal([]byte(arg.Value), &computeOpts); err != nil {
continue
}
if store, ok := computeOpts["store"].(bool); ok && !store {
return true
}
}
}
return false
}

func (m *foreignKeyMigrator) resolveTargetModelByPath(targetModelPath string) (*meta.Model, error) {
for _, model := range m.models {
if model != nil && model.Path == targetModelPath {
Expand Down Expand Up @@ -235,7 +283,14 @@ func (m *foreignKeyMigrator) createForeignKeys(foreignKeys []ForeignKeyInfo) err
// Create the new foreign key.
createSQL := builder.BuildForeignKeySQL(fk)
if err := db.Exec(createSQL).Error; err != nil {
return fmt.Errorf("create foreign key failed: %v", err)
return fmt.Errorf(
"create foreign key failed: table=%s column=%s refer=%s.%s: %w",
fk.TableName,
fk.ColumnName,
fk.ReferTableName,
fk.ReferColumnName,
err,
)
}
}

Expand Down
276 changes: 275 additions & 1 deletion internal/module/evolution/schema/foreignkey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,11 +250,285 @@ func TestForeignKeyMigratorRuntimePaths(t *testing.T) {
fkMigrator := newForeignKeyMigrator(runtimeScope, nil, nil).(*foreignKeyMigrator)
fks := []ForeignKeyInfo{{TableName: "sales_order", ColumnName: "owner_id", ReferTableName: "sales_user", ReferColumnName: "id", OnDelete: "CASCADE"}}

if err := fkMigrator.createForeignKeys(fks); err == nil || !strings.Contains(err.Error(), "create foreign key failed") {
err := fkMigrator.createForeignKeys(fks)
if err == nil || !strings.Contains(err.Error(), "create foreign key failed") {
t.Fatalf("expected create foreign key error, got %v", err)
}
for _, want := range []string{
"table=sales_order",
"column=owner_id",
"refer=sales_user.id",
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("expected %q in error, got %v", want, err)
}
}
if err := fkMigrator.MigrateForeignKeys(); err != nil {
t.Fatalf("MigrateForeignKeys() with no keys error = %v", err)
}
})

t.Run("skips non-stored compute many-to-one via ResolvedSpec", func(t *testing.T) {
runtimeScope := newSchemaTestScope(t)
target := &meta.Model{Name: "Line", Path: "sandbox/line.ts", ModelTable: "sandbox_line"}
stored := newRelationField("OwnerId", "sandbox/line", `{"type":"ManyToOne"}`)
virtual := newRelationField("PrimaryLineId", "sandbox/line", `{"type":"ManyToOne","index":true,"readonly":true}`)
if err := virtual.SetResolvedSpec(&meta.FieldResolvedSpec{
FieldName: "PrimaryLineId",
Structural: meta.FieldStructuralSpec{
Name: "PrimaryLineId",
FieldType: "ManyToOne",
},
Migration: meta.FieldMigrationDecision{
StorageKind: "virtualRuntime",
ShouldCreateColumn: false,
ReasonCode: "COMPUTE_STORE_FALSE",
},
}); err != nil {
t.Fatalf("SetResolvedSpec: %v", err)
}
source := &meta.Model{
Name: "Record",
Path: "sandbox/record.ts",
ModelTable: "sandbox_record",
Fields: []*meta.Field{stored, virtual},
}

keys, err := newForeignKeyMigrator(runtimeScope, nil, []*meta.Model{source, target}).(*foreignKeyMigrator).getManyToOneKeys()
if err != nil {
t.Fatalf("getManyToOneKeys() error = %v", err)
}
if len(keys) != 1 || keys[0].ColumnName != "owner_id" {
t.Fatalf("expected only stored owner_id FK, got %#v", keys)
}
})

t.Run("skips non-stored compute many-to-one via Compute decorator fallback", func(t *testing.T) {
runtimeScope := newSchemaTestScope(t)
target := &meta.Model{Name: "Line", Path: "sandbox/line.ts", ModelTable: "sandbox_line"}
virtual := &meta.Field{
Name: "PrimaryLineId",
ModuleSpecPath: "sandbox/line",
Decorators: []*meta.Decorator{
{
Name: "Field",
Arguments: []*meta.Argument{{Type: "ObjectLiteral", Value: `{"type":"ManyToOne"}`}},
},
{
Name: "Compute",
Arguments: []*meta.Argument{
{Type: "StringLiteral", Value: "PrimaryLineId"},
{Type: "ObjectLiteral", Value: `{"deps":["Lines.Id"],"store":false}`},
},
},
},
}
source := &meta.Model{
Name: "Record",
Path: "sandbox/record.ts",
ModelTable: "sandbox_record",
Fields: []*meta.Field{virtual},
}

keys, err := newForeignKeyMigrator(runtimeScope, nil, []*meta.Model{source, target}).(*foreignKeyMigrator).getManyToOneKeys()
if err != nil {
t.Fatalf("getManyToOneKeys() error = %v", err)
}
if len(keys) != 0 {
t.Fatalf("expected no FK for store:false Compute, got %#v", keys)
}
})
}

func TestShouldSkipManyToOneForeignKey(t *testing.T) {
t.Parallel()

t.Run("select option skips without consulting field", func(t *testing.T) {
skip, err := shouldSkipManyToOneForeignKey(nil, map[string]interface{}{"select": "expr"})
if err != nil || !skip {
t.Fatalf("expected skip for select, got skip=%v err=%v", skip, err)
}
})

t.Run("nil field without select does not skip", func(t *testing.T) {
skip, err := shouldSkipManyToOneForeignKey(nil, map[string]interface{}{})
if err != nil || skip {
t.Fatalf("expected no skip for nil field, got skip=%v err=%v", skip, err)
}
})

t.Run("GetResolvedSpec error wraps field name", func(t *testing.T) {
field := &meta.Field{Name: "OwnerId", ResolvedSpec: `{not-json`}
_, err := shouldSkipManyToOneForeignKey(field, map[string]interface{}{})
if err == nil || !strings.Contains(err.Error(), "error unmarshal field resolved spec for OwnerId") {
t.Fatalf("expected resolved-spec unwrap error, got %v", err)
}
})

t.Run("getManyToOneKeys propagates shouldSkip ResolvedSpec errors", func(t *testing.T) {
runtimeScope := newSchemaTestScope(t)
target := &meta.Model{Name: "Line", Path: "sandbox/line.ts", ModelTable: "sandbox_line"}
bad := &meta.Field{
Name: "OwnerId",
ModuleSpecPath: "sandbox/line",
ResolvedSpec: `{not-json`,
Decorators: []*meta.Decorator{
{Name: "Field", Arguments: []*meta.Argument{{Type: "ObjectLiteral", Value: `{"type":"ManyToOne"}`}}},
},
}
source := &meta.Model{
Name: "Record",
Path: "sandbox/record.ts",
ModelTable: "sandbox_record",
Fields: []*meta.Field{bad},
}
_, err := newForeignKeyMigrator(runtimeScope, nil, []*meta.Model{source, target}).(*foreignKeyMigrator).getManyToOneKeys()
if err == nil || !strings.Contains(err.Error(), "error unmarshal field resolved spec for OwnerId") {
t.Fatalf("expected getManyToOneKeys to propagate skipErr, got %v", err)
}
})

t.Run("ResolvedSpec ShouldCreateColumn true/false", func(t *testing.T) {
stored := &meta.Field{Name: "OwnerId"}
if err := stored.SetResolvedSpec(&meta.FieldResolvedSpec{
FieldName: "OwnerId",
Migration: meta.FieldMigrationDecision{ShouldCreateColumn: true},
}); err != nil {
t.Fatalf("SetResolvedSpec: %v", err)
}
skip, err := shouldSkipManyToOneForeignKey(stored, nil)
if err != nil || skip {
t.Fatalf("expected keep stored column FK, got skip=%v err=%v", skip, err)
}

virtual := &meta.Field{Name: "PrimaryLineId"}
if err := virtual.SetResolvedSpec(&meta.FieldResolvedSpec{
FieldName: "PrimaryLineId",
Migration: meta.FieldMigrationDecision{ShouldCreateColumn: false, ReasonCode: "COMPUTE_STORE_FALSE"},
}); err != nil {
t.Fatalf("SetResolvedSpec: %v", err)
}
skip, err = shouldSkipManyToOneForeignKey(virtual, nil)
if err != nil || !skip {
t.Fatalf("expected skip virtual column, got skip=%v err=%v", skip, err)
}
})

t.Run("falls back to Compute decorator when ResolvedSpec absent", func(t *testing.T) {
virtual := &meta.Field{
Name: "PrimaryLineId",
Decorators: []*meta.Decorator{{
Name: "Compute",
Arguments: []*meta.Argument{
{Type: "ObjectLiteral", Value: `{"store":false}`},
},
}},
}
skip, err := shouldSkipManyToOneForeignKey(virtual, map[string]interface{}{})
if err != nil || !skip {
t.Fatalf("expected skip via Compute store:false, got skip=%v err=%v", skip, err)
}
})
}

func TestFieldHasNonStoredComputeDecorator(t *testing.T) {
t.Parallel()

if fieldHasNonStoredComputeDecorator(nil) {
t.Fatal("nil field must be false")
}

cases := []struct {
name string
field *meta.Field
want bool
}{
{
name: "empty decorators",
field: &meta.Field{Name: "X"},
want: false,
},
{
name: "nil decorator skipped",
field: &meta.Field{
Name: "X",
Decorators: []*meta.Decorator{nil},
},
want: false,
},
{
name: "non-Compute decorator ignored",
field: &meta.Field{
Name: "X",
Decorators: []*meta.Decorator{{
Name: "Field",
Arguments: []*meta.Argument{{Type: "ObjectLiteral", Value: `{"store":false}`}},
}},
},
want: false,
},
{
name: "Compute store true does not skip",
field: &meta.Field{
Name: "X",
Decorators: []*meta.Decorator{{
Name: "Compute",
Arguments: []*meta.Argument{
{Type: "ObjectLiteral", Value: `{"store":true}`},
},
}},
},
want: false,
},
{
name: "Compute store false skips",
field: &meta.Field{
Name: "X",
Decorators: []*meta.Decorator{{
Name: "Compute",
Arguments: []*meta.Argument{
{Type: "StringLiteral", Value: "fn"},
{Type: "ObjectLiteral", Value: `{"deps":["A"],"store":false}`},
},
}},
},
want: true,
},
{
name: "invalid Compute JSON ignored",
field: &meta.Field{
Name: "X",
Decorators: []*meta.Decorator{{
Name: "Compute",
Arguments: []*meta.Argument{
{Type: "ObjectLiteral", Value: `{bad`},
},
}},
},
want: false,
},
{
name: "empty ObjectLiteral value ignored",
field: &meta.Field{
Name: "X",
Decorators: []*meta.Decorator{{
Name: "Compute",
Arguments: []*meta.Argument{
nil,
{Type: "ObjectLiteral", Value: ""},
{Type: "StringLiteral", Value: `{"store":false}`},
},
}},
},
want: false,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := fieldHasNonStoredComputeDecorator(tc.field); got != tc.want {
t.Fatalf("got %v, want %v", got, tc.want)
}
})
}
}
11 changes: 6 additions & 5 deletions internal/module/lifecycle/install_tx_timing.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (

// LogModuleCommitTxHold records how long a per-module Commit Required
// transaction was held (install / upgrade / uninstall). Successful holds stay
// Debug (ops metric); failures stay Info.
// Debug (ops metric); failures log at Error.
func LogModuleCommitTxHold(logger *slog.Logger, op string, caller string, started time.Time, err error) {
if logger == nil || started.IsZero() {
return
Expand All @@ -36,13 +36,14 @@ func logModuleCommitTxHold(logger *slog.Logger, op string, caller string, durati
"caller", caller,
"duration_ms", duration.Milliseconds(),
}
msg := op + " commit transaction hold completed"
if err != nil {
attrs = append(attrs, "error", err.Error())
logger.Info(msg, attrs...)
// Pass the error value (not err.Error()) so console/devslog/json
// ReplaceAttr can expand xfmt / QuickJS frames into a stack trace.
attrs = append(attrs, "error", err)
logger.Error(op+" commit transaction hold failed", attrs...)
return
}
logger.Debug(msg, attrs...)
logger.Debug(op+" commit transaction hold completed", attrs...)
}

// LogInstallOuterTxHold records install Commit / legacy outer Required hold time.
Expand Down
Loading
Loading