Skip to content

feat(db): ordered snapshot / layout-revision contract (RFC #1623 phase 4)#1669

Open
kevin-dp wants to merge 8 commits into
refactor/live-query-observerfrom
phase4/ordered-layout-contract
Open

feat(db): ordered snapshot / layout-revision contract (RFC #1623 phase 4)#1669
kevin-dp wants to merge 8 commits into
refactor/live-query-observerfrom
phase4/ordered-layout-contract

Conversation

@kevin-dp

Copy link
Copy Markdown
Contributor

Phase 4 of the live-query platform RFC (#1623): the ordered snapshot / layout contract. Stacked on #1642 (observer migration) — review/merge that first; this PR's base is refactor/live-query-observer so the diff is Phase 4 only.

Problem

An orderBy live query that reorders its rows without changing any projected row value (an "order-only move") is swallowed by the collection's value-diff: .values()/.entries() re-sort internally, but no change event fires, so useLiveQuery keeps rendering the stale order. This is the last universal expected-fail in the cross-adapter conformance suite (tracked as #1601).

Approach

The RFC is explicit that this should be "an explicit layout-revision requirement, not a hidden forced-update path". So instead of forging a row update:

  • The live-query flush captures the retracted side of each change and, after commit(), detects an order-only move (projected value deep-equal, orderByIndex moved) and publishes a first-class empty layout-change notification via a new CollectionChangesManager.emitLayoutChangeEvent(). It reuses the existing empty-batch delivery already used for the ready signal — subscribers re-read, nothing is faked.
  • The shared observer snapshot gains layoutRevision, which increments on any visible membership, ordering, or order-only-move change. This is the canonical contract the RFC wants for future fine-grained materializers; adapters currently pick up the reorder through their existing wholesale re-read.

Result

order-only-move is removed from UNIVERSAL_EXPECTED_FAIL and now passes on all five adapters (React, Vue, Svelte, Solid, Angular) — 26/26 conformance each. Full @tanstack/db suite green (2464 tests), all five adapter suites green.

Coverage

  • packages/db/tests/live-query-order-only-move.test.ts — core mechanism: republish + layoutRevision bump on an order-only move; no bump when order is unchanged (no spurious notification); bump on membership change.
  • Conformance order-only-move scenario now a real pass across all adapters.

Relationship to #1601

@v-anton's #1601 fixes the same bug via a forced row update. This PR takes the RFC-sanctioned layout-revision approach instead (a distinct, first-class notification + layoutRevision), so it supersedes rather than duplicates that path. Happy to coordinate on which lands — flagging for maintainer decision.

🤖 Generated with Claude Code

…1623 phase 4)

An `orderBy` live query that reorders its rows without changing any projected
row value (an "order-only move") was swallowed by the collection's value-diff:
`.values()`/`.entries()` re-sorted, but no change event fired, so subscribers
kept the stale order. This is the last universal expected-fail in the
cross-adapter conformance suite (issue #1601).

Phase 4 of the live-query platform RFC calls for an explicit layout-revision
contract rather than a forged row `update`. This does that:

- The live-query flush captures the retracted side of each change and, after
  commit, detects an order-only move (value deep-equal, `orderByIndex` moved)
  and publishes a first-class empty layout-change notification via a new
  `CollectionChangesManager.emitLayoutChangeEvent()`.
- The shared observer snapshot gains `layoutRevision`, which increments on any
  visible membership, ordering, or order-only-move change.

All five adapters pick this up through their existing wholesale re-read, so the
`order-only-move` conformance scenario is removed from UNIVERSAL_EXPECTED_FAIL
and now passes on React, Vue, Svelte, Solid, and Angular.

Distinct from PR #1601 (v-anton), which fixes the same bug via a forced row
`update`; this uses the RFC's layout-revision approach instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 864d3043-5bd9-4e5f-a030-1ee54341a5cd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch phase4/ordered-layout-contract

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 13, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1669

@tanstack/browser-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/browser-db-sqlite-persistence@1669

@tanstack/capacitor-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/capacitor-db-sqlite-persistence@1669

@tanstack/cloudflare-durable-objects-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/cloudflare-durable-objects-db-sqlite-persistence@1669

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1669

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1669

@tanstack/db-sqlite-persistence-core

npm i https://pkg.pr.new/@tanstack/db-sqlite-persistence-core@1669

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1669

@tanstack/electron-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/electron-db-sqlite-persistence@1669

@tanstack/expo-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/expo-db-sqlite-persistence@1669

@tanstack/node-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/node-db-sqlite-persistence@1669

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1669

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1669

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1669

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1669

@tanstack/react-native-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/react-native-db-sqlite-persistence@1669

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1669

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1669

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1669

@tanstack/tauri-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/tauri-db-sqlite-persistence@1669

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1669

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1669

commit: 4634ea5

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Size Change: +474 B (+0.38%)

Total Size: 127 kB

📦 View Changed
Filename Size Change
packages/db/dist/esm/collection/changes.js 1.56 kB +183 B (+13.25%) ⚠️
packages/db/dist/esm/live-query-observer.js 1.16 kB +122 B (+11.76%) ⚠️
packages/db/dist/esm/query/live/collection-config-builder.js 9.27 kB +169 B (+1.86%)
ℹ️ View Unchanged
Filename Size
packages/db/dist/esm/collection/change-events.js 1.43 kB
packages/db/dist/esm/collection/cleanup-queue.js 810 B
packages/db/dist/esm/collection/events.js 434 B
packages/db/dist/esm/collection/index.js 3.62 kB
packages/db/dist/esm/collection/indexes.js 1.99 kB
packages/db/dist/esm/collection/lifecycle.js 1.69 kB
packages/db/dist/esm/collection/mutations.js 2.47 kB
packages/db/dist/esm/collection/state.js 5.4 kB
packages/db/dist/esm/collection/subscription.js 3.74 kB
packages/db/dist/esm/collection/sync.js 2.88 kB
packages/db/dist/esm/collection/transaction-metadata.js 144 B
packages/db/dist/esm/deferred.js 207 B
packages/db/dist/esm/errors.js 5.1 kB
packages/db/dist/esm/event-emitter.js 748 B
packages/db/dist/esm/index.js 3.19 kB
packages/db/dist/esm/indexes/auto-index.js 829 B
packages/db/dist/esm/indexes/base-index.js 767 B
packages/db/dist/esm/indexes/basic-index.js 2.06 kB
packages/db/dist/esm/indexes/btree-index.js 2.19 kB
packages/db/dist/esm/indexes/index-registry.js 820 B
packages/db/dist/esm/indexes/reverse-index.js 557 B
packages/db/dist/esm/live-query-adapter.js 318 B
packages/db/dist/esm/local-only.js 916 B
packages/db/dist/esm/local-storage.js 2.12 kB
packages/db/dist/esm/optimistic-action.js 359 B
packages/db/dist/esm/paced-mutations.js 496 B
packages/db/dist/esm/proxy.js 3.75 kB
packages/db/dist/esm/query/builder/functions.js 1.47 kB
packages/db/dist/esm/query/builder/index.js 5.84 kB
packages/db/dist/esm/query/builder/ref-proxy.js 1.24 kB
packages/db/dist/esm/query/compiler/evaluators.js 1.89 kB
packages/db/dist/esm/query/compiler/expressions.js 430 B
packages/db/dist/esm/query/compiler/group-by.js 3.56 kB
packages/db/dist/esm/query/compiler/index.js 6.67 kB
packages/db/dist/esm/query/compiler/joins.js 2.5 kB
packages/db/dist/esm/query/compiler/lazy-targets.js 923 B
packages/db/dist/esm/query/compiler/order-by.js 1.74 kB
packages/db/dist/esm/query/compiler/select.js 1.53 kB
packages/db/dist/esm/query/effect.js 4.77 kB
packages/db/dist/esm/query/expression-helpers.js 1.43 kB
packages/db/dist/esm/query/ir.js 1.25 kB
packages/db/dist/esm/query/live-query-collection.js 360 B
packages/db/dist/esm/query/live/collection-registry.js 264 B
packages/db/dist/esm/query/live/collection-subscriber.js 1.93 kB
packages/db/dist/esm/query/live/internal.js 145 B
packages/db/dist/esm/query/live/utils.js 1.81 kB
packages/db/dist/esm/query/optimizer.js 2.92 kB
packages/db/dist/esm/query/predicate-utils.js 2.97 kB
packages/db/dist/esm/query/query-once.js 359 B
packages/db/dist/esm/query/subset-dedupe.js 960 B
packages/db/dist/esm/scheduler.js 1.3 kB
packages/db/dist/esm/SortedMap.js 1.3 kB
packages/db/dist/esm/strategies/debounceStrategy.js 247 B
packages/db/dist/esm/strategies/queueStrategy.js 428 B
packages/db/dist/esm/strategies/throttleStrategy.js 246 B
packages/db/dist/esm/transactions.js 3.04 kB
packages/db/dist/esm/utils.js 927 B
packages/db/dist/esm/utils/array-utils.js 273 B
packages/db/dist/esm/utils/browser-polyfills.js 304 B
packages/db/dist/esm/utils/btree.js 5.61 kB
packages/db/dist/esm/utils/comparison.js 1.11 kB
packages/db/dist/esm/utils/cursor.js 457 B
packages/db/dist/esm/utils/index-optimization.js 2.39 kB
packages/db/dist/esm/utils/type-guards.js 157 B
packages/db/dist/esm/utils/uuid.js 449 B
packages/db/dist/esm/virtual-props.js 360 B

compressed-size-action::db-package-size

@github-actions

Copy link
Copy Markdown
Contributor

Size Change: 0 B

Total Size: 3.81 kB

ℹ️ View Unchanged
Filename Size
packages/react-db/dist/esm/index.js 249 B
packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.32 kB
packages/react-db/dist/esm/useLiveQuery.js 924 B
packages/react-db/dist/esm/useLiveQueryEffect.js 355 B
packages/react-db/dist/esm/useLiveSuspenseQuery.js 567 B
packages/react-db/dist/esm/usePacedMutations.js 401 B

compressed-size-action::react-db-package-size

kevin-dp and others added 2 commits July 13, 2026 16:51
Addresses independent review of the layoutRevision contract:

- The join-with-separator signature could collide: a key value equal to the
  concatenation of neighboring keys around the separator produces the same
  string as two separate keys, so a real layout change (a membership change
  whose combined key spans the separator) was missed. Compare the ordered key
  sequence directly instead - collision-free, and it avoids materializing a
  large string on every snapshot rebuild (a new key array is only allocated
  when the layout actually moved). Adds a regression test.
- Correct the layoutRevision doc comment: it is NOT in lockstep with snapshot
  identity (a value-only update yields a new snapshot but the same
  layoutRevision).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@KyleAMathews KyleAMathews left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Found 2 issues, both reproduced with failing regression tests against d7628551f219cab27ddd542bffda40c8ee7f5aad:

  1. A graph flush containing both an ordinary projected-value update and an order-only move publishes twice. commit() synchronously emits the ordinary row batch, then emitLayoutChangeEvent() emits a second empty batch even though listeners already observe the final values and order. My regression expected one observer callback and received two. Please coalesce layout dirtiness into the commit publication (or otherwise suppress the separate layout event when that commit already publishes), and cover this with an exact-one-callback mixed-batch test.

// 1. Flush parent changes
if (hasParentChanges) {
begin()
changesToApply.forEach(this.applyChanges.bind(this, config))
commit()
// An order-only move (the row's projected value is unchanged but its
// `orderByIndex` moved) is swallowed by the collection's value-diff, so
// `commit()` emits nothing even though `.values()`/`.entries()` are now
// re-sorted. Publish an explicit layout-change notification so ordered
// consumers re-read — a first-class signal, not a forged row `update`.
if (hasOrderOnlyMove(changesToApply)) {
const changesManager = (config.collection as any)._changes as {
emitLayoutChangeEvent: () => void
}
changesManager.emitLayoutChangeEvent()
}

  1. The layout fix does not cover ordered child collections produced by includes. On retract-then-insert, the insertion side replaces existing.value but leaves the retracted orderByIndex; later the child collection commits without any layout-only signal. I reproduced this with a child projection that omits its sort field: after moving c1 behind c2, the child collection remained ordered [c1, c2] instead of [c2, c1]. Please preserve the insertion-side order metadata, publish child layout-only changes through the same atomic mechanism, and add an ordered-includes regression.

for (const [[childKey, tupleData], multiplicity] of messages) {
const [childResult, _orderByIndex, correlationKey, parentContext] =
tupleData as unknown as [
any,
string | undefined,
unknown,
Record<string, any> | null,
]
const routingKey = computeRoutingKey(correlationKey, parentContext)
// Accumulate by [routingKey, childKey]
let byChild = state.pendingChildChanges.get(routingKey)
if (!byChild) {
byChild = new Map()
state.pendingChildChanges.set(routingKey, byChild)
}
const existing = byChild.get(childKey) || {
deletes: 0,
inserts: 0,
value: childResult,
orderByIndex: _orderByIndex,
}
if (multiplicity < 0) {
existing.deletes += Math.abs(multiplicity)
} else if (multiplicity > 0) {
existing.inserts += multiplicity
existing.value = childResult
}
byChild.set(childKey, existing)

// Apply child changes to the child Collection
if (entry.syncMethods) {
entry.syncMethods.begin()
for (const [childKey, change] of childChanges) {
entry.resultKeys.set(change.value, childKey)
if (entry.orderByIndices && change.orderByIndex !== undefined) {
entry.orderByIndices.set(change.value, change.orderByIndex)
}
const key = entry.syncMethods.collection.getKeyFromItem(
change.value,
)
const childAlreadyExists = entry.syncMethods.collection.has(key)
if (change.inserts > 0 && change.deletes === 0) {
entry.syncMethods.write({
value: change.value,
type: childAlreadyExists ? `update` : `insert`,
})
} else if (
change.inserts > change.deletes ||
(change.inserts === change.deletes && childAlreadyExists)
) {
entry.syncMethods.write({ value: change.value, type: `update` })
} else if (change.deletes > 0) {
entry.syncMethods.write({ value: change.value, type: `delete` })
}
}
entry.syncMethods.commit()
}

Generated with Claude Code

@KyleAMathews

Copy link
Copy Markdown
Collaborator

Reproductions for the requested changes

I verified both findings against head d7628551f219cab27ddd542bffda40c8ee7f5aad with focused tests. Here are the essential regressions to add.

1. Mixed value update + order-only move must publish exactly once

Using the existing ordered query whose projection omits age:

it(`publishes a mixed value update and order-only move exactly once`, async () => {
  const source = makeSource()
  const lq = await makeOrderedByAge(source)
  const observer = createLiveQueryObserver<
    { id: string; name: string },
    string
  >(lq as any)

  let notifications = 0
  observer.subscribe(() => notifications++)
  notifications = 0 // exclude subscribeChanges' initial-state publication

  source.utils.begin()
  source.utils.write({
    type: `update`,
    value: { id: `1`, name: `Alicia`, age: 30 },
  })
  source.utils.write({
    type: `update`,
    value: { id: `2`, name: `Bob`, age: 99 },
  })
  source.utils.commit()

  const after = observer.getSnapshot()
  expect((after.data as Array<Person>).map(({ id, name }) => [id, name])).toEqual([
    [`1`, `Alicia`],
    [`3`, `Carol`],
    [`2`, `Bob`],
  ])
  expect(notifications).toBe(1)
})

Result on the PR head:

AssertionError: expected 2 to be 1

The first callback comes from commit()'s ordinary row update. The collection already has its final value and ordering at that point. The post-commit emitLayoutChangeEvent() then produces a redundant second callback. Layout dirtiness needs to participate in the same publication as row changes; a layout-only flush should publish only when no ordinary publication represents that flush.

Relevant code:

// 1. Flush parent changes
if (hasParentChanges) {
begin()
changesToApply.forEach(this.applyChanges.bind(this, config))
commit()
// An order-only move (the row's projected value is unchanged but its
// `orderByIndex` moved) is swallowed by the collection's value-diff, so
// `commit()` emits nothing even though `.values()`/`.entries()` are now
// re-sorted. Publish an explicit layout-change notification so ordered
// consumers re-read — a first-class signal, not a forged row `update`.
if (hasOrderOnlyMove(changesToApply)) {
const changesManager = (config.collection as any)._changes as {
emitLayoutChangeEvent: () => void
}
changesManager.emitLayoutChangeEvent()
}

2. Ordered included child must consume the new order metadata and publish its move

it(`publishes an ordered included child move exactly once`, async () => {
  const parents = createCollection(
    mockSyncCollectionOptions<{ id: string }>({
      id: `parents`,
      getKey: ({ id }) => id,
      initialData: [{ id: `p1` }],
    }),
  )
  const children = createCollection(
    mockSyncCollectionOptions<{
      id: string
      parentId: string
      name: string
      position: number
    }>({
      id: `children`,
      getKey: ({ id }) => id,
      initialData: [
        { id: `c1`, parentId: `p1`, name: `One`, position: 1 },
        { id: `c2`, parentId: `p1`, name: `Two`, position: 2 },
      ],
    }),
  )
  const lq = createLiveQueryCollection((q) =>
    q.from({ parent: parents }).select(({ parent }) => ({
      id: parent.id,
      children: q
        .from({ child: children })
        .where(({ child }) => eq(child.parentId, parent.id))
        .orderBy(({ child }) => child.position)
        .select(({ child }) => ({ id: child.id, name: child.name })),
    })),
  )
  await lq.preload()

  const childCollection = lq.get(`p1`)!.children
  let notifications = 0
  const subscription = childCollection.subscribeChanges(
    () => notifications++,
    { includeInitialState: false },
  )

  children.utils.begin()
  children.utils.write({
    type: `update`,
    value: { id: `c1`, parentId: `p1`, name: `One`, position: 3 },
  })
  children.utils.commit()

  expect([...childCollection.values()].map(({ id }) => id)).toEqual([
    `c2`,
    `c1`,
  ])
  expect(notifications).toBe(1)
  subscription.unsubscribe()
})

Result on the PR head:

AssertionError: expected [ 'c1', 'c2' ] to deeply equal [ 'c2', 'c1' ]

There are two gaps in this path:

  1. On retract-then-insert, existing.value is replaced by the insertion-side value, but existing.orderByIndex is not replaced by the insertion-side index.
  2. The child collection calls commit() but has no equivalent layout-only publication when the projected child value is unchanged.

Relevant code:

  • for (const [[childKey, tupleData], multiplicity] of messages) {
    const [childResult, _orderByIndex, correlationKey, parentContext] =
    tupleData as unknown as [
    any,
    string | undefined,
    unknown,
    Record<string, any> | null,
    ]
    const routingKey = computeRoutingKey(correlationKey, parentContext)
    // Accumulate by [routingKey, childKey]
    let byChild = state.pendingChildChanges.get(routingKey)
    if (!byChild) {
    byChild = new Map()
    state.pendingChildChanges.set(routingKey, byChild)
    }
    const existing = byChild.get(childKey) || {
    deletes: 0,
    inserts: 0,
    value: childResult,
    orderByIndex: _orderByIndex,
    }
    if (multiplicity < 0) {
    existing.deletes += Math.abs(multiplicity)
    } else if (multiplicity > 0) {
    existing.inserts += multiplicity
    existing.value = childResult
    }
    byChild.set(childKey, existing)
  • // Apply child changes to the child Collection
    if (entry.syncMethods) {
    entry.syncMethods.begin()
    for (const [childKey, change] of childChanges) {
    entry.resultKeys.set(change.value, childKey)
    if (entry.orderByIndices && change.orderByIndex !== undefined) {
    entry.orderByIndices.set(change.value, change.orderByIndex)
    }
    const key = entry.syncMethods.collection.getKeyFromItem(
    change.value,
    )
    const childAlreadyExists = entry.syncMethods.collection.has(key)
    if (change.inserts > 0 && change.deletes === 0) {
    entry.syncMethods.write({
    value: change.value,
    type: childAlreadyExists ? `update` : `insert`,
    })
    } else if (
    change.inserts > change.deletes ||
    (change.inserts === change.deletes && childAlreadyExists)
    ) {
    entry.syncMethods.write({ value: change.value, type: `update` })
    } else if (change.deletes > 0) {
    entry.syncMethods.write({ value: change.value, type: `delete` })
    }
    }
    entry.syncMethods.commit()
    }

I also tested the stable-rank case (age: 20 → 21, still first). After excluding the expected initial subscription publication, it already produces zero callback delta, preserves snapshot identity, and keeps layoutRevision stable. No change is needed for that case.

kevin-dp and others added 4 commits July 15, 2026 10:11
Two gaps in the order-only-move handling, reproduced as failing tests (to be
fixed in a follow-up commit):

1. A commit containing both an ordinary value update and an order-only move
   publishes twice (commit's row batch + the separate empty layout event),
   where exactly one publication is expected.
2. Ordered child collections produced by `includes` don't consume the
   insertion-side order metadata or publish a layout-only move, so an ordered
   child stays in its old order after a child order-only move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dren

Addresses Kyle's review of the order-only-move handling:

1. A commit containing both an ordinary value update and an order-only move
   published twice: commit() emitted the row batch and then the separate layout
   event fired redundantly. Replace hasOrderOnlyMove with
   needsLayoutOnlyPublication, which fires the layout event only when the commit
   published nothing else (any real insert/delete/value-changed update already
   notifies subscribers, who re-read the re-sorted collection).

2. Ordered child collections produced by includes did not reorder on an
   order-only child move:
   - The child accumulate replaced value on the insert side but left the
     retracted orderByIndex, so the child collection re-sorted against a stale
     index. Update orderByIndex on insert and capture the retract side (both the
     single-level and nested-includes accumulate blocks).
   - The child flush committed without a layout-only publication when the
     projected child value was unchanged. Publish one through the same
     mechanism (emitLayoutChange) when the child commit published nothing else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The includes flush is recursive, so the order-only-move handling must hold
beyond one level. Adds a two-level ordered-includes regression (org -> teams
-> members): moving a grandchild whose projected value is unchanged must
re-sort its collection and publish exactly once. Verified red when the
child-flush layout publication is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kevin-dp

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, Kyle — both findings were spot on. Fixed in c9ec751, with the two regressions you specified added first (43f1a7d) so the failure is on record.

1. Mixed value update + order-only move now publishes exactly once. Replaced hasOrderOnlyMove with needsLayoutOnlyPublication, which fires the layout event only when the commit published nothing else. Any real insert/delete/value-changed update already notifies subscribers (who re-read the re-sorted collection), so the separate layout event is skipped in that case. Covered by the exact-one-callback mixed-batch test.

2. Ordered includes children now consume the insertion-side order metadata and publish their move. Two gaps, both closed:

  • The child accumulate replaced existing.value on the insert side but left the retracted orderByIndex. It now updates orderByIndex on insert and captures previousValue/previousOrderByIndex on retract (both the single-level output and the nested-includes buffer accumulate).
  • The child flush committed without a layout-only publication when the projected child value was unchanged. It now runs the same needsLayoutOnlyPublication check after the child commit() and publishes through a shared emitLayoutChange helper.

Covered by the ordered-includes regression.

Depth. Since flushIncludesState is recursive and the child layout-emit runs at every level, I added a two-level nested-includes regression (org → teams → members, moving a grandchild) to guard that this holds beyond one level — verified it goes red if the child-flush publication is removed. An independent pass also confirmed the exactly-once contract and re-sort at 1/2/3 levels, plus the insert/delete-combined-with-move and multiple-moves-in-one-commit cases.

Stable-rank case (sort field changes but position doesn't, value unchanged) needs no change, as you noted — it produces zero layout events; there's a test asserting that too.

Full @tanstack/db suite and all five adapter conformance suites are green.

@kevin-dp kevin-dp requested a review from KyleAMathews July 15, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants