Skip to content

False-positive JS view migration: identical view is planned as RemoveView + AddView #4842

@pedrobzz

Description

@pedrobzz

PS: This issue has the help of GPT 5.4 xhigh, I identified the problem, tried to fix it myself, gave the steps to reproduce and it did the rest. The bug is 100% real, the solution may be hallucination but it's there to try to help this issue to be resolved faster. I'm not a Rust developer

Description of the error

In a JS-hosted module on SpacetimeDB 2.1.0, spacetime dev / pre_publish can report:

  • Removed view: my_chats
  • Created view: my_chats

even when the view source is unchanged.

I reproduced this with a stronger check than "same source twice": I published one dist/bundle.js to a fresh local database, then called pre_publish again against that same database using the exact same bundle bytes. The planner still reported RemoveView + AddView for my_chats.

Expected behavior:
the view should be treated as unchanged or at most UpdateView / recompute-only.

Actual behavior:
the planner treats the view as incompatible and emits a breaking remove/create plan.

Table and view used to reproduce

import { type Infer, t, table } from "spacetimedb/server";

export const MessageRow = t.row("Message", { // same error with t.object and t.row
  id: t.string(),
  role: t.string(),
  content: t.string(),
});

export const Chat = t.row("Chat", {
  id: t.u64().primaryKey().autoInc(),
  identity: t.identity().index(),
  messages: t.array(MessageRow),
});

export const chatsTable = table(
  {
    name: "chats",
    public: false,
    indexes: [] as const,
  },
  Chat
);

export type Chat = Infer<typeof chatsTable.rowType>;
export type Message = Infer<typeof MessageRow>;
import spacetimedb from "../../module";
import { chats } from "../../schemas";
import { t } from "spacetimedb/server";

export const chatsView = spacetimedb.view(
  { name: "my_chats", public: true },
  t.array(chats.rowType),
  (ctx) => {
    return Array.from(ctx.db.chats.identity.filter(ctx.sender));
  }
);

Steps to reproduce

  1. Create a JS SpacetimeDB module with the table and view above.
  2. Build it:
    spacetime build --debug
  3. Publish it once to a local DB:
    spacetime publish view-repro --server local --yes --js-path dist/bundle.js --no-config
  4. Without changing any source code or the generated bundle, call pre_publish again on the same DB using the same bundle:
    curl -X POST \
      "http://127.0.0.1:3000/v1/database/<db_identity>/pre_publish?host_type=Js&pretty_print_style=NoColor" \
      -H "Authorization: Bearer <token>" \
      --data-binary @dist/bundle.js
  5. Observe that the migration plan still contains:
    • Removed view: my_chats
    • Created view: my_chats

Optional sanity check:
spacetime describe --json shows the same public view definition before and after. In my repro, my_chats matched on name, index, is_public, is_anonymous, params, and return_type.

Why it is probably failing

This looks like a false positive in view compatibility checking.

ModuleDefLookup for ViewColumnDef and ViewParamDef already avoids using ColId and matches by (view_name, column_name) instead, which suggests ColId is not intended to be portable across migrations.

However, auto_migrate_view appears to still treat old.col_id != new.col_id as a breaking incompatibility for view params / return columns.

If internal view col_id values are recomputed differently between:

  • the currently running module definition, and
  • the freshly extracted JS module definition

then the planner can emit AddView + RemoveView even though the public ABI of the view did not change.

That matches what I saw:

  • same bundle bytes
  • same described public schema
  • still a breaking remove/create plan

Likely code location

The issue is probably in:

  • crates/schema/src/auto_migrate.rs
    • auto_migrate_view(...)
  • crates/schema/src/def.rs
    • impl ModuleDefLookup for ViewColumnDef
    • impl ModuleDefLookup for ViewParamDef

There is also a related UX problem in:

  • crates/schema/src/auto_migrate/formatter.rs

because UpdateView is intentionally omitted from the formatted plan, so a false incompatibility only shows up as Removed view / Created view, which makes the problem look like a real destructive schema change.

Suggested fix (Suggested by GPT 5.4 xhigh. May not be the real fix, I'm not a Rust developer)

In auto_migrate_view, do not treat raw view col_id equality as part of the public compatibility contract.

Instead, consider a view breaking only when one of these changes:

  • a public param or return column is added or removed
  • the public order of params or return columns changes
  • a param or return column type changes
  • is_anonymous changes

Otherwise, the planner should emit UpdateView instead of AddView + RemoveView.

A good regression test would be:

  • create two otherwise identical view defs
  • change only ViewColumnDef.col_id or ViewParamDef.col_id
  • assert the plan is UpdateView
  • assert it does not contain AddView / RemoveView

Extra evidence

I reproduced this with the exact same published bundle against the exact same database, so this does not require a real schema change in user code.

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions