From 90811dc266b52147e5fb6311555395a75f6f55ff Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 14:35:21 +0200 Subject: [PATCH 01/55] fix(browser): select popover options by text --- tests/browser/articleEditor.test.ts | 20 ++++++++++++++------ tests/browser/authorSelect.test.ts | 10 +++++++--- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/browser/articleEditor.test.ts b/tests/browser/articleEditor.test.ts index 6618f4fb..26fb0ad0 100644 --- a/tests/browser/articleEditor.test.ts +++ b/tests/browser/articleEditor.test.ts @@ -24,10 +24,14 @@ browserTest('Article Editor', () => { // Type in the search input to filter, then click the filtered option waitFor(() => el('[role="dialog"] input').exists) el('[role="dialog"] input').fill('Jane') - // Wait for the FILTERED option — the stale pre-filter list also has buttons - waitFor(() => el('[role="dialog"] button[class]').text.includes('Jane')) + const janeOption = () => el('xpath=//*[@role="dialog"]//button[contains(normalize-space(.), "Jane")]') + waitFor(() => janeOption().exists) clickUntil( - () => el('[role="dialog"] button[class]'), + () => { + const option = janeOption() + expect(option.text).toContain('Jane') + return option + }, () => !el('article-save-button').isDisabled, ) expect(el('article-dirty-notice').exists).toBe(true) @@ -48,10 +52,14 @@ browserTest('Article Editor', () => { // Search for the tag and click it waitFor(() => el('[role="dialog"] input').exists) el('[role="dialog"] input').fill('TypeScript') - // Wait for the FILTERED option — the stale pre-filter list also has buttons - waitFor(() => el('[role="dialog"] button[class]').text.includes('TypeScript')) + const typeScriptOption = () => el('xpath=//*[@role="dialog"]//button[contains(normalize-space(.), "TypeScript")]') + waitFor(() => typeScriptOption().exists) clickUntil( - () => el('[role="dialog"] button[class]'), + () => { + const option = typeScriptOption() + expect(option.text).toContain('TypeScript') + return option + }, () => el('tag-badge-TypeScript').exists, ) }) diff --git a/tests/browser/authorSelect.test.ts b/tests/browser/authorSelect.test.ts index fe1e8ad8..6219f901 100644 --- a/tests/browser/authorSelect.test.ts +++ b/tests/browser/authorSelect.test.ts @@ -21,10 +21,14 @@ browserTest('Article with Author Select', () => { // Type to filter and click an option waitFor(() => el('[role="dialog"] input').exists) el('[role="dialog"] input').fill('Bob') - // Wait for the FILTERED option — the stale pre-filter list also has buttons - waitFor(() => el('[role="dialog"] button[class]').text.includes('Bob')) + const bobOption = () => el('xpath=//*[@role="dialog"]//button[contains(normalize-space(.), "Bob")]') + waitFor(() => bobOption().exists) clickUntil( - () => el('[role="dialog"] button[class]'), + () => { + const option = bobOption() + expect(option.text).toContain('Bob') + return option + }, () => !el('author-select-save-button').isDisabled, ) expect(el('current-author-display').text).toContain('Changes will be applied on save') From 3365361cc9147b707d3cdf6ca073143bd0a5eee6 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 14:35:21 +0200 Subject: [PATCH 02/55] test(bindx-react): pin has-one subscription regressions --- tests/react/jsx/HasOneNullRelation.test.tsx | 143 +++++++++++++++----- 1 file changed, 111 insertions(+), 32 deletions(-) diff --git a/tests/react/jsx/HasOneNullRelation.test.tsx b/tests/react/jsx/HasOneNullRelation.test.tsx index b268c226..b70ad474 100644 --- a/tests/react/jsx/HasOneNullRelation.test.tsx +++ b/tests/react/jsx/HasOneNullRelation.test.tsx @@ -1,31 +1,23 @@ -// Regression test for https://github.com/contember/bindx/issues/32 -// -// `` over a nullable many/one-has-one relation that is -// currently null at runtime fires its children callback with `undefined` -// instead of the placeholder accessor `useEntity` returns for the same -// field. The typed contract claims `EntityRef` (non-nullable), so callers -// don't guard — and crash on the first field access (`Cannot read properties -// of undefined (reading '')`). -// -// Bug observed in NPI (`packages/admin/app/components/publications/seo-card.tsx`): -// outer `` auto-creates the placeholder, then the -// inner `` over the disconnected image relation -// gives `undefined` to the callback. The same shape reproduces here with -// `{author => …`. +// Regression test for https://github.com/contember/bindx/issues/32. +// Nested nullable relations must expose placeholder accessors to JSX children. import '../../setup' import { afterEach, describe, expect, test } from 'bun:test' -import { cleanup, render, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import React from 'react' import { BindxProvider, defineSchema, entityDef, HasOne, + type HasOneRef, hasOne, + isPlaceholderId, MockAdapter, scalar, + Show, useEntity, + useHasOne, } from '@contember/bindx-react' afterEach(() => { @@ -48,6 +40,10 @@ interface Article { title: string author: Author | null } +interface SelectedProfile { + id: string + bio: string | null +} interface NestedSchema { Article: Article Author: Author @@ -92,17 +88,34 @@ const mockData = { 'article-1': { id: 'article-1', title: 'Article 1', - // Both levels disconnected — outer `author` is null, so the - // inner `` runs on a placeholder - // author. This mirrors the NPI seo-card scenario where the - // product has no SEO meta row yet, the outer HasOne hands out - // a placeholder, and the inner one over the still-empty image - // relation crashes. author: null, }, + 'article-2': { + id: 'article-2', + title: 'Article 2', + author: { + id: 'author-1', + name: 'Author 1', + email: 'author@example.com', + profile: null, + }, + }, + }, + Author: { + 'author-1': { + id: 'author-1', + name: 'Author 1', + email: 'author@example.com', + profile: null, + }, + }, + Profile: { + 'profile-1': { + id: 'profile-1', + bio: 'Connected profile', + avatar: null, + }, }, - Author: {}, - Profile: {}, } function getByTestId(container: Element, testId: string): Element { @@ -130,13 +143,17 @@ describe('HasOne JSX — nested nullable has-one with no connected row', () => {
{author => ( - - {profile => ( -
- {profile.bio.inputProps.value ?? 'empty'} -
- )} -
+
+ {isPlaceholderId(author.id) ? 'yes' : 'no'} + + {profile => ( +
+ {isPlaceholderId(profile.id) ? 'yes' : 'no'} + {profile.bio.inputProps.value ?? 'empty'} +
+ )} +
+
)}
@@ -153,9 +170,71 @@ describe('HasOne JSX — nested nullable has-one with no connected row', () => { expect(queryByTestId(container, 'loading')).toBeNull() }) - // Inner HasOne should still render — placeholder accessor returns - // `null` field values, not throw `Cannot read properties of undefined`. expect(getByTestId(container, 'profile-block')).not.toBeNull() + expect(getByTestId(container, 'author-placeholder').textContent).toBe('yes') + expect(getByTestId(container, 'profile-placeholder').textContent).toBe('yes') expect(getByTestId(container, 'profile-bio').textContent).toBe('empty') }) + + test.failing('$connect(id) re-points sibling field subscriptions to a warm target', async () => { + const adapter = new MockAdapter(mockData, { delay: 0 }) + + function ConnectProfile({ field }: { field: HasOneRef }): React.ReactElement { + const profile = useHasOne(field) + return ( +
+ {profile.$id} + +
+ ) + } + + function ProfileSlot({ field }: { field: HasOneRef }): React.ReactElement { + return ( +
+ empty}> + {bio => {bio}} + + +
+ ) + } + + function TestComponent(): React.ReactElement { + const article = useEntity(schema.Article, { by: { id: 'article-2' } }, a => + a.id().author(author => author.id().profile(profile => profile.id().bio()))) + const profile = useEntity(schema.Profile, { by: { id: 'profile-1' } }, p => p.id().bio()) + + if (article.$isLoading || profile.$isLoading) return
Loading…
+ if (article.$isError || article.$isNotFound || profile.$isError || profile.$isNotFound) { + return
Error
+ } + + return ( + + {author => } + + ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'loading')).toBeNull() + }) + expect(getByTestId(container, 'profile-empty').textContent).toBe('empty') + + fireEvent.click(getByTestId(container, 'connect-profile')) + + expect(getByTestId(container, 'connected-profile-id').textContent).toBe('profile-1') + await waitFor(() => { + expect(getByTestId(container, 'profile-value').textContent).toBe('Connected profile') + }) + }) }) From 5890a4077de12c898d2b52f14d56cec2e36802bd Mon Sep 17 00:00:00 2001 From: MalaRuze Date: Tue, 11 Aug 2026 22:17:30 +0200 Subject: [PATCH 03/55] test: failing repro for nested create temp IDs leaking into the next update mutation --- .../nestedCreateTempIdLeak.test.ts | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 tests/unit/persistence/nestedCreateTempIdLeak.test.ts diff --git a/tests/unit/persistence/nestedCreateTempIdLeak.test.ts b/tests/unit/persistence/nestedCreateTempIdLeak.test.ts new file mode 100644 index 00000000..1de8aa55 --- /dev/null +++ b/tests/unit/persistence/nestedCreateTempIdLeak.test.ts @@ -0,0 +1,200 @@ +// Regression test for +import { describe, test, expect, beforeEach, mock } from 'bun:test' +import { + SnapshotStore, + MutationCollector, + ContemberSchemaMutationAdapter, + ActionDispatcher, + BatchPersister, + type BackendAdapter, + type SchemaNames, +} from '@contember/bindx' +import { buildNodeSelectionFromMutationData } from '@contember/bindx-client' + +/** + * Page → blocks (hasMany) → button (hasOne) → link (hasOne). + * + * Models a page builder that duplicates a section: the duplicate is emitted as a + * batch of sibling `create` operations inside one hasMany, each carrying its own + * nested hasOne creates. Sibling blocks are of different kinds, so their create + * payloads contain different subsets of the nested fields (unset fields are + * omitted from create data). + */ +const schema: SchemaNames = { + entities: { + Page: { + name: 'Page', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + blocks: { type: 'many', entity: 'Block' }, + }, + }, + Block: { + name: 'Block', + scalars: ['id', 'order', 'type'], + fields: { + id: { type: 'column' }, + order: { type: 'column' }, + type: { type: 'column' }, + button: { type: 'one', entity: 'Button', nullable: true }, + }, + }, + Button: { + name: 'Button', + scalars: ['id', 'label', 'modalTitle'], + fields: { + id: { type: 'column' }, + label: { type: 'column' }, + modalTitle: { type: 'column' }, + link: { type: 'one', entity: 'Link', nullable: true }, + }, + }, + Link: { + name: 'Link', + scalars: ['id', 'type', 'externalTarget'], + fields: { + id: { type: 'column' }, + type: { type: 'column' }, + externalTarget: { type: 'column' }, + }, + }, + }, + enums: {}, +} + +type NodeSelection = { name: string; children?: NodeSelection[] } + +const readSelection = (selectionSet: readonly unknown[]): NodeSelection[] => + selectionSet.map(item => { + const field = item as { name: string; selectionSet?: readonly unknown[] } + return { name: field.name, children: field.selectionSet ? readSelection(field.selectionSet) : undefined } + }) + +type AdapterCall = { entityType: string; entityId: string; data: Record } + +/** + * Adapter without `persistTransaction`, so BatchPersister takes the sequential path and + * reconstructs nested IDs from the mutation's `node` response. The response echoes exactly + * the fields the node selection asked for — the same contract a real Contember API honours. + */ +function createNodeEchoAdapter(calls: AdapterCall[]): BackendAdapter { + let serverIdCounter = 0 + + const buildNode = (data: Record, selection: NodeSelection[]): Record => { + const selected = new Map(selection.map(field => [field.name, field])) + const node: Record = { id: `server-${++serverIdCounter}` } + + for (const [key, value] of Object.entries(data)) { + const fieldSelection = selected.get(key) + if (!fieldSelection || value === null || value === undefined) continue + + if (Array.isArray(value)) { + const items: Record[] = [] + for (const op of value) { + if (typeof op !== 'object' || op === null) continue + const opObj = op as Record + if ('create' in opObj) { + items.push(buildNode(opObj['create'] as Record, fieldSelection.children ?? [])) + } else if ('connect' in opObj) { + items.push({ id: (opObj['connect'] as Record)['id'] }) + } + } + if (items.length > 0) node[key] = items + } else if (typeof value === 'object') { + const opObj = value as Record + if ('create' in opObj) { + node[key] = buildNode(opObj['create'] as Record, fieldSelection.children ?? []) + } else if ('connect' in opObj) { + node[key] = { id: (opObj['connect'] as Record)['id'] } + } + } else { + node[key] = value + } + } + return node + } + + const respond = (data: Record) => + Promise.resolve({ ok: true, data: buildNode(data, readSelection(buildNodeSelectionFromMutationData(data))) }) + + return { + query: mock(() => Promise.resolve([])), + delete: mock(() => Promise.resolve({ ok: true })), + persist: mock((entityType: string, entityId: string, changes: Record) => { + calls.push({ entityType, entityId, data: changes }) + return respond(changes) + }), + create: mock((entityType: string, data: Record) => { + calls.push({ entityType, entityId: '', data }) + return respond(data) + }), + } +} + +describe('Nested create ID reconciliation across sibling creates', () => { + let store: SnapshotStore + let persister: BatchPersister + let calls: AdapterCall[] + let blockWithModal: string + let buttonWithModal: string + + beforeEach(async () => { + store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + const schemaAdapter = new ContemberSchemaMutationAdapter(schema) + const mutationCollector = new MutationCollector(store, schemaAdapter) + calls = [] + persister = new BatchPersister(createNodeEchoAdapter(calls), store, dispatcher, { + mutationCollector, + schema: schemaAdapter as never, // ContemberSchemaMutationAdapter satisfies MutationSchemaProvider + }) + + store.setEntityData('Page', 'page-1', { id: 'page-1', title: 'Contacts' }, true) + store.setExistsOnServer('Page', 'page-1', true) + store.getOrCreateHasMany('Page', 'page-1', 'blocks', []) + + // First sibling: a button that opens a modal (sets `modalTitle`, has no link). + blockWithModal = store.createEntity('Block', { order: 1, type: 'button' }) + buttonWithModal = store.createEntity('Button', { label: 'Kontaktujte nás', modalTitle: 'Kontakt' }) + store.getOrCreateRelation('Block', blockWithModal, 'button', { + currentId: buttonWithModal, serverId: null, state: 'connected', serverState: 'disconnected', placeholderData: {}, + }) + store.addToHasMany('Page', 'page-1', 'blocks', blockWithModal) + + // Second sibling: a plain link button (no `modalTitle`, but a nested link). + const blockWithLink = store.createEntity('Block', { order: 2, type: 'button' }) + const buttonWithLink = store.createEntity('Button', { label: 'Napište nám' }) + const link = store.createEntity('Link', { type: 'external', externalTarget: 'mailto:info@example.com' }) + store.getOrCreateRelation('Block', blockWithLink, 'button', { + currentId: buttonWithLink, serverId: null, state: 'connected', serverState: 'disconnected', placeholderData: {}, + }) + store.getOrCreateRelation('Button', buttonWithLink, 'link', { + currentId: link, serverId: null, state: 'connected', serverState: 'disconnected', placeholderData: {}, + }) + store.addToHasMany('Page', 'page-1', 'blocks', blockWithLink) + + const result = await persister.persistAll() + expect(result.success).toBe(true) + }) + + test('should map every nested create to its server ID when sibling creates carry different nested shapes', () => { + expect(store.getPersistedId('Block', blockWithModal)).not.toBeNull() + expect(store.getPersistedId('Button', buttonWithModal)).not.toBeNull() + }) + + test('should not emit an update keyed by a temp ID when a nested create stays unresolved', async () => { + // The entity is reported as existing on the server, so the next edit goes out as an update. + expect(store.existsOnServer('Button', buttonWithModal)).toBe(true) + + calls.length = 0 + const buttonId = store.getPersistedId('Button', buttonWithModal) ?? buttonWithModal + store.updateEntityFields('Button', buttonId, { label: 'Napište nám hned' }) + await persister.persistAll() + + const buttonUpdate = calls.find(call => call.entityType === 'Button') + expect(buttonUpdate).toBeDefined() + expect(buttonUpdate!.entityId).not.toMatch(/^__temp_/) + }) +}) From 2a1fc5ea82481cafbc60cd8c8f368ffb309a92fa Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 19 Aug 2026 14:43:12 +0200 Subject: [PATCH 04/55] fix(bindx-client): union nested relation selections across sibling creates (#70) buildSelectionFromOps unioned scalar fields across sibling create/update ops but kept nested relations in a Map keyed by field name, so the last sibling's shape won. Two blocks whose nested `button` creates carried different fields emitted a node selection covering only one of them, so the response could not be content-matched back to the other sibling: its nested creates kept their temp IDs while still being committed as existing on the server, and the next edit went out as an update keyed by `__temp_...`, which the API rejects. Nested payloads are now accumulated per field name across every sibling and fed back through buildSelectionFromOps, so the union is recursive by construction for both has-one and has-many, nested-in-nested included. The walker is rebuilt around one primitive (buildSelectionFromDataObjects) and is now cast-free; a latent Object.entries(null) crash on a present-but-null `data` is guarded by the new isRecord type guard. The selection tests live under tests/unit/ rather than tests/bindx-client/ because only tests/unit, tests/react and tests/cases are in the CI script. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee --- .../src/graphql/mutationFragments.ts | 122 +++++++++--------- .../mutationSelectionUnion.test.ts | 74 +++++++++++ 2 files changed, 136 insertions(+), 60 deletions(-) create mode 100644 tests/unit/persistence/mutationSelectionUnion.test.ts diff --git a/packages/bindx-client/src/graphql/mutationFragments.ts b/packages/bindx-client/src/graphql/mutationFragments.ts index 4e72d010..8481c957 100644 --- a/packages/bindx-client/src/graphql/mutationFragments.ts +++ b/packages/bindx-client/src/graphql/mutationFragments.ts @@ -76,92 +76,94 @@ export function buildMutationSelection( export function buildNodeSelectionFromMutationData( data: Record, ): GraphQlSelectionSet { - const fields: GraphQlSelectionSet = [new GraphQlField(null, 'id')] - - for (const [fieldName, value] of Object.entries(data)) { - if (value === null || value === undefined) continue - - if (Array.isArray(value)) { - const nested = buildSelectionFromOps(value) - if (nested) fields.push(new GraphQlField(null, fieldName, {}, nested)) - } else if (typeof value === 'object') { - const nested = buildSelectionFromCreateOrUpdate(value as Record) - if (nested) fields.push(new GraphQlField(null, fieldName, {}, nested)) - } else { - fields.push(new GraphQlField(null, fieldName)) - } - } + return buildSelectionFromDataObjects([data]) +} - return fields +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) } /** - * Extracts the inner data from a create or update operation and recurses. + * Unwraps a create/update operation to the data object it writes. */ -function buildSelectionFromCreateOrUpdate( - op: Record, -): GraphQlSelectionSet | undefined { - if ('create' in op && typeof op['create'] === 'object' && op['create'] !== null) { - return buildNodeSelectionFromMutationData(op['create'] as Record) - } - if ('update' in op && typeof op['update'] === 'object' && op['update'] !== null) { - const update = op['update'] as Record - const data = ('data' in update ? update['data'] : update) as Record - return buildNodeSelectionFromMutationData(data) +function extractOperationData(op: unknown): Record | undefined { + if (!isRecord(op)) return undefined + + const create = op['create'] + if (isRecord(create)) return create + + const update = op['update'] + if (isRecord(update)) { + const data = update['data'] + return isRecord(data) ? data : update } + return undefined } /** - * Merges selections from all create/update operations in a hasMany array. - * Collects the union of scalar + relation fields across all operations. + * Builds one selection set covering every given data object. + * + * Sibling ops in a hasMany often carry different subsets of the same relation + * (unset fields are absent from create data), so both scalars and nested + * relations are unioned — keeping only the last shape would emit a selection + * the other siblings' responses cannot be content-matched against. */ -function buildSelectionFromOps(ops: unknown[]): GraphQlSelectionSet | undefined { +function buildSelectionFromDataObjects( + dataObjects: readonly Record[], +): GraphQlSelectionSet { const scalarFields = new Set() - const nestedFields = new Map>() - let hasOps = false - - for (const item of ops) { - if (typeof item !== 'object' || item === null) continue - const op = item as Record - - const innerData = - ('create' in op && typeof op['create'] === 'object' && op['create'] !== null) - ? op['create'] as Record - : ('update' in op && typeof op['update'] === 'object' && op['update'] !== null) - ? (() => { const u = op['update'] as Record; return ('data' in u ? u['data'] : u) as Record })() - : null + const nestedOps = new Map() - if (!innerData) continue - hasOps = true + const collectNested = (fieldName: string, ops: readonly unknown[]): void => { + const collected = nestedOps.get(fieldName) + if (collected) { + collected.push(...ops) + } else { + nestedOps.set(fieldName, [...ops]) + } + } - for (const [key, value] of Object.entries(innerData)) { + for (const data of dataObjects) { + for (const [fieldName, value] of Object.entries(data)) { if (value === null || value === undefined) continue - if (typeof value === 'object') { - nestedFields.set(key, value as Record) - } else { - scalarFields.add(key) + + if (Array.isArray(value)) { + collectNested(fieldName, value) + } else if (isRecord(value)) { + collectNested(fieldName, [value]) + } else if (fieldName !== 'id') { + scalarFields.add(fieldName) } } } - if (!hasOps) return undefined - const fields: GraphQlSelectionSet = [new GraphQlField(null, 'id')] for (const fieldName of scalarFields) { fields.push(new GraphQlField(null, fieldName)) } - for (const [fieldName, value] of nestedFields) { - if (Array.isArray(value)) { - const nested = buildSelectionFromOps(value) - if (nested) fields.push(new GraphQlField(null, fieldName, {}, nested)) - } else { - const nested = buildSelectionFromCreateOrUpdate(value as Record) - if (nested) fields.push(new GraphQlField(null, fieldName, {}, nested)) - } + for (const [fieldName, ops] of nestedOps) { + const nested = buildSelectionFromOps(ops) + if (nested) fields.push(new GraphQlField(null, fieldName, {}, nested)) } return fields } + +/** + * Merges the selections of all create/update operations written to one field. + */ +function buildSelectionFromOps(ops: readonly unknown[]): GraphQlSelectionSet | undefined { + const dataObjects: Record[] = [] + + for (const op of ops) { + const data = extractOperationData(op) + if (data) dataObjects.push(data) + } + + if (dataObjects.length === 0) return undefined + + return buildSelectionFromDataObjects(dataObjects) +} diff --git a/tests/unit/persistence/mutationSelectionUnion.test.ts b/tests/unit/persistence/mutationSelectionUnion.test.ts new file mode 100644 index 00000000..f80b188e --- /dev/null +++ b/tests/unit/persistence/mutationSelectionUnion.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect } from 'bun:test' +import { buildNodeSelectionFromMutationData } from '@contember/bindx-client' + +// Lives under tests/unit/ rather than tests/bindx-client/ because only tests/unit, +// tests/react and tests/cases are in the CI `test` script. + +interface SelectionField { + readonly name: string + readonly selectionSet?: readonly unknown[] +} + +interface FieldNames { + name: string + children?: FieldNames[] +} + +function isSelectionField(value: unknown): value is SelectionField { + return typeof value === 'object' && value !== null && 'name' in value && typeof value.name === 'string' +} + +function readFieldNames(selectionSet: readonly unknown[]): FieldNames[] { + return selectionSet.map(item => { + if (!isSelectionField(item)) { + throw new Error('selection entry is not a GraphQL field') + } + return { + name: item.name, + children: item.selectionSet ? readFieldNames(item.selectionSet) : undefined, + } + }) +} + +function findField(fields: FieldNames[], name: string): FieldNames | undefined { + return fields.find(field => field.name === name) +} + +describe('Node selection across sibling create operations', () => { + test('unions the nested relation shapes of every sibling, not just the last one', () => { + const selection = readFieldNames(buildNodeSelectionFromMutationData({ + title: 'Contacts', + blocks: [ + { alias: 'a', create: { order: 1, button: { create: { label: 'A', modalTitle: 'Modal' } } } }, + { alias: 'b', create: { order: 2, button: { create: { label: 'B', link: { create: { type: 'external' } } } } } }, + ], + })) + + const button = findField(findField(selection, 'blocks')?.children ?? [], 'button') + const buttonFields = (button?.children ?? []).map(field => field.name) + + expect(buttonFields).toContain('id') + expect(buttonFields).toContain('label') + expect(buttonFields).toContain('modalTitle') + expect(buttonFields).toContain('link') + }) + + test('unions nested shapes recursively, across siblings of a nested hasMany', () => { + const selection = readFieldNames(buildNodeSelectionFromMutationData({ + blocks: [ + { alias: 'a', create: { items: [{ alias: 'a1', create: { label: 'A' } }] } }, + { alias: 'b', create: { items: [{ alias: 'b1', create: { note: 'B' } }] } }, + ], + })) + + const items = findField(findField(selection, 'blocks')?.children ?? [], 'items') + + expect((items?.children ?? []).map(field => field.name)).toEqual(['id', 'label', 'note']) + }) + + test('requests each field once, even when the data already carries an id', () => { + const selection = readFieldNames(buildNodeSelectionFromMutationData({ id: 'page-1', title: 'Contacts' })) + + expect(selection.map(field => field.name)).toEqual(['id', 'title']) + }) +}) From 26da72f7a64414487935fb238d4118f641c8d2be Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 19 Aug 2026 15:24:07 +0200 Subject: [PATCH 05/55] fix(bindx): pair nested creates by elimination, not greedy first-fit (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widening the mutation node selection also widened the content matcher: the selection is its input, so selecting a nested relation that last-wins previously dropped makes isCreateDataMatchingNode recurse into a subtree it used to skip. Any scalar the server does not echo back byte-identically — an ordinary datetime normalisation is enough — then failed the match for the entire parent create op, and the greedy loop discarded that sibling and every entity nested under it. A one-entity temp-ID leak became a three-entity one, silently, with success: true. extractNestedResultsFromNode's greedy first-fit loop is replaced by a pairing pass: first pair every op that has exactly one candidate row, removing that row and looping, since consuming a row often makes another op unique; then fall back to first-fit for ops that remain ambiguous; then, if exactly one op and one row are left unpaired, pair them. The uniqueness pass is what makes elimination sound. Bare elimination on top of the greedy loop would have widened a pre-existing bug: a subset payload steals its sibling's row today and the sibling ends up unmapped, but with plain elimination the sibling would instead be mis-mapped onto the subset's row — silent cross-wiring rather than a leak. Pairing the precise payload first removes that precondition, which also repairs the existing bug. isCreateDataMatchingNode is untouched: strict comparison is still the evidence, it is just no longer the sole arbiter. The matcher must not treat "cannot identify" as "discard". Elimination is deliberately capped at one op and one row; with two simultaneously unmatchable siblings the entities keep their temp IDs rather than being guessed at, which a test pins. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee --- .../bindx/src/persistence/BatchPersister.ts | 74 ++++- .../nestedCreateNormalisedScalar.test.ts | 265 ++++++++++++++++++ 2 files changed, 333 insertions(+), 6 deletions(-) create mode 100644 tests/unit/persistence/nestedCreateNormalisedScalar.test.ts diff --git a/packages/bindx/src/persistence/BatchPersister.ts b/packages/bindx/src/persistence/BatchPersister.ts index 65ffb094..0950f441 100644 --- a/packages/bindx/src/persistence/BatchPersister.ts +++ b/packages/bindx/src/persistence/BatchPersister.ts @@ -52,6 +52,18 @@ export interface BatchPersisterOptions { defaultUpdateMode?: UpdateMode } +/** One inline create operation inside a hasMany mutation, keyed by its alias (a temp ID). */ +interface NodeCreateOp { + readonly alias: string + readonly createData: Record +} + +/** A create operation and the response row it produced. */ +interface NodeCreatePair { + readonly op: NodeCreateOp + readonly nodeItem: Record +} + /** * BatchPersister orchestrates multi-entity persistence with: * - Deduplication (same entity referenced multiple times → single mutation) @@ -975,7 +987,7 @@ export class BatchPersister { // Separate create ops from known IDs (connect/update) const knownIds = new Set() - const createOps: Array<{ alias: string; createData: Record }> = [] + const createOps: NodeCreateOp[] = [] for (const op of fieldValue) { if (typeof op !== 'object' || op === null) continue @@ -1004,14 +1016,11 @@ export class BatchPersister { // Filter response to new items only, then content-match to create ops. // Contember API does not guarantee hasMany ordering in node response, // so we match by scalar field values instead of position. - // Unmatched creates keep their temp IDs until the next fetch. const unmatchedItems = (nodeItems as Record[]) .filter(it => typeof it === 'object' && it !== null && !knownIds.has(it['id'] as string)) - for (const createOp of createOps) { - const idx = unmatchedItems.findIndex(it => this.isCreateDataMatchingNode(createOp.createData, it)) - if (idx < 0) continue - results.push(makeResult(createOp.alias, createOp.createData, unmatchedItems.splice(idx, 1)[0]!)) + for (const { op, nodeItem } of this.pairCreateOpsWithNodes(createOps, unmatchedItems)) { + results.push(makeResult(op.alias, op.createData, nodeItem)) } } else if (typeof fieldValue === 'object') { const opObj = fieldValue as Record @@ -1036,6 +1045,59 @@ export class BatchPersister { return results } + /** + * Pairs inline create operations with the response rows they produced. + * + * An unambiguous match is taken first: an op with several candidate rows waits until + * the other ops have consumed theirs, so a payload that is a subset of its sibling's + * no longer steals that sibling's row. What is left falls back to first-fit, which + * keeps indistinguishable siblings (identical payloads) mapped. + * + * A single op and row left over are paired by elimination. A server that echoes a + * value back normalised (a date, a decimal) matches nothing byte-for-byte, and + * discarding the pair would leave that op's whole subtree on temp IDs (issue #70). + */ + private pairCreateOpsWithNodes( + createOps: readonly NodeCreateOp[], + nodeItems: readonly Record[], + ): NodeCreatePair[] { + const pairs: NodeCreatePair[] = [] + const pendingOps = [...createOps] + const freeItems = [...nodeItems] + + const takePair = (opIndex: number, nodeItem: Record): void => { + pairs.push({ op: pendingOps[opIndex]!, nodeItem }) + pendingOps.splice(opIndex, 1) + freeItems.splice(freeItems.indexOf(nodeItem), 1) + } + + while (pendingOps.length > 0 && freeItems.length > 0) { + const candidatesPerOp = pendingOps.map( + op => freeItems.filter(item => this.isCreateDataMatchingNode(op.createData, item)), + ) + + const unique = candidatesPerOp.findIndex(candidates => candidates.length === 1) + if (unique >= 0) { + takePair(unique, candidatesPerOp[unique]![0]!) + continue + } + + const ambiguous = candidatesPerOp.findIndex(candidates => candidates.length > 1) + if (ambiguous >= 0) { + takePair(ambiguous, candidatesPerOp[ambiguous]![0]!) + continue + } + + break + } + + if (pendingOps.length === 1 && freeItems.length === 1) { + takePair(0, freeItems[0]!) + } + + return pairs + } + /** * Content-based matching: checks whether create data matches a response node * by comparing scalars and hasOne relation IDs. Same approach as the legacy diff --git a/tests/unit/persistence/nestedCreateNormalisedScalar.test.ts b/tests/unit/persistence/nestedCreateNormalisedScalar.test.ts new file mode 100644 index 00000000..a92dc0ad --- /dev/null +++ b/tests/unit/persistence/nestedCreateNormalisedScalar.test.ts @@ -0,0 +1,265 @@ +import { describe, test, expect, mock } from 'bun:test' +import { + SnapshotStore, + MutationCollector, + ContemberSchemaMutationAdapter, + ActionDispatcher, + BatchPersister, + type BackendAdapter, + type SchemaNames, +} from '@contember/bindx' +import { buildNodeSelectionFromMutationData } from '@contember/bindx-client' + +/** + * Page → blocks (hasMany) → button (hasOne) → link (hasOne). + * + * The node selection is also the content matcher's input: every field it requests is a + * field the matcher then compares. These tests pin how the matcher behaves when a + * comparison cannot succeed — a server that echoes a value back normalised — and where + * it refuses to guess. + */ +const schema: SchemaNames = { + entities: { + Page: { + name: 'Page', + scalars: ['id', 'title'], + fields: { id: { type: 'column' }, title: { type: 'column' }, blocks: { type: 'many', entity: 'Block' } }, + }, + Block: { + name: 'Block', + scalars: ['id', 'order', 'type'], + fields: { + id: { type: 'column' }, + order: { type: 'column' }, + type: { type: 'column' }, + button: { type: 'one', entity: 'Button', nullable: true }, + }, + }, + Button: { + name: 'Button', + scalars: ['id', 'label'], + fields: { id: { type: 'column' }, label: { type: 'column' }, link: { type: 'one', entity: 'Link', nullable: true } }, + }, + Link: { + name: 'Link', + scalars: ['id', 'type', 'publishedAt'], + fields: { id: { type: 'column' }, type: { type: 'column' }, publishedAt: { type: 'column' } }, + }, + }, + enums: {}, +} + +interface SelectionField { + readonly name: string + readonly selectionSet?: readonly unknown[] +} + +interface NodeSelection { + name: string + children?: NodeSelection[] +} + +function isSelectionField(value: unknown): value is SelectionField { + return typeof value === 'object' && value !== null && 'name' in value && typeof value.name === 'string' +} + +function readSelection(selectionSet: readonly unknown[]): NodeSelection[] { + return selectionSet.map(item => { + if (!isSelectionField(item)) throw new Error('selection entry is not a GraphQL field') + return { name: item.name, children: item.selectionSet ? readSelection(item.selectionSet) : undefined } + }) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +interface ServerOptions { + /** Normalises a scalar on its way back out, the way a datetime or decimal column does. */ + readonly normalise?: (fieldName: string, value: unknown) => unknown + /** Returns hasMany rows in reverse — the API guarantees no ordering. */ + readonly reverseRows?: boolean +} + +interface MockServer { + readonly adapter: BackendAdapter + /** Every row the server built, keyed by the ID it assigned — the ground truth for pairing. */ + readonly rowsById: Map> +} + +/** + * Echoes back exactly the fields the node selection asked for — the contract a real + * Contember API honours — optionally normalising scalars and reordering hasMany rows. + */ +function createMockServer(options: ServerOptions = {}): MockServer { + let serverIdCounter = 0 + const rowsById = new Map>() + + const buildNode = (data: Record, selection: NodeSelection[]): Record => { + const selected = new Map(selection.map(field => [field.name, field])) + const node: Record = { id: `server-${++serverIdCounter}` } + + for (const [fieldName, value] of Object.entries(data)) { + const fieldSelection = selected.get(fieldName) + if (!fieldSelection || value === null || value === undefined) continue + + if (Array.isArray(value)) { + const rows: Record[] = [] + for (const op of value) { + if (!isRecord(op)) continue + const create = op['create'] + if (isRecord(create)) rows.push(buildNode(create, fieldSelection.children ?? [])) + } + if (rows.length > 0) node[fieldName] = options.reverseRows ? rows.reverse() : rows + } else if (isRecord(value)) { + const create = value['create'] + if (isRecord(create)) node[fieldName] = buildNode(create, fieldSelection.children ?? []) + } else { + node[fieldName] = options.normalise ? options.normalise(fieldName, value) : value + } + } + + const id = node['id'] + if (typeof id === 'string') rowsById.set(id, node) + return node + } + + const respond = (data: Record) => + Promise.resolve({ ok: true, data: buildNode(data, readSelection(buildNodeSelectionFromMutationData(data))) }) + + return { + rowsById, + adapter: { + query: mock(() => Promise.resolve([])), + delete: mock(() => Promise.resolve({ ok: true })), + persist: mock((_entityType: string, _entityId: string, changes: Record) => respond(changes)), + create: mock((_entityType: string, data: Record) => respond(data)), + }, + } +} + +function createPersister(store: SnapshotStore, adapter: BackendAdapter): BatchPersister { + const schemaAdapter = new ContemberSchemaMutationAdapter(schema) + return new BatchPersister(adapter, store, new ActionDispatcher(store), { + mutationCollector: new MutationCollector(store, schemaAdapter), + schema: schemaAdapter as never, // ContemberSchemaMutationAdapter satisfies MutationSchemaProvider + }) +} + +function seedPage(store: SnapshotStore): void { + store.setEntityData('Page', 'page-1', { id: 'page-1', title: 'Contacts' }, true) + store.setExistsOnServer('Page', 'page-1', true) + store.getOrCreateHasMany('Page', 'page-1', 'blocks', []) +} + +function addBlock(store: SnapshotStore, data: Record): string { + const blockId = store.createEntity('Block', data) + store.addToHasMany('Page', 'page-1', 'blocks', blockId) + return blockId +} + +function connect(store: SnapshotStore, parentType: string, parentId: string, field: string, childId: string): void { + store.getOrCreateRelation(parentType, parentId, field, { + currentId: childId, serverId: null, state: 'connected', serverState: 'disconnected', placeholderData: {}, + }) +} + +/** ISO-normalises a date-only value, the shape a datetime column echoes back. */ +const normaliseDate = (fieldName: string, value: unknown): unknown => + fieldName === 'publishedAt' && value === '2024-01-01' ? '2024-01-01T00:00:00.000Z' : value + +describe('Nested create reconciliation when the server normalises a scalar', () => { + test('maps every sibling even though the normalised value matches nothing', async () => { + const store = new SnapshotStore() + const server = createMockServer({ normalise: normaliseDate }) + const persister = createPersister(store, server.adapter) + seedPage(store) + + // The link subtree only reaches the node selection because sibling shapes are + // unioned; comparing it byte-for-byte is what the normalised date defeats. + const blockWithLink = addBlock(store, { order: 1, type: 'button' }) + const buttonWithLink = store.createEntity('Button', { label: 'A' }) + const link = store.createEntity('Link', { type: 'external', publishedAt: '2024-01-01' }) + connect(store, 'Block', blockWithLink, 'button', buttonWithLink) + connect(store, 'Button', buttonWithLink, 'link', link) + + const plainBlock = addBlock(store, { order: 2, type: 'button' }) + const plainButton = store.createEntity('Button', { label: 'B' }) + connect(store, 'Block', plainBlock, 'button', plainButton) + + expect((await persister.persistAll()).success).toBe(true) + + expect(store.getPersistedId('Block', blockWithLink)).not.toBeNull() + expect(store.getPersistedId('Button', buttonWithLink)).not.toBeNull() + expect(store.getPersistedId('Link', link)).not.toBeNull() + expect(store.getPersistedId('Block', plainBlock)).not.toBeNull() + expect(store.getPersistedId('Button', plainButton)).not.toBeNull() + + expect(store.getPersistedId('Block', blockWithLink)).not.toBe(store.getPersistedId('Block', plainBlock)) + }) + + test('refuses to pair when two siblings are both unmatched, rather than guessing', async () => { + const store = new SnapshotStore() + const server = createMockServer({ normalise: normaliseDate }) + const persister = createPersister(store, server.adapter) + seedPage(store) + + // Both blocks carry the defeated comparison, so neither can be identified and + // elimination has nothing unambiguous to fall back on. + const blockIds = [1, 2].map(order => { + const blockId = addBlock(store, { order, type: 'button' }) + const buttonId = store.createEntity('Button', { label: `label-${order}` }) + const linkId = store.createEntity('Link', { type: 'external', publishedAt: '2024-01-01' }) + connect(store, 'Block', blockId, 'button', buttonId) + connect(store, 'Button', buttonId, 'link', linkId) + return blockId + }) + + expect((await persister.persistAll()).success).toBe(true) + + for (const blockId of blockIds) { + expect(store.getPersistedId('Block', blockId)).toBeNull() + } + }) +}) + +describe('Pairing create operations with response rows', () => { + test('an ambiguous payload does not consume the row its sibling matches uniquely', async () => { + const store = new SnapshotStore() + const server = createMockServer({ reverseRows: true }) + const persister = createPersister(store, server.adapter) + seedPage(store) + + // The first payload is a strict subset of the second, so it matches both rows. + const looseBlock = addBlock(store, { order: 1 }) + const preciseBlock = addBlock(store, { order: 1, type: 'button' }) + + expect((await persister.persistAll()).success).toBe(true) + + const looseId = store.getPersistedId('Block', looseBlock) + const preciseId = store.getPersistedId('Block', preciseBlock) + expect(looseId).not.toBeNull() + expect(preciseId).not.toBeNull() + expect(looseId).not.toBe(preciseId) + + // Each block must own the row built from its own payload. + expect(server.rowsById.get(looseId!)?.['type']).toBeUndefined() + expect(server.rowsById.get(preciseId!)?.['type']).toBe('button') + }) + + test('maps indistinguishable siblings, which any pairing describes equally well', async () => { + const store = new SnapshotStore() + const server = createMockServer() + const persister = createPersister(store, server.adapter) + seedPage(store) + + const first = addBlock(store, { order: 1, type: 'button' }) + const second = addBlock(store, { order: 1, type: 'button' }) + + expect((await persister.persistAll()).success).toBe(true) + + expect(store.getPersistedId('Block', first)).not.toBeNull() + expect(store.getPersistedId('Block', second)).not.toBeNull() + expect(store.getPersistedId('Block', first)).not.toBe(store.getPersistedId('Block', second)) + }) +}) From c83c48fc2be67ba44bbb85583229e2e470b2bba0 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 19 Aug 2026 14:43:26 +0200 Subject: [PATCH 06/55] perf(bindx): memoize the dirty-entity scan per store write version (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChangeRegistry.getDirtyEntities() ran a full-store scan — deepEqual of data vs serverData for every snapshot, plus a reachability walk and a per-entity dirtyFields/dirtyRelations pass. usePersist feeds it to useSyncExternalStore, which React runs synchronously inside every store notification, and list reorder helpers emit one notification per reindexed item. A single delete in a large sortable list therefore cost O(N*M) full scans before React rendered. The result is now memoized per store write version. The key is deliberately NOT getVersion(): that is the subscription manager's globalVersion, bumped only inside notifying paths, and several dirtiness-changing writes do not notify — createEntity registers its root AFTER its last notification, registerParentChild un-registers a root silently, commitAllRelations and resetAllRelations never notify, and refreshServerData can skip notifying. Keying on it would serve a stale empty dirty set to the very first read, which happens synchronously inside createEntity's own notification. Instead getDirtyVersion() sums monotonic sub-store mutation counters, the pattern ReachabilityAnalyzer already uses: a new dataWriteVersion on EntitySnapshotStore plus the meta mutation/editable counters, the relation counter and the root registry counter. A sum of monotonic counters is strictly increasing on any bump, so an unchanged sum proves no dirtiness-relevant write happened, independent of whether anything was notified. getDirtyEntitiesNotInFlight() keeps filtering on every call, since in-flight state changes without any store write. This is the memoization fix only. Coalescing the notification storm and incremental dirty tracking are separate; a delete in an N-item list still fires ~N notifications, each now O(1) instead of a full scan. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee --- .../bindx/src/persistence/ChangeRegistry.ts | 23 ++- .../bindx/src/store/EntitySnapshotStore.ts | 30 ++- packages/bindx/src/store/SnapshotStore.ts | 24 +++ .../dirtyEntitiesMemoization.test.ts | 180 ++++++++++++++++++ 4 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 tests/unit/persistence/dirtyEntitiesMemoization.test.ts diff --git a/packages/bindx/src/persistence/ChangeRegistry.ts b/packages/bindx/src/persistence/ChangeRegistry.ts index 338e69af..5628f44a 100644 --- a/packages/bindx/src/persistence/ChangeRegistry.ts +++ b/packages/bindx/src/persistence/ChangeRegistry.ts @@ -23,6 +23,9 @@ export class ChangeRegistry { /** Subscribers to in-flight state changes */ private readonly subscribers = new Set<() => void>() + /** Memoized {@link getDirtyEntities} result, keyed by the store's dirty version. */ + private dirtyCache: { version: number; result: readonly DirtyEntity[] } | null = null + constructor(private readonly store: SnapshotStore) {} /** @@ -34,21 +37,37 @@ export class ChangeRegistry { /** * Gets all dirty entities with their change types and dirty fields/relations. + * + * The underlying scan walks every entity snapshot, so it is memoized on + * {@link SnapshotStore.getDirtyVersion} — a global store subscriber reads this + * synchronously from inside every notification, and list helpers emit one + * notification per touched item. Within a version the same array instance is + * returned; callers must treat it as read-only (the type already says so). */ getDirtyEntities(): readonly DirtyEntity[] { - const rawDirty = this.store.getAllDirtyEntities() + const version = this.store.getDirtyVersion() + const cached = this.dirtyCache + if (cached !== null && cached.version === version) { + return cached.result + } - return rawDirty.map(entity => ({ + const result = this.store.getAllDirtyEntities().map(entity => ({ entityType: entity.entityType, entityId: entity.entityId, changeType: entity.changeType, dirtyFields: this.store.getDirtyFields(entity.entityType, entity.entityId), dirtyRelations: this.store.getDirtyRelations(entity.entityType, entity.entityId), })) + + this.dirtyCache = { version, result } + return result } /** * Gets dirty entities that are not currently in-flight. + * + * Filtered on every call, never memoized: in-flight membership lives on this + * registry and changes with no store write, so it does not move the dirty version. */ getDirtyEntitiesNotInFlight(): readonly DirtyEntity[] { return this.getDirtyEntities().filter( diff --git a/packages/bindx/src/store/EntitySnapshotStore.ts b/packages/bindx/src/store/EntitySnapshotStore.ts index 84febbe3..431012b2 100644 --- a/packages/bindx/src/store/EntitySnapshotStore.ts +++ b/packages/bindx/src/store/EntitySnapshotStore.ts @@ -43,6 +43,17 @@ export class EntitySnapshotStore implements Rekeyable { */ private editableWriteVersion = 0 + /** + * Monotonic counter bumped on every write that can change a snapshot's `data` + * or `serverData` — i.e. every write that can change whether the entity is + * dirty. Unlike the two counters above it spans BOTH layers: `mutationVersion` + * ignores value edits and `editableWriteVersion` ignores server-baseline writes + * (refreshServerData/commit/commitFields), so neither alone is a sound dirty + * key. {@link bumpVersion} is excluded — it rewrites neither side. Read by + * SnapshotStore.getDirtyVersion() to memoize the full-store dirty scan. + */ + private dataWriteVersion = 0 + getMutationVersion(): number { return this.mutationVersion } @@ -51,6 +62,10 @@ export class EntitySnapshotStore implements Rekeyable { return this.editableWriteVersion } + getDataWriteVersion(): number { + return this.dataWriteVersion + } + get(key: string): EntitySnapshot | undefined { return this.snapshots.get(key) } @@ -92,6 +107,7 @@ export class EntitySnapshotStore implements Rekeyable { this.idIndex.set(id, key) if (!existing) this.mutationVersion++ if (!isServerData) this.editableWriteVersion++ + this.dataWriteVersion++ return newSnapshot } @@ -142,6 +158,7 @@ export class EntitySnapshotStore implements Rekeyable { ) this.snapshots.set(key, newSnapshot) + this.dataWriteVersion++ return newSnapshot } @@ -167,6 +184,7 @@ export class EntitySnapshotStore implements Rekeyable { this.snapshots.set(key, newSnapshot) this.editableWriteVersion++ + this.dataWriteVersion++ return newSnapshot } @@ -189,6 +207,7 @@ export class EntitySnapshotStore implements Rekeyable { this.snapshots.set(key, newSnapshot) this.editableWriteVersion++ + this.dataWriteVersion++ return true } @@ -208,6 +227,7 @@ export class EntitySnapshotStore implements Rekeyable { ) this.snapshots.set(key, newSnapshot) + this.dataWriteVersion++ } /** @@ -227,6 +247,7 @@ export class EntitySnapshotStore implements Rekeyable { this.snapshots.set(key, newSnapshot) this.editableWriteVersion++ + this.dataWriteVersion++ } /** @@ -243,6 +264,7 @@ export class EntitySnapshotStore implements Rekeyable { this.idIndex.delete(existing.id) this.mutationVersion++ } + if (existing) this.dataWriteVersion++ this.snapshots.delete(key) } @@ -291,6 +313,7 @@ export class EntitySnapshotStore implements Rekeyable { ) this.snapshots.set(key, newSnapshot) + this.dataWriteVersion++ } /** @@ -318,7 +341,10 @@ export class EntitySnapshotStore implements Rekeyable { this.idIndex.set(snapshot.id, key) keys.add(key) } - if (keys.size > 0) this.mutationVersion++ + if (keys.size > 0) { + this.mutationVersion++ + this.dataWriteVersion++ + } return keys } @@ -347,6 +373,7 @@ export class EntitySnapshotStore implements Rekeyable { this.idIndex.delete(snapshot.id) this.idIndex.set(ctx.newId, ctx.newKey) this.mutationVersion++ + this.dataWriteVersion++ } keys(): IterableIterator { @@ -367,6 +394,7 @@ export class EntitySnapshotStore implements Rekeyable { this.snapshots.clear() this.idIndex.clear() this.mutationVersion++ + this.dataWriteVersion++ } } diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index cb98f5c9..7882d038 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -1256,6 +1256,30 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { // ==================== Dirty Tracking (delegated to DirtyTracker) ==================== + /** + * Sum of the monotonic mutation counters of every sub-store {@link getAllDirtyEntities} + * reads. Strictly increasing, so an unchanged value proves no write could have + * changed the dirty set — the cache key {@link ChangeRegistry} memoizes the + * full-store scan on. + * + * Deliberately NOT derived from {@link getVersion} (the notification version): + * several writes change dirtiness without notifying — `createEntity` registers + * its root after its last notify, `registerParentChild` un-registers one, + * `commit/resetAllRelations` never notify, and `refreshServerData` can skip it — + * so a notification-keyed memo would serve a stale result. Counters bump inside + * the write itself and cannot be bypassed that way. + * + * The entity snapshot store contributes its data-write counter alone: it bumps + * on a superset of the writes `getMutationVersion()` covers. + */ + getDirtyVersion(): number { + return this.entitySnapshots.getDataWriteVersion() + + this.meta.getMutationVersion() + + this.meta.getEditableWriteVersion() + + this.relations.getMutationVersion() + + this.roots.getMutationVersion() + } + getAllDirtyEntities(): Array<{ entityType: string entityId: string diff --git a/tests/unit/persistence/dirtyEntitiesMemoization.test.ts b/tests/unit/persistence/dirtyEntitiesMemoization.test.ts new file mode 100644 index 00000000..3b35f244 --- /dev/null +++ b/tests/unit/persistence/dirtyEntitiesMemoization.test.ts @@ -0,0 +1,180 @@ +// Memoization of the full-store dirty scan behind ChangeRegistry.getDirtyEntities(). +// +// The scan (SnapshotStore.getAllDirtyEntities → DirtyTracker) walks every entity +// snapshot, deep-compares its scalars against the server baseline and runs a +// reachability walk. A global store subscriber (the save-button hook) reads it +// synchronously from inside EVERY store notification, and list helpers emit one +// notification per touched item — so a single removal in a large list used to cost +// one full scan per notification. +// +// The cache key must be notification-INDEPENDENT: several store writes change +// dirtiness without bumping the notification version (createEntity registers its +// root after the last notify; commitAllRelations and a skipNotify refreshServerData +// do not notify at all). The key therefore sums the sub-stores' monotonic mutation +// counters, the same pattern ReachabilityAnalyzer uses. +import { describe, test, expect } from 'bun:test' +import { ChangeRegistry, SnapshotStore } from '@contember/bindx' + +interface Harness { + store: SnapshotStore + registry: ChangeRegistry + scanCount: () => number +} + +function createHarness(): Harness { + const store = new SnapshotStore() + + // Spy: getAllDirtyEntities is the full-store scan ChangeRegistry delegates to, + // so a stable count across calls proves the memo served them without re-scanning. + let scans = 0 + const original = store.getAllDirtyEntities.bind(store) + store.getAllDirtyEntities = () => { + scans++ + return original() + } + + return { store, registry: new ChangeRegistry(store), scanCount: () => scans } +} + +/** Server-loaded Article a1 with a locally edited title. */ +function seedEditedArticle(store: SnapshotStore): void { + store.setEntityData('Article', 'a1', { id: 'a1', title: 'server' }, true) + store.setFieldValue('Article', 'a1', ['title'], 'edited') +} + +describe('ChangeRegistry dirty-scan memoization', () => { + test('repeated calls without a store mutation run exactly one scan', () => { + const { store, registry, scanCount } = createHarness() + seedEditedArticle(store) + + const first = registry.getDirtyEntities() + for (let i = 0; i < 20; i++) { + expect(registry.getDirtyEntities()).toBe(first) + } + + expect(scanCount()).toBe(1) + expect(first).toEqual([{ + entityType: 'Article', + entityId: 'a1', + changeType: 'update', + dirtyFields: ['title'], + dirtyRelations: [], + }]) + }) + + test('a scalar field edit invalidates the memo', () => { + const { store, registry, scanCount } = createHarness() + store.setEntityData('Article', 'a1', { id: 'a1', title: 'server' }, true) + + expect(registry.getDirtyEntities()).toEqual([]) + expect(scanCount()).toBe(1) + + store.setFieldValue('Article', 'a1', ['title'], 'edited') + + expect(registry.getDirtyEntities()).toEqual([{ + entityType: 'Article', + entityId: 'a1', + changeType: 'update', + dirtyFields: ['title'], + dirtyRelations: [], + }]) + expect(scanCount()).toBe(2) + }) + + test('a relation mutation invalidates the memo, and so does committing it', () => { + const { store, registry } = createHarness() + store.setEntityData('Article', 'a1', { id: 'a1', title: 'server' }, true) + store.setEntityData('Author', 'u1', { id: 'u1' }, true) + + expect(registry.getDirtyEntities()).toEqual([]) + + store.setRelation('Article', 'a1', 'author', { currentId: 'u1', state: 'connected' }) + + expect(registry.getDirtyEntities()).toEqual([{ + entityType: 'Article', + entityId: 'a1', + changeType: 'update', + dirtyFields: [], + dirtyRelations: ['author'], + }]) + + // commitAllRelations notifies nothing at all (it runs on persist success), + // so only a counter-based key sees it. + store.commitAllRelations('Article', 'a1') + + expect(registry.getDirtyEntities()).toEqual([]) + }) + + test('createEntity invalidates the memo even though it registers its root after the last notification', () => { + const { store, registry } = createHarness() + + // The save-button hook subscribes globally and reads the dirty set + // synchronously from inside the notification — i.e. mid-createEntity, before + // roots.register() has run and with no further notification to follow it. + const readsDuringNotify: number[] = [] + store.subscribe(() => { + readsDuringNotify.push(registry.getDirtyEntities().length) + }) + + const id = store.createEntity('Article', { title: 'draft' }) + + expect(readsDuringNotify.length).toBeGreaterThan(0) + expect(readsDuringNotify.every(count => count === 0)).toBe(true) + + expect(registry.getDirtyEntities()).toEqual([{ + entityType: 'Article', + entityId: id, + changeType: 'create', + dirtyFields: [], + dirtyRelations: [], + }]) + }) + + test('a silent server-baseline refresh invalidates the memo', () => { + const { store, registry } = createHarness() + seedEditedArticle(store) + + expect(registry.getDirtyEntities()).toHaveLength(1) + + // Revalidation that adopts the local value as the new baseline, with + // notification suppressed — the entity is clean afterwards. + store.refreshServerData('Article', 'a1', { id: 'a1', title: 'edited' }, true) + + expect(registry.getDirtyEntities()).toEqual([]) + }) + + test('scheduling a deletion invalidates the memo', () => { + const { store, registry } = createHarness() + store.setEntityData('Article', 'a1', { id: 'a1', title: 'server' }, true) + + expect(registry.getDirtyEntities()).toEqual([]) + + store.scheduleForDeletion('Article', 'a1') + + expect(registry.getDirtyEntities()).toEqual([{ + entityType: 'Article', + entityId: 'a1', + changeType: 'delete', + dirtyFields: [], + dirtyRelations: [], + }]) + }) + + test('getDirtyEntitiesNotInFlight re-filters on every call, without re-scanning', () => { + const { store, registry, scanCount } = createHarness() + seedEditedArticle(store) + + expect(registry.getDirtyEntitiesNotInFlight()).toHaveLength(1) + const scansAfterFirstRead = scanCount() + + // In-flight state lives on the registry, not the store: it changes with no + // store write, so the filter must run even while the memo stays warm. + registry.markInFlight([{ entityType: 'Article', entityId: 'a1' }]) + expect(registry.getDirtyEntitiesNotInFlight()).toHaveLength(0) + + registry.clearInFlight([{ entityType: 'Article', entityId: 'a1' }]) + expect(registry.getDirtyEntitiesNotInFlight()).toHaveLength(1) + + expect(scanCount()).toBe(scansAfterFirstRead) + }) +}) From 06ca1efa7acfedaf5d74dd4f63e14049a56c3225 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 19 Aug 2026 15:28:10 +0200 Subject: [PATCH 07/55] refactor(bindx): guard the dirty-version key behind a write chokepoint (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up to the dirty-scan memo. The memo is only as correct as its key, and `dataWriteVersion++` was hand-maintained across 11 call sites in EntitySnapshotStore. A contributor adding a mutating method — or an early-return branch to an existing one — and forgetting the bump would break nothing loudly: the store would serve a stale dirty set for the rest of the session, and the user would see a dead Save button. Writes now go through writeSnapshot/deleteSnapshot, which own the bump, the same shape HasOneStore uses for writeRelation/deleteRelation. bumpVersion is the one documented bypass: it reuses the same data/serverData refs so it cannot change dirtiness, and it runs once per ancestor on every notification, so bumping there would keep the cache permanently cold. The chokepoint makes it hard to get wrong; the new guard test is what enforces it. It classifies every name on the prototype into mutating / non-mutating / internal and fails on anything unclassified, then asserts each mutating method moves the key and each non-mutating one does not — so a new method cannot be added without a deliberate decision about its bump. Also: hasDirtyEntities() shares the memo instead of running its own full scan; getDirtyVersion() is marked @internal and documents the constraint the memo now depends on — snapshot values must be replaced, never mutated in place, since createEntitySnapshot freezes only the top level. The memoization test no longer asserts that a mid-write read sees an empty dirty set. That pinned one of the known missing-notification bugs as expected behaviour and would have handed a red test to whoever fixes it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee --- .../bindx/src/persistence/ChangeRegistry.ts | 7 +- .../bindx/src/store/EntitySnapshotStore.ts | 62 ++++--- packages/bindx/src/store/SnapshotStore.ts | 11 ++ .../dirtyEntitiesMemoization.test.ts | 45 ++++- tests/unit/store/dirtyVersionCoverage.test.ts | 175 ++++++++++++++++++ 5 files changed, 269 insertions(+), 31 deletions(-) create mode 100644 tests/unit/store/dirtyVersionCoverage.test.ts diff --git a/packages/bindx/src/persistence/ChangeRegistry.ts b/packages/bindx/src/persistence/ChangeRegistry.ts index 5628f44a..75af177b 100644 --- a/packages/bindx/src/persistence/ChangeRegistry.ts +++ b/packages/bindx/src/persistence/ChangeRegistry.ts @@ -43,6 +43,10 @@ export class ChangeRegistry { * synchronously from inside every notification, and list helpers emit one * notification per touched item. Within a version the same array instance is * returned; callers must treat it as read-only (the type already says so). + * + * The key only sees writes that go through the store, so snapshot values must be + * replaced rather than mutated in place — snapshots are frozen only at the top + * level. See {@link SnapshotStore.getDirtyVersion}. */ getDirtyEntities(): readonly DirtyEntity[] { const version = this.store.getDirtyVersion() @@ -145,7 +149,8 @@ export class ChangeRegistry { * Checks if there are any dirty entities. */ hasDirtyEntities(): boolean { - return this.store.getAllDirtyEntities().length > 0 + // Shares the memo: this is public API a save button may be built on. + return this.getDirtyEntities().length > 0 } /** diff --git a/packages/bindx/src/store/EntitySnapshotStore.ts b/packages/bindx/src/store/EntitySnapshotStore.ts index 431012b2..5d33557d 100644 --- a/packages/bindx/src/store/EntitySnapshotStore.ts +++ b/packages/bindx/src/store/EntitySnapshotStore.ts @@ -66,6 +66,25 @@ export class EntitySnapshotStore implements Rekeyable { return this.dataWriteVersion } + /** + * The single write chokepoint. Every path that installs a snapshot goes through + * here, so {@link dataWriteVersion} cannot be forgotten by a new mutating method + * or a new early-return branch — a stale dirty memo is silent (dead Save button), + * so the bump must not be hand-maintained per call site. Mirrors + * HasOneStore.writeRelation / HasManyStore.writeHasMany. + */ + private writeSnapshot(key: string, snapshot: EntitySnapshot): void { + this.snapshots.set(key, snapshot) + this.dataWriteVersion++ + } + + /** The single delete chokepoint — counterpart of {@link writeSnapshot}. */ + private deleteSnapshot(key: string): void { + if (this.snapshots.delete(key)) { + this.dataWriteVersion++ + } + } + get(key: string): EntitySnapshot | undefined { return this.snapshots.get(key) } @@ -103,11 +122,10 @@ export class EntitySnapshotStore implements Rekeyable { (existing?.version ?? 0) + 1, ) - this.snapshots.set(key, newSnapshot) + this.writeSnapshot(key, newSnapshot) this.idIndex.set(id, key) if (!existing) this.mutationVersion++ if (!isServerData) this.editableWriteVersion++ - this.dataWriteVersion++ return newSnapshot } @@ -157,8 +175,7 @@ export class EntitySnapshotStore implements Rekeyable { existing.version + 1, ) - this.snapshots.set(key, newSnapshot) - this.dataWriteVersion++ + this.writeSnapshot(key, newSnapshot) return newSnapshot } @@ -182,9 +199,8 @@ export class EntitySnapshotStore implements Rekeyable { existing.version + 1, ) - this.snapshots.set(key, newSnapshot) + this.writeSnapshot(key, newSnapshot) this.editableWriteVersion++ - this.dataWriteVersion++ return newSnapshot } @@ -205,9 +221,8 @@ export class EntitySnapshotStore implements Rekeyable { existing.version + 1, ) - this.snapshots.set(key, newSnapshot) + this.writeSnapshot(key, newSnapshot) this.editableWriteVersion++ - this.dataWriteVersion++ return true } @@ -226,8 +241,7 @@ export class EntitySnapshotStore implements Rekeyable { existing.version + 1, ) - this.snapshots.set(key, newSnapshot) - this.dataWriteVersion++ + this.writeSnapshot(key, newSnapshot) } /** @@ -245,9 +259,8 @@ export class EntitySnapshotStore implements Rekeyable { existing.version + 1, ) - this.snapshots.set(key, newSnapshot) + this.writeSnapshot(key, newSnapshot) this.editableWriteVersion++ - this.dataWriteVersion++ } /** @@ -264,13 +277,17 @@ export class EntitySnapshotStore implements Rekeyable { this.idIndex.delete(existing.id) this.mutationVersion++ } - if (existing) this.dataWriteVersion++ - this.snapshots.delete(key) + this.deleteSnapshot(key) } /** * Bumps the version of an entity snapshot without changing data. * Used by SubscriptionManager when child entities change. + * + * The ONE deliberate bypass of {@link writeSnapshot}: it reinstalls the very same + * `data` and `serverData` references under a new version, so it cannot change + * dirtiness and must not invalidate the dirty memo — it runs once per ancestor on + * every notification, which would keep the cache permanently cold. */ bumpVersion(key: string): void { const existing = this.snapshots.get(key) @@ -312,8 +329,7 @@ export class EntitySnapshotStore implements Rekeyable { existing.version + 1, ) - this.snapshots.set(key, newSnapshot) - this.dataWriteVersion++ + this.writeSnapshot(key, newSnapshot) } /** @@ -337,14 +353,11 @@ export class EntitySnapshotStore implements Rekeyable { importSnapshots(snapshots: Map): Set { const keys = new Set() for (const [key, snapshot] of snapshots) { - this.snapshots.set(key, snapshot) + this.writeSnapshot(key, snapshot) this.idIndex.set(snapshot.id, key) keys.add(key) } - if (keys.size > 0) { - this.mutationVersion++ - this.dataWriteVersion++ - } + if (keys.size > 0) this.mutationVersion++ return keys } @@ -368,12 +381,11 @@ export class EntitySnapshotStore implements Rekeyable { snapshot.version + 1, ) - this.snapshots.delete(ctx.oldKey) - this.snapshots.set(ctx.newKey, newSnapshot) + this.deleteSnapshot(ctx.oldKey) + this.writeSnapshot(ctx.newKey, newSnapshot) this.idIndex.delete(snapshot.id) this.idIndex.set(ctx.newId, ctx.newKey) this.mutationVersion++ - this.dataWriteVersion++ } keys(): IterableIterator { @@ -391,6 +403,8 @@ export class EntitySnapshotStore implements Rekeyable { } clear(): void { + // Bulk drop: bumps both counters inline rather than per key, the same shape as + // HasOneStore.clear / HasManyStore.clear. this.snapshots.clear() this.idIndex.clear() this.mutationVersion++ diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 7882d038..d34d53ca 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -1271,6 +1271,17 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { * * The entity snapshot store contributes its data-write counter alone: it bumps * on a superset of the writes `getMutationVersion()` covers. + * + * REQUIRES snapshot values to be replaced, never mutated in place. + * `createEntitySnapshot` freezes only the top level, so a nested object inside + * `data` (a rich-text JSON payload, say) stays writable. Editing one in place + * changes dirtiness without touching any counter, and the memo will serve the + * pre-edit answer — where the former unconditional scan happened to notice it on + * the next read. The store already implies this rule by freezing at all; the memo + * is what makes breaking it consequential. + * + * @internal Cache key for the dirty-scan memo. Monotonic and otherwise + * meaningless — the absolute value carries no consumer-facing information. */ getDirtyVersion(): number { return this.entitySnapshots.getDataWriteVersion() diff --git a/tests/unit/persistence/dirtyEntitiesMemoization.test.ts b/tests/unit/persistence/dirtyEntitiesMemoization.test.ts index 3b35f244..478c94bc 100644 --- a/tests/unit/persistence/dirtyEntitiesMemoization.test.ts +++ b/tests/unit/persistence/dirtyEntitiesMemoization.test.ts @@ -110,17 +110,20 @@ describe('ChangeRegistry dirty-scan memoization', () => { // The save-button hook subscribes globally and reads the dirty set // synchronously from inside the notification — i.e. mid-createEntity, before - // roots.register() has run and with no further notification to follow it. - const readsDuringNotify: number[] = [] + // roots.register() has run — which is how a stale entry gets into the memo. + // What those mid-write reads return is NOT asserted here: whether the create + // is visible from inside createEntity's own notification depends on the + // missing notification after roots.register, a separate open bug. This test + // pins only what the memo owes: the read AFTER the write is correct. + let readsDuringWrite = 0 store.subscribe(() => { - readsDuringNotify.push(registry.getDirtyEntities().length) + readsDuringWrite++ + registry.getDirtyEntities() }) const id = store.createEntity('Article', { title: 'draft' }) - expect(readsDuringNotify.length).toBeGreaterThan(0) - expect(readsDuringNotify.every(count => count === 0)).toBe(true) - + expect(readsDuringWrite).toBeGreaterThan(0) expect(registry.getDirtyEntities()).toEqual([{ entityType: 'Article', entityId: id, @@ -130,6 +133,36 @@ describe('ChangeRegistry dirty-scan memoization', () => { }]) }) + test('registerParentChild invalidates the memo — it un-roots a create without notifying', () => { + const { store, registry } = createHarness() + store.setEntityData('Article', 'a1', { id: 'a1' }, true) + const childId = store.createEntity('Comment', { text: 'draft' }) + + // Top-level create: a root, so a reachable create. + expect(registry.getDirtyEntities()).toHaveLength(1) + + // Anchoring it under a parent drops its root registration and notifies + // nothing. With no relation edge added, the create is no longer reachable — + // so the dirty set changes while the notification version does not. + store.registerParentChild('Article', 'a1', 'Comment', childId) + + expect(registry.getDirtyEntities()).toEqual([]) + }) + + test('resetAllRelations invalidates the memo — it notifies nothing at all', () => { + const { store, registry } = createHarness() + store.setEntityData('Article', 'a1', { id: 'a1' }, true) + store.setEntityData('Author', 'u1', { id: 'u1' }, true) + store.setRelation('Article', 'a1', 'author', { currentId: 'u1', state: 'connected' }) + + expect(registry.getDirtyEntities()).toHaveLength(1) + + // The rollback path: reverts the relation to its server state, silently. + store.resetAllRelations('Article', 'a1') + + expect(registry.getDirtyEntities()).toEqual([]) + }) + test('a silent server-baseline refresh invalidates the memo', () => { const { store, registry } = createHarness() seedEditedArticle(store) diff --git a/tests/unit/store/dirtyVersionCoverage.test.ts b/tests/unit/store/dirtyVersionCoverage.test.ts new file mode 100644 index 00000000..96abdbc1 --- /dev/null +++ b/tests/unit/store/dirtyVersionCoverage.test.ts @@ -0,0 +1,175 @@ +// Guard for the dirty-scan cache key (issue #65). +// +// ChangeRegistry memoizes the full-store dirty scan on SnapshotStore.getDirtyVersion(), +// a sum of monotonic sub-store counters. A write that changes dirtiness without +// moving that sum fails SILENTLY: the store keeps serving the pre-write dirty set +// for the rest of the session, so the Save button goes dead with nothing thrown and +// no test failing anywhere near the offending line. +// +// EntitySnapshotStore contributes the term that must cover both layers (`data` and +// `serverData`). Its writes funnel through the writeSnapshot/deleteSnapshot +// chokepoints, which own the bump — this test is what makes that structural +// property enforced rather than merely intended: +// - every method on the prototype is classified here, so a NEW method fails the +// test until someone decides which bucket it belongs in; +// - every method classified as mutating must move the counter. +import { describe, test, expect } from 'bun:test' +import { SnapshotStore } from '@contember/bindx' +import { EntitySnapshotStore } from '../../../packages/bindx/src/store/EntitySnapshotStore.js' +import { createEntitySnapshot } from '../../../packages/bindx/src/store/snapshots.js' + +interface StoreCase { + readonly method: string + readonly run: (store: EntitySnapshotStore) => void +} + +/** Seeds Article:a1 so the methods that need an existing snapshot actually write. */ +function seeded(): EntitySnapshotStore { + const store = new EntitySnapshotStore() + store.setData('Article:a1', 'a1', 'Article', { id: 'a1', title: 'server' }, true) + return store +} + +/** Every method that can change a snapshot's `data` or `serverData`. */ +const mutatingCases: readonly StoreCase[] = [ + { method: 'setData', run: s => { s.setData('Article:a2', 'a2', 'Article', { id: 'a2' }, true) } }, + { method: 'refreshServerData', run: s => { s.refreshServerData('Article:a1', 'a1', 'Article', { id: 'a1', title: 'fresh' }) } }, + { method: 'updateFields', run: s => { s.updateFields('Article:a1', { title: 'edited' }) } }, + { method: 'setFieldValue', run: s => { s.setFieldValue('Article:a1', ['title'], 'edited') } }, + { method: 'commit', run: s => { s.commit('Article:a1') } }, + { method: 'reset', run: s => { s.reset('Article:a1') } }, + { method: 'remove', run: s => { s.remove('Article:a1') } }, + { method: 'commitFields', run: s => { s.commitFields('Article:a1', ['title']) } }, + { + method: 'importSnapshots', + run: s => { + s.importSnapshots(new Map([ + ['Article:a3', createEntitySnapshot('a3', 'Article', { id: 'a3' }, { id: 'a3' }, 1)], + ])) + }, + }, + { + method: 'rekey', + run: s => { + s.rekey({ + oldKey: 'Article:a1', + newKey: 'Article:a9', + oldKeyPrefix: 'Article:a1:', + newKeyPrefix: 'Article:a9:', + oldId: 'a1', + newId: 'a9', + }) + }, + }, + { method: 'clear', run: s => { s.clear() } }, +] + +/** Reads and pure bookkeeping: must NOT move the counter. */ +const nonMutatingCases: readonly StoreCase[] = [ + { method: 'get', run: s => { s.get('Article:a1') } }, + { method: 'has', run: s => { s.has('Article:a1') } }, + { method: 'keys', run: s => { [...s.keys()] } }, + { method: 'keyForId', run: s => { s.keyForId('a1') } }, + { method: 'exportSnapshots', run: s => { s.exportSnapshots(['Article:a1']) } }, + { method: 'getMutationVersion', run: s => { s.getMutationVersion() } }, + { method: 'getEditableWriteVersion', run: s => { s.getEditableWriteVersion() } }, + { method: 'getDataWriteVersion', run: s => { s.getDataWriteVersion() } }, + // The one documented bypass: reinstalls the same data/serverData references + // under a new version, so it cannot change dirtiness and must keep the memo warm + // (it runs once per ancestor on every notification). + { method: 'bumpVersion', run: s => { s.bumpVersion('Article:a1') } }, +] + +/** The private chokepoints — exercised through every case above, not directly. */ +const internalMethods: readonly string[] = ['constructor', 'writeSnapshot', 'deleteSnapshot'] + +describe('EntitySnapshotStore dirty-version coverage', () => { + test('every method on the prototype is classified', () => { + const classified = new Set([ + ...mutatingCases.map(c => c.method), + ...nonMutatingCases.map(c => c.method), + ...internalMethods, + ]) + const unclassified = Object.getOwnPropertyNames(EntitySnapshotStore.prototype) + .filter(name => !classified.has(name)) + + // A new method landed. Decide whether it can change `data`/`serverData`: if it + // can, route it through writeSnapshot/deleteSnapshot and add it to + // mutatingCases; if it cannot, add it to nonMutatingCases. + expect(unclassified).toEqual([]) + }) + + for (const { method, run } of mutatingCases) { + test(`${method} moves the dirty version`, () => { + const store = seeded() + const before = store.getDataWriteVersion() + run(store) + expect(store.getDataWriteVersion()).toBeGreaterThan(before) + }) + } + + for (const { method, run } of nonMutatingCases) { + test(`${method} leaves the dirty version alone`, () => { + const store = seeded() + const before = store.getDataWriteVersion() + run(store) + expect(store.getDataWriteVersion()).toBe(before) + }) + } +}) + +// The four store writes that change dirtiness while notifying nothing (or notifying +// before the change lands). They are why the key cannot be getVersion(); each must +// still move getDirtyVersion(). +describe('SnapshotStore.getDirtyVersion covers the silent writes', () => { + test('createEntity — registers its root after the last notification', () => { + const store = new SnapshotStore() + const before = store.getDirtyVersion() + store.createEntity('Article', { title: 'draft' }) + expect(store.getDirtyVersion()).toBeGreaterThan(before) + }) + + test('registerParentChild — un-registers a root, notifies nothing', () => { + const store = new SnapshotStore() + store.setEntityData('Article', 'a1', { id: 'a1' }, true) + const childId = store.createEntity('Comment', { text: 'draft' }) + + const notifyVersion = store.getVersion() + const before = store.getDirtyVersion() + store.registerParentChild('Article', 'a1', 'Comment', childId) + + expect(store.getVersion()).toBe(notifyVersion) + expect(store.getDirtyVersion()).toBeGreaterThan(before) + }) + + test('commitAllRelations / resetAllRelations — notify nothing', () => { + const store = new SnapshotStore() + store.setEntityData('Article', 'a1', { id: 'a1' }, true) + store.setEntityData('Author', 'u1', { id: 'u1' }, true) + store.setRelation('Article', 'a1', 'author', { currentId: 'u1', state: 'connected' }) + + const notifyVersion = store.getVersion() + const beforeCommit = store.getDirtyVersion() + store.commitAllRelations('Article', 'a1') + expect(store.getDirtyVersion()).toBeGreaterThan(beforeCommit) + + const beforeReset = store.getDirtyVersion() + store.resetAllRelations('Article', 'a1') + expect(store.getDirtyVersion()).toBeGreaterThan(beforeReset) + + expect(store.getVersion()).toBe(notifyVersion) + }) + + test('refreshServerData with skipNotify', () => { + const store = new SnapshotStore() + store.setEntityData('Article', 'a1', { id: 'a1', title: 'server' }, true) + store.setFieldValue('Article', 'a1', ['title'], 'edited') + + const notifyVersion = store.getVersion() + const before = store.getDirtyVersion() + store.refreshServerData('Article', 'a1', { id: 'a1', title: 'edited' }, true) + + expect(store.getVersion()).toBe(notifyVersion) + expect(store.getDirtyVersion()).toBeGreaterThan(before) + }) +}) From 7a496219f05d222326be0f3631f7a035b5089b20 Mon Sep 17 00:00:00 2001 From: MalaRuze Date: Mon, 13 Jul 2026 14:50:56 +0200 Subject: [PATCH 08/55] test: failing repro for unstable accessor identity across renders in useEntityList Co-Authored-By: Claude Fable 5 --- .../useEntityList/accessorIdentity.test.tsx | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/react/hooks/useEntityList/accessorIdentity.test.tsx diff --git a/tests/react/hooks/useEntityList/accessorIdentity.test.tsx b/tests/react/hooks/useEntityList/accessorIdentity.test.tsx new file mode 100644 index 00000000..2019d6ac --- /dev/null +++ b/tests/react/hooks/useEntityList/accessorIdentity.test.tsx @@ -0,0 +1,166 @@ +import '../../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup, fireEvent } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + MockAdapter, + defineSchema, + entityDef, + scalar, + useEntityList, +} from '@contember/bindx-react' + +// Regression test for +// +// useEntityList's getSnapshot invalidates its list cache on every store version bump and then +// rebuilds each item via EntityHandle.create(...). A brand-new handle (and proxy) per item per +// store change means accessor identity is unstable even for entities whose data did not change, +// which defeats React.memo (and any identity-based caching) in list consumers: editing one +// item's field re-renders every sibling's subtree. + +afterEach(() => { + cleanup() +}) + +interface Author { + id: string + name: string +} + +interface TestSchema { + Author: Author +} + +const schema = defineSchema({ + entities: { + Author: { + fields: { + id: scalar(), + name: scalar(), + }, + }, + }, +}) + +const authorDef = entityDef('Author') + +function getByTestId(container: Element, testId: string): Element { + const el = container.querySelector(`[data-testid="${testId}"]`) + if (!el) throw new Error(`Element with data-testid="${testId}" not found`) + return el +} + +function createMockData() { + return { + Author: { + 'author-1': { id: 'author-1', name: 'John Doe' }, + 'author-2': { id: 'author-2', name: 'Jane Smith' }, + }, + } +} + +describe('useEntityList accessor identity', () => { + test('should return identity-stable accessors for unchanged entities when a sibling entity field changes', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const seenById: Record = {} + + function TestComponent(): React.ReactElement { + const authors = useEntityList(authorDef, {}, a => a.id().name()) + if (authors.$status !== 'ready') { + return
Loading...
+ } + for (const item of authors.items) { + ;(seenById[item.id] ??= []).push(item) + } + return ( +
+ {authors.items[0]!.name.value} + +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'first-name').textContent).toBe('John Doe') + }) + + // Change ONE entity's field — author-2 is untouched. + fireEvent.click(getByTestId(container, 'rename')) + await waitFor(() => { + expect(getByTestId(container, 'first-name').textContent).toBe('Renamed') + }) + + const untouched = seenById['author-2']! + expect(untouched.length).toBeGreaterThan(1) + // The accessor for the UNCHANGED entity must keep its identity across renders — + // this is what allows React.memo / useMemo consumers to skip unchanged items. + expect(untouched[untouched.length - 1]).toBe(untouched[0]) + }) + + test('should allow React.memo list children to bail out when a sibling entity changes', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const renderCounts: Record = {} + + interface RowProps { + // Matches what authors.items hands out; the row only reads .id / .name.value. + item: { id: string; name: { value: string | null; setValue: (v: string) => void } } + } + + const Row = React.memo(function Row({ item }: RowProps): React.ReactElement { + renderCounts[item.id] = (renderCounts[item.id] ?? 0) + 1 + return {item.name.value} + }) + + function TestComponent(): React.ReactElement { + const authors = useEntityList(authorDef, {}, a => a.id().name()) + if (authors.$status !== 'ready') { + return
Loading...
+ } + return ( +
+ {authors.items.map(item => ( + + ))} + +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'row-author-1').textContent).toBe('John Doe') + }) + + const countAfterMount = renderCounts['author-2']! + + fireEvent.click(getByTestId(container, 'rename')) + await waitFor(() => { + expect(getByTestId(container, 'row-author-1').textContent).toBe('Renamed') + }) + + // author-2 did not change — its memoized row must not re-render. + expect(renderCounts['author-2']).toBe(countAfterMount) + }) +}) From 011c4f4ff8ff3b790362d1404545c98007b05248 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 19 Aug 2026 14:53:07 +0200 Subject: [PATCH 09/55] fix(bindx-react): give useEntityList items a stable accessor identity (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useEntityList rebuilt every item's EntityHandle on every store version bump, including items whose data did not change, so item accessor identity was unstable across renders. That defeats React.memo in list consumers — editing one item re-rendered every sibling's subtree — and it cascaded: a fresh root handle starts with an empty relationHandleCache, so every nested HasOne and HasMany handle and their per-item proxy caches were rebuilt too. Items are now cached per (entityType, entityId) for the hook's lifetime: one handle and one proxy per id, reused for the id's whole life, with ids no longer in the list evicted on rebuild. The cache is dropped whenever a handle construction input changes, notably selectionMeta. Identity is deliberately NOT a change signal. Making it one would require a total per-entity change signal, and EntitySnapshot.version is not one — notifyEntitySubscribers bumps the parents' versions but never the notified key's own, so errors, touched, scheduled-deletion and optimistic persisting flags never move it. Keying re-wraps on it looked right and silently broke memoized rows for all of those. Instead identity means identity, and change delivery is the subscription's job — the contract PR #56 established for accessors generally. The reproducer's memoized Row is amended to subscribe via useField, which is the contract it now tests. All seven assertions are byte-identical to the original; only the component and its props type changed. Known limit, documented on the cache: a membership change on a DESCENDANT relation does not reach a subscriber on the root item, because notifyRelationSubscribers does not walk up the parent chain. Such a row must subscribe to the owner of the relation it renders. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee --- .../bindx-react/src/hooks/useEntityList.ts | 87 ++++++- .../useEntityList/accessorCache.test.tsx | 245 ++++++++++++++++++ .../useEntityList/accessorIdentity.test.tsx | 14 +- 3 files changed, 331 insertions(+), 15 deletions(-) create mode 100644 tests/react/hooks/useEntityList/accessorCache.test.tsx diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index d4cb345c..71c388fa 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -108,6 +108,60 @@ function createErrorListResult(error: FieldError): ErrorEntityListResult { } } +/** + * Per-id cache of the accessors `useEntityList` hands out. + * + * An EntityHandle is a stateless live view over the store, so one handle — and one proxy over it — + * serves an id for the whole life of the list. Identity therefore means identity and nothing else: + * it is stable across every change to the entity, which is what lets identity-keyed consumers + * (`React.memo` rows, `useMemo`) skip work. Change delivery is the subscription's job — a memoized + * consumer must subscribe via `` / `useField` / `useAccessor` to observe changes. + * + * Subscribe to the entity that OWNS the changed relation, not merely to the row's own entity: + * `notifyRelationSubscribers` notifies the relation key and its owning entity but — unlike + * `notifyEntitySubscribers` — does not walk up the parent chain, so a membership change on a + * descendant relation never reaches a subscriber on the root item. A memoized row rendering + * `item.profile.tags` subscribes with `useAccessor(item.profile.tags)`, not `useField(item.name)`. + * Beware `useAccessor(item.profile)`: a has-one ref reports its OWNER, so that subscribes to the + * row itself rather than the target — reach through to the nested relation or `.$entity`. The + * composed primitives (`` / ``) already resolve the right key. + */ +class ItemAccessorCache { + private readonly entries = new Map>() + + constructor( + private readonly createHandle: (id: string) => EntityHandle, + ) {} + + /** Rebuilds the accessor array; ids no longer listed are evicted so the cache stays bounded. */ + build(items: ReadonlyArray<{ id: string }>): Array> { + const accessors: Array> = [] + const liveIds = new Set() + + for (const item of items) { + accessors.push(this.resolve(item.id)) + liveIds.add(item.id) + } + + for (const id of this.entries.keys()) { + if (!liveIds.has(id)) { + this.entries.delete(id) + } + } + + return accessors + } + + private resolve(id: string): EntityAccessor { + let accessor = this.entries.get(id) + if (!accessor) { + accessor = EntityHandle.wrapProxy(this.createHandle(id)) + this.entries.set(id, accessor) + } + return accessor + } +} + // ============================================================================ // Hook overloads // ============================================================================ @@ -234,6 +288,25 @@ export function useEntityList( result: UseEntityListResult } | null>(null) + // --- Item accessor cache --- + // Kept for the hook's lifetime so item identity survives a list snapshot rebuild. Dropped + // whenever a handle construction input changes — handles are built against `selectionMeta` and + // validate field access against it. Note this does not fully close the stale-selection window: + // `listCacheRef` below does not include the selection in its hit key, so the render on which + // the selection widens still serves the previous result and its narrow accessors. + const itemAccessorCache = useMemo( + () => new ItemAccessorCache((id) => EntityHandle.createRaw( + id, + entityType, + store, + dispatcher, + schemaRegistry as SchemaRegistry>, + undefined, + selectionMeta, + )), + [entityType, store, dispatcher, schemaRegistry, selectionMeta], + ) + // --- Store subscription --- const subscribe = useCallback( (onStoreChange: () => void) => { @@ -334,17 +407,7 @@ export function useEntityList( } else if (state.status === 'error') { result = createErrorListResult(state.error!) } else { - const items = state.items.map((item) => { - return EntityHandle.create( - item.id, - entityType, - store, - dispatcher, - schemaRegistry as SchemaRegistry>, - undefined, - selectionMeta, - ) as unknown as EntityAccessor - }) + const items = itemAccessorCache.build(state.items) result = { $status: 'ready', @@ -370,7 +433,7 @@ export function useEntityList( } return result - }, [entityType, store, dispatcher, schemaRegistry, selectionMeta, addItem, removeItem, moveItem]) + }, [store, itemAccessorCache, addItem, removeItem, moveItem]) const isEqual = useCallback( (a: UseEntityListResult, b: UseEntityListResult): boolean => { diff --git a/tests/react/hooks/useEntityList/accessorCache.test.tsx b/tests/react/hooks/useEntityList/accessorCache.test.tsx new file mode 100644 index 00000000..64f29888 --- /dev/null +++ b/tests/react/hooks/useEntityList/accessorCache.test.tsx @@ -0,0 +1,245 @@ +import '../../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup, act, fireEvent } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + MockAdapter, + defineSchema, + entityDef, + scalar, + useEntityList, + useField, + type EntityAccessor, + type UseEntityListResult, +} from '@contember/bindx-react' + +// Companion to accessorIdentity.test.tsx: the item accessor cache must stay correct while it +// reuses handles — identity survives every change to the entity, order and membership are still +// reflected, and a widened selection is not served by handles built against the narrow one. + +afterEach(() => { + cleanup() +}) + +interface Author { + id: string + name: string + rank: number +} + +interface TestSchema { + Author: Author +} + +const schema = defineSchema({ + entities: { + Author: { + fields: { + id: scalar(), + name: scalar(), + rank: scalar(), + }, + }, + }, +}) + +const authorDef = entityDef('Author') + +function createMockData() { + return { + Author: { + 'author-1': { id: 'author-1', name: 'John Doe', rank: 1 }, + 'author-2': { id: 'author-2', name: 'Jane Smith', rank: 2 }, + 'author-3': { id: 'author-3', name: 'Jack Black', rank: 3 }, + }, + } +} + +/** Renders nothing; hands the latest hook result back to the test. */ +function createListProbe(): { + Probe: (props: { filter?: Record; withRank?: boolean }) => React.ReactElement | null + latest: () => UseEntityListResult<{ id: string; name: string }> +} { + let current: UseEntityListResult<{ id: string; name: string }> | null = null + + function Probe({ filter, withRank }: { filter?: Record; withRank?: boolean }): React.ReactElement | null { + const result = useEntityList( + authorDef, + { filter, orderBy: [{ rank: 'asc' }] }, + a => (withRank ? a.id().name().rank() : a.id().name()), + ) + current = result + return null + } + + return { + Probe, + latest: () => { + if (!current) throw new Error('Probe has not rendered yet') + return current + }, + } +} + +function readyItems(result: UseEntityListResult<{ id: string; name: string }>): Array<{ id: string }> { + if (result.$status !== 'ready') throw new Error(`Expected ready, got ${result.$status}`) + return result.items +} + +/** + * Reads a field accessor by name. Read dynamically because the field is not part of the item's + * static type before the selection widens; this hits the same validation path as `item.rank.value`. + */ +function readFieldValue(item: object, fieldName: string): unknown { + const accessor: unknown = Reflect.get(item, fieldName) + if (typeof accessor !== 'object' || accessor === null) { + throw new Error(`No accessor for field '${fieldName}'`) + } + const value: unknown = Reflect.get(accessor, 'value') + return value +} + +describe('useEntityList item accessor cache', () => { + test('should keep item identity across a reorder', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const { Probe, latest } = createListProbe() + + render( + + + , + ) + + await waitFor(() => expect(readyItems(latest())).toHaveLength(3)) + const before = readyItems(latest()) + const [first, second, third] = before + + act(() => { + const result = latest() + if (result.$status !== 'ready') throw new Error('not ready') + result.$move(0, 2) + }) + + const after = readyItems(latest()) + expect(after.map(item => item.id)).toEqual(['author-2', 'author-3', 'author-1']) + expect(after[0]).toBe(second!) + expect(after[1]).toBe(third!) + expect(after[2]).toBe(first!) + }) + + test('should keep existing item identity when an item is added or removed', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const { Probe, latest } = createListProbe() + + render( + + + , + ) + + await waitFor(() => expect(readyItems(latest())).toHaveLength(3)) + const survivor = readyItems(latest())[1]! + + act(() => { + const result = latest() + if (result.$status !== 'ready') throw new Error('not ready') + result.$add({ name: 'New Author' }) + }) + + let items = readyItems(latest()) + expect(items).toHaveLength(4) + expect(items[1]).toBe(survivor) + + act(() => { + const result = latest() + if (result.$status !== 'ready') throw new Error('not ready') + result.$remove('author-1') + }) + + items = readyItems(latest()) + expect(items.map(item => item.id)).not.toContain('author-1') + expect(items[0]).toBe(survivor) + }) + + test('should eventually serve the widened selection once the accessor cache is dropped', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const { Probe, latest } = createListProbe() + + const { rerender } = render( + + + , + ) + + await waitFor(() => expect(readyItems(latest())).toHaveLength(3)) + + rerender( + + + , + ) + + // A handle validates field access against the selection it was built with, so without the + // cache drop the new field would throw forever. This pins the steady state only: on the + // render where the selection widens, `listCacheRef` still hits (its key ignores the + // selection) and hands out the narrow accessors, so reading `rank` throws there and + // `waitFor` rides past it. That transient is pre-existing and not covered here. + await waitFor(() => { + expect(readFieldValue(readyItems(latest())[0]!, 'rank')).toBe(1) + }) + }) + + test('should keep item identity across a change to its own entity while a subscribing row updates', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const seen: unknown[] = [] + let renderCount = 0 + + interface RowProps { + item: EntityAccessor<{ id: string; name: string }> + } + + const Row = React.memo(function Row({ item }: RowProps): React.ReactElement { + const name = useField(item.name) + renderCount++ + return {name.value} + }) + + function TestComponent(): React.ReactElement { + const authors = useEntityList(authorDef, { orderBy: [{ rank: 'asc' }] }, a => a.id().name()) + if (authors.$status !== 'ready') return
+ const first = authors.items[0]! + seen.push(first) + return ( +
+ + +
+ ) + } + + const { container } = render( + + + , + ) + + const row = (): Element => { + const el = container.querySelector('[data-testid="row"]') + if (!el) throw new Error('Row not rendered') + return el + } + + await waitFor(() => expect(row().textContent).toBe('John Doe')) + const rendersBefore = renderCount + + fireEvent.click(container.querySelector('[data-testid="rename"]')!) + + // Identity is not a change signal: it survives the entity's own change, and the row still + // updates because it subscribes. + await waitFor(() => expect(row().textContent).toBe('Renamed')) + expect(renderCount).toBeGreaterThan(rendersBefore) + expect(seen.length).toBeGreaterThan(1) + expect(seen[seen.length - 1]).toBe(seen[0]) + }) +}) diff --git a/tests/react/hooks/useEntityList/accessorIdentity.test.tsx b/tests/react/hooks/useEntityList/accessorIdentity.test.tsx index 2019d6ac..d53db17a 100644 --- a/tests/react/hooks/useEntityList/accessorIdentity.test.tsx +++ b/tests/react/hooks/useEntityList/accessorIdentity.test.tsx @@ -9,6 +9,8 @@ import { entityDef, scalar, useEntityList, + useField, + type EntityAccessor, } from '@contember/bindx-react' // Regression test for @@ -18,6 +20,9 @@ import { // store change means accessor identity is unstable even for entities whose data did not change, // which defeats React.memo (and any identity-based caching) in list consumers: editing one // item's field re-renders every sibling's subtree. +// +// Identity is stable and is NOT a change signal — a memoized row subscribes to observe its own +// entity, which is the repo-wide contract for accessors. afterEach(() => { cleanup() @@ -114,13 +119,16 @@ describe('useEntityList accessor identity', () => { const renderCounts: Record = {} interface RowProps { - // Matches what authors.items hands out; the row only reads .id / .name.value. - item: { id: string; name: { value: string | null; setValue: (v: string) => void } } + // Matches what authors.items hands out. + item: EntityAccessor<{ id: string; name: string }> } + // Item accessor identity is stable by design, so a memoized row no longer re-renders from + // the parent — it subscribes to its own entity to observe changes. const Row = React.memo(function Row({ item }: RowProps): React.ReactElement { + const name = useField(item.name) renderCounts[item.id] = (renderCounts[item.id] ?? 0) + 1 - return {item.name.value} + return {name.value} }) function TestComponent(): React.ReactElement { From 8cb259b0f79589e89c06c013afff13d6e3b003ea Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 19 Aug 2026 15:28:26 +0200 Subject: [PATCH 10/55] fix(bindx-react): subscribe implicit entity props; notify on store clear (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up to the stable accessor identity. Once identity stops churning, anything memoized that does not subscribe goes silently stale. Two such places were reproduced, both of which worked before only because identity churn happened to re-render them. createComponent() with an implicit entity prop never subscribed. useRenderProps called useAccessor only for entity props carrying a selector, so .entity('author', schema.Author) — the mode whose whole point is implicit selection collection — got no subscription while ComponentImpl is memo-wrapped. Editing the entity left the component rendering the old value indefinitely. All declared entity props are now subscribed. The hook count stays constant: entityConfigs is fixed when buildComponent runs and never mutated, and a declared-but-unpassed prop still consumes exactly one slot through useAccessor's noop path. SnapshotStore.clear() notified global subscribers only, so a row subscribed exactly as the accessor contract prescribes kept rendering wiped data — and unlike the descendant-relation limitation, no subscription a consumer could write fixed it. clear() is the documented logout / teardown / schema-switch path. SubscriptionManager gains notifyAll(), which bumps the global version once and then dispatches to every entity, relation and global subscriber; it snapshots the registries first, since a subscriber may unsubscribe itself or a sibling while being notified. Registrations are deliberately not dropped — those components are still mounted and still own their unsubscribe closures. notify() semantics are untouched. Eviction is now tested. The earlier claim that a black-box test is vacuous was wrong: an id that leaves and re-enters the list must get a NEW accessor, which passes with the eviction loop and fails without it, entirely through the public hook. ItemAccessorCache moves to its own module — useEntityList.ts had grown past the file-size guideline — and a comment pins the invariant that the items array identity is deliberately unstable, since nothing in the DataGrid render chain subscribes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee --- .../src/hooks/ItemAccessorCache.ts | 59 ++++++++++ .../bindx-react/src/hooks/useEntityList.ts | 58 +--------- .../bindx-react/src/jsx/componentFactory.ts | 22 ++-- packages/bindx/src/store/SnapshotStore.ts | 4 +- .../bindx/src/store/SubscriptionManager.ts | 25 ++++ .../useEntityList/accessorCache.test.tsx | 31 +++++ .../useEntityList/accessorIdentity.test.tsx | 2 +- .../hooks/useEntityList/storeClear.test.tsx | 99 ++++++++++++++++ .../implicitEntityPropSubscription.test.tsx | 107 ++++++++++++++++++ 9 files changed, 340 insertions(+), 67 deletions(-) create mode 100644 packages/bindx-react/src/hooks/ItemAccessorCache.ts create mode 100644 tests/react/hooks/useEntityList/storeClear.test.tsx create mode 100644 tests/react/jsx/implicitEntityPropSubscription.test.tsx diff --git a/packages/bindx-react/src/hooks/ItemAccessorCache.ts b/packages/bindx-react/src/hooks/ItemAccessorCache.ts new file mode 100644 index 00000000..35d3b469 --- /dev/null +++ b/packages/bindx-react/src/hooks/ItemAccessorCache.ts @@ -0,0 +1,59 @@ +import type { EntityAccessor } from '@contember/bindx' +import { EntityHandle } from '@contember/bindx' + +/** + * Per-id cache of the accessors `useEntityList` hands out. + * + * An EntityHandle is a stateless live view over the store, so one handle — and one proxy over it — + * serves an id for the whole life of the list. Identity therefore means identity and nothing else: + * it is stable across every change to the entity, which is what lets identity-keyed consumers + * (`React.memo` rows, `useMemo`) skip work. Change delivery is the subscription's job — a memoized + * consumer must subscribe via `` / `useField` / `useAccessor` to observe changes. + * + * Subscribe to the entity that OWNS the changed relation, not merely to the row's own entity: + * `notifyRelationSubscribers` notifies the relation key and its owning entity but — unlike + * `notifyEntitySubscribers` — does not walk up the parent chain, so a membership change on a + * descendant relation never reaches a subscriber on the root item. A memoized row rendering + * `item.profile.tags` subscribes with `useAccessor(item.profile.tags)`, not `useField(item.name)`. + * Beware `useAccessor(item.profile)`: a has-one ref reports its OWNER, so that subscribes to the + * row itself rather than the target — reach through to the nested relation or `.$entity`. The + * composed primitives (`` / ``) already resolve the right key. + * + * The cache belongs to one hook instance and is thrown away whenever a handle construction input + * changes; handles validate field access against the selection they were built with. + */ +export class ItemAccessorCache { + private readonly entries = new Map>() + + constructor( + private readonly createHandle: (id: string) => EntityHandle, + ) {} + + /** Rebuilds the accessor array; ids no longer listed are evicted so the cache stays bounded. */ + build(items: ReadonlyArray<{ id: string }>): Array> { + const accessors: Array> = [] + const liveIds = new Set() + + for (const item of items) { + accessors.push(this.resolve(item.id)) + liveIds.add(item.id) + } + + for (const id of this.entries.keys()) { + if (!liveIds.has(id)) { + this.entries.delete(id) + } + } + + return accessors + } + + private resolve(id: string): EntityAccessor { + let accessor = this.entries.get(id) + if (!accessor) { + accessor = EntityHandle.wrapProxy(this.createHandle(id)) + this.entries.set(id, accessor) + } + return accessor + } +} diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index 71c388fa..eb7eddcc 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -3,6 +3,7 @@ import type { EntityDef, EntityAccessor, SelectionInput, SelectionMeta, FieldErr import { EntityHandle, isTempId, resolveSelectionMeta, buildQueryFromSelection, refreshServerData, createLoadError } from '@contember/bindx' import { useBindxContext, useSchemaRegistry } from './BackendAdapterContext.js' import { useStoreSubscription } from './useStoreSubscription.js' +import { ItemAccessorCache } from './ItemAccessorCache.js' // ============================================================================ // Options @@ -108,60 +109,6 @@ function createErrorListResult(error: FieldError): ErrorEntityListResult { } } -/** - * Per-id cache of the accessors `useEntityList` hands out. - * - * An EntityHandle is a stateless live view over the store, so one handle — and one proxy over it — - * serves an id for the whole life of the list. Identity therefore means identity and nothing else: - * it is stable across every change to the entity, which is what lets identity-keyed consumers - * (`React.memo` rows, `useMemo`) skip work. Change delivery is the subscription's job — a memoized - * consumer must subscribe via `` / `useField` / `useAccessor` to observe changes. - * - * Subscribe to the entity that OWNS the changed relation, not merely to the row's own entity: - * `notifyRelationSubscribers` notifies the relation key and its owning entity but — unlike - * `notifyEntitySubscribers` — does not walk up the parent chain, so a membership change on a - * descendant relation never reaches a subscriber on the root item. A memoized row rendering - * `item.profile.tags` subscribes with `useAccessor(item.profile.tags)`, not `useField(item.name)`. - * Beware `useAccessor(item.profile)`: a has-one ref reports its OWNER, so that subscribes to the - * row itself rather than the target — reach through to the nested relation or `.$entity`. The - * composed primitives (`` / ``) already resolve the right key. - */ -class ItemAccessorCache { - private readonly entries = new Map>() - - constructor( - private readonly createHandle: (id: string) => EntityHandle, - ) {} - - /** Rebuilds the accessor array; ids no longer listed are evicted so the cache stays bounded. */ - build(items: ReadonlyArray<{ id: string }>): Array> { - const accessors: Array> = [] - const liveIds = new Set() - - for (const item of items) { - accessors.push(this.resolve(item.id)) - liveIds.add(item.id) - } - - for (const id of this.entries.keys()) { - if (!liveIds.has(id)) { - this.entries.delete(id) - } - } - - return accessors - } - - private resolve(id: string): EntityAccessor { - let accessor = this.entries.get(id) - if (!accessor) { - accessor = EntityHandle.wrapProxy(this.createHandle(id)) - this.entries.set(id, accessor) - } - return accessor - } -} - // ============================================================================ // Hook overloads // ============================================================================ @@ -407,6 +354,9 @@ export function useEntityList( } else if (state.status === 'error') { result = createErrorListResult(state.error!) } else { + // The array itself is deliberately NOT identity-stable: consumers that only re-render + // through a parent (the DataGrid render chain) rely on a fresh array per store bump. + // Per-item accessor identity is the stable part — see ItemAccessorCache. const items = itemAccessorCache.build(state.items) result = { diff --git a/packages/bindx-react/src/jsx/componentFactory.ts b/packages/bindx-react/src/jsx/componentFactory.ts index f7c7f8d2..7f031cb9 100644 --- a/packages/bindx-react/src/jsx/componentFactory.ts +++ b/packages/bindx-react/src/jsx/componentFactory.ts @@ -70,14 +70,14 @@ export interface EntityConfig { // ============================================================================ /** - * Converts explicit entity ref props to accessors via useAccessor. + * Converts entity ref props to accessors via useAccessor, subscribing each one. * Called with a fixed list of prop names — hook count is stable across renders. */ -function useRenderProps(props: TProps, explicitPropNames: string[]): TProps { +function useRenderProps(props: TProps, entityPropNames: string[]): TProps { const record = props as Record const accessors: Record = {} - for (const name of explicitPropNames) { - // eslint-disable-next-line react-hooks/rules-of-hooks -- stable iteration count (explicitPropNames is fixed at build time) + for (const name of entityPropNames) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- stable iteration count (entityPropNames is fixed at build time) accessors[name] = useAccessor(record[name] as EntityRef) } return { ...props, ...accessors } as TProps @@ -121,10 +121,10 @@ export function buildComponent( } } - // Collect explicit entity prop names (stable list for hooks) - const explicitEntityPropNames = [...entityConfigs.entries()] - .filter(([_, c]) => c.selector) - .map(([name]) => name) + // Every entity prop is subscribed, selector or not: accessor identity is stable, so a + // memo()-wrapped component only learns about its entity through its own subscription. + // The list is fixed at build time, which keeps the hook count in ComponentImpl stable. + const entityPropNames = [...entityConfigs.keys()] // 2. Implicit entities - collect lazily to avoid TDZ errors const implicitConfigs = [...entityConfigs.entries()].filter(([_, c]) => !c.selector) @@ -145,9 +145,9 @@ export function buildComponent( function ComponentImpl(props: TProps): ReactNode { ensureImplicitCollected() - // Convert explicit entity refs to accessors (stable hook count — explicitEntityPropNames is fixed) - const renderProps = explicitEntityPropNames.length > 0 - ? useRenderProps(props, explicitEntityPropNames) + // Subscribe every entity ref prop (stable hook count — entityPropNames is fixed) + const renderProps = entityPropNames.length > 0 + ? useRenderProps(props, entityPropNames) : props // Evaluate condition at runtime diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index d34d53ca..0ac203dc 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -1347,7 +1347,9 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { // Undo history describes the now-wiped world; drop it with the store. this.journal?.clear() - this.subscriptions.notify() + // Every subscription's data just disappeared, so notify entity/relation subscribers too — + // a global-only notify leaves them rendering wiped data with no consumer-side remedy. + this.subscriptions.notifyAll() } } diff --git a/packages/bindx/src/store/SubscriptionManager.ts b/packages/bindx/src/store/SubscriptionManager.ts index ddeaa022..3ca2ae38 100644 --- a/packages/bindx/src/store/SubscriptionManager.ts +++ b/packages/bindx/src/store/SubscriptionManager.ts @@ -132,6 +132,31 @@ export class SubscriptionManager implements Rekeyable { } } + /** + * Notifies every registered subscriber — entity, relation and global. + * + * For store-wide events such as clear(), where every subscription's data is gone at once and + * there is no per-key change to notify from. Registrations are left intact: the subscribers + * belong to mounted components that must learn their entity is no longer there. + */ + notifyAll(): void { + this.globalVersion++ + + // Snapshot first — a subscriber may unsubscribe itself or a sibling while being notified. + const subscribers: Subscriber[] = [] + for (const subs of this.entitySubscribers.values()) { + subscribers.push(...subs) + } + for (const subs of this.relationSubscribers.values()) { + subscribers.push(...subs) + } + subscribers.push(...this.globalSubscribers) + + for (const sub of subscribers) { + sub() + } + } + // ==================== Parent-Child Relationships ==================== /** diff --git a/tests/react/hooks/useEntityList/accessorCache.test.tsx b/tests/react/hooks/useEntityList/accessorCache.test.tsx index 64f29888..ebf21a09 100644 --- a/tests/react/hooks/useEntityList/accessorCache.test.tsx +++ b/tests/react/hooks/useEntityList/accessorCache.test.tsx @@ -242,4 +242,35 @@ describe('useEntityList item accessor cache', () => { expect(seen.length).toBeGreaterThan(1) expect(seen[seen.length - 1]).toBe(seen[0]) }) + + test('should give a re-entering id a new accessor, proving the evicted entry is gone', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const { Probe, latest } = createListProbe() + + const showOnly = async (id: string): Promise => { + rerender( + + + , + ) + await waitFor(() => { + expect(readyItems(latest()).map(item => item.id)).toEqual([id]) + }) + } + + const { rerender } = render( + + + , + ) + await waitFor(() => expect(readyItems(latest())).toHaveLength(1)) + const before = readyItems(latest())[0]! + + // author-1 leaves the list entirely, then comes back. + await showOnly('author-2') + await showOnly('author-1') + + // A surviving cache entry would hand back the very same accessor here. + expect(readyItems(latest())[0]).not.toBe(before) + }) }) diff --git a/tests/react/hooks/useEntityList/accessorIdentity.test.tsx b/tests/react/hooks/useEntityList/accessorIdentity.test.tsx index d53db17a..62c5a5f0 100644 --- a/tests/react/hooks/useEntityList/accessorIdentity.test.tsx +++ b/tests/react/hooks/useEntityList/accessorIdentity.test.tsx @@ -13,7 +13,7 @@ import { type EntityAccessor, } from '@contember/bindx-react' -// Regression test for +// Regression test for https://github.com/contember/bindx/issues/64 // // useEntityList's getSnapshot invalidates its list cache on every store version bump and then // rebuilds each item via EntityHandle.create(...). A brand-new handle (and proxy) per item per diff --git a/tests/react/hooks/useEntityList/storeClear.test.tsx b/tests/react/hooks/useEntityList/storeClear.test.tsx new file mode 100644 index 00000000..4a66d6fd --- /dev/null +++ b/tests/react/hooks/useEntityList/storeClear.test.tsx @@ -0,0 +1,99 @@ +import '../../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, act, cleanup } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + MockAdapter, + defineSchema, + entityDef, + scalar, + useEntityList, + useField, + useSnapshotStore, + type EntityAccessor, +} from '@contember/bindx-react' + +// store.clear() is the logout / provider-teardown / schema-switch path. Accessor identity is +// stable, so a memoized row cannot learn the store was wiped from a parent re-render — the +// notification has to reach the subscription it was told to register. + +afterEach(() => { + cleanup() +}) + +interface Author { + id: string + name: string +} + +interface TestSchema { + Author: Author +} + +const schema = defineSchema({ + entities: { + Author: { + fields: { + id: scalar(), + name: scalar(), + }, + }, + }, +}) + +const authorDef = entityDef('Author') + +function createMockData() { + return { + Author: { + 'author-1': { id: 'author-1', name: 'John Doe' }, + }, + } +} + +describe('store.clear() with a subscribed list row', () => { + test('notifies entity subscribers so a memoized row stops rendering wiped data', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + let clearStore: (() => void) | null = null + + interface RowProps { + item: EntityAccessor<{ id: string; name: string }> + } + + const Row = React.memo(function Row({ item }: RowProps): React.ReactElement { + const name = useField(item.name) + return {name.value ?? 'empty'} + }) + + function List(): React.ReactElement { + const store = useSnapshotStore() + const authors = useEntityList(authorDef, {}, a => a.id().name()) + clearStore = () => store.clear() + if (authors.$status !== 'ready') return
+ return + } + + const { container } = render( + + + , + ) + + const row = (): Element => { + const el = container.querySelector('[data-testid="row"]') + if (!el) throw new Error('Row not rendered') + return el + } + + await waitFor(() => expect(row().textContent).toBe('John Doe')) + + act(() => { + clearStore!() + }) + + // The row subscribed exactly as the accessor contract prescribes; no consumer-side + // subscription can compensate if clear() skips entity subscribers. + await waitFor(() => expect(row().textContent).toBe('empty')) + }) +}) diff --git a/tests/react/jsx/implicitEntityPropSubscription.test.tsx b/tests/react/jsx/implicitEntityPropSubscription.test.tsx new file mode 100644 index 00000000..67eae6dd --- /dev/null +++ b/tests/react/jsx/implicitEntityPropSubscription.test.tsx @@ -0,0 +1,107 @@ +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, act, cleanup } from '@testing-library/react' +import React from 'react' +import { BindxProvider, MockAdapter, Entity, createComponent, useAccessor, useField } from '@contember/bindx-react' +import type { FieldRef } from '@contember/bindx' +import { getByTestId, queryByTestId, createMockData, schema, testSchema } from '../../shared' + +afterEach(() => { + cleanup() +}) + +/** + * Accessors keep a stable identity across data changes, so a memo()-wrapped bindx component no + * longer re-renders just because its parent did — every entity prop it receives must carry its own + * store subscription. `createComponent().entity(name, def)` WITHOUT a selector (implicit selection + * collection) is a first-party API and must subscribe exactly like the explicit-selector form. + */ +describe('createComponent entity prop subscriptions', () => { + test('re-renders an implicit entity prop when its entity changes', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + // No selector — the selection is collected implicitly from the render function. + const ImplicitName = createComponent() + .entity('author', schema.Author) + .render(({ author }) => {author.name.inputProps.value}) + + let rename: (() => void) | null = null + + function Rename({ name }: { name: FieldRef }): null { + const field = useField(name) + rename = () => field.setValue('Renamed') + return null + } + + const { container } = render( + + + {author => ( + <> + + + + )} + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'implicit')).not.toBeNull() + }) + expect(getByTestId(container, 'implicit').textContent).toBe('John Doe') + + act(() => { + rename!() + }) + + await waitFor(() => { + expect(getByTestId(container, 'implicit').textContent).toBe('Renamed') + }) + }) + + test('re-renders an explicit entity prop when its entity changes', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + const ExplicitName = createComponent() + .entity('author', schema.Author, a => a.name()) + .render(({ author }) => { + const acc = useAccessor(author) + return {acc.$data?.name} + }) + + let rename: (() => void) | null = null + + function Rename({ name }: { name: FieldRef }): null { + const field = useField(name) + rename = () => field.setValue('Renamed') + return null + } + + const { container } = render( + + + {author => ( + <> + + + + )} + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'explicit')).not.toBeNull() + }) + expect(getByTestId(container, 'explicit').textContent).toBe('John Doe') + + act(() => { + rename!() + }) + + await waitFor(() => { + expect(getByTestId(container, 'explicit').textContent).toBe('Renamed') + }) + }) +}) From 4ff94f7bfcd2607130dfba4a6f80a2bfeae811e7 Mon Sep 17 00:00:00 2001 From: jonasnobile Date: Sun, 10 May 2026 21:02:26 +0200 Subject: [PATCH 11/55] test: failing reproducer for entity:persisting / entity:persisted events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the EntityHandle.intercept('entity:persisting', ...) interceptor and the useOnEntityEvent('entity:persisted', ...) hook are publicly advertised (the hook's own JSDoc literally documents the persisted example), but never fire at runtime. The events/eventFactory.ts createBeforeEvent / createAfterEvent switch statements have no case for SET_PERSISTING (the action BatchPersister dispatches around the mutation), so the EventEmitter never receives a before/after event to fan out. The two new tests assert the canonical advertised usage and currently fail with `Received length: 0`. The mutation itself succeeds — the mock store reflects the new value — proving the gap is purely in the event factory, not in the persist pipeline. Same root cause exists for entity:persistFailed (failure path) and entity:deleting / entity:deleted (DELETE_ENTITY action). Adding failure injection to MockAdapter is out of scope here; mirroring the fix once the persisting/persisted path lands is a one-line addition in the BatchPersister catch branch and the eventFactory switch. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/cases/entityPersistEvents.test.tsx | 202 +++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 tests/cases/entityPersistEvents.test.tsx diff --git a/tests/cases/entityPersistEvents.test.tsx b/tests/cases/entityPersistEvents.test.tsx new file mode 100644 index 00000000..ea154e7b --- /dev/null +++ b/tests/cases/entityPersistEvents.test.tsx @@ -0,0 +1,202 @@ +import '../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + defineSchema, + entityDef, + MockAdapter, + scalar, + useBindxContext, + useEntity, + useOnEntityEvent, +} from '@contember/bindx-react' +import type { EntityPersistedEvent, EntityPersistingEvent } from '@contember/bindx' + +afterEach(() => { + cleanup() +}) + +// ============================================================================ +// Reproduces an upstream gap: `entity:persisting` interceptors and +// `entity:persisted` / `entity:persistFailed` listeners are publicly +// advertised on `EntityHandle.intercept(...)` / `EntityHandle.onPersisted(...)` +// and via the React `useOnEntityEvent('entity:persisted', ...)` hook (the +// hook's own JSDoc literally documents `useOnEntityEvent('entity:persisted', +// 'Article', articleId, ...)` as the canonical example) — but at runtime +// the events are never actually emitted. +// +// The reason: `BatchPersister` dispatches `setPersisting(...)` (action type +// `SET_PERSISTING`), but `events/eventFactory.ts` `createBeforeEvent` / +// `createAfterEvent` switch statements have NO case for `SET_PERSISTING`, +// so they return `null`, so the `EventEmitter` never gets a before/after +// event to fan out. Same gap exists for `DELETE_ENTITY` (→ `entity:deleting` +// / `entity:deleted`). Only `entity:resetting` / `entity:reset` have the +// matching factory case today. +// +// Downstream impact: any bindx consumer that wires a "before save" hook +// for normalization (e.g. populating `normalizedName` for search), an +// "after save" toast, or a delete confirmation interceptor sees their +// callback silently never fire. It compiles, runs without warnings, and +// just does nothing. +// ============================================================================ + +interface Article { + id: string + title: string +} + +interface TestSchema { + Article: Article +} + +const schema = defineSchema({ + entities: { + Article: { + fields: { + id: scalar(), + title: scalar(), + }, + }, + }, +}) + +const articleDef = entityDef
('Article') + +function createMockData() { + return { + Article: { + 'article-1': { id: 'article-1', title: 'Initial' }, + }, + } +} + +function getByTestId(container: Element, testId: string): Element { + const el = container.querySelector(`[data-testid="${testId}"]`) + if (!el) throw new Error(`Element with data-testid="${testId}" not found`) + return el +} + +describe('Entity persist lifecycle events', () => { + test('FAILING: `entity:persisting` interceptor fires before BatchPersister sends mutations', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const persistingCalls: EntityPersistingEvent[] = [] + + function TestComponent() { + const { dispatcher } = useBindxContext() + const article = useEntity(articleDef, { by: { id: 'article-1' } }, e => e.id().title()) + + React.useEffect(() => { + const emitter = dispatcher.getEventEmitter() + return emitter.interceptEntity( + 'entity:persisting', + 'Article', + 'article-1', + event => { + persistingCalls.push(event) + return { action: 'continue' } + }, + ) + }, [dispatcher]) + + if (article.$isLoading) return
Loading…
+ if (article.$isError || article.$isNotFound) return
Error
+ + return ( +
+ + +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'persist')).toBeTruthy() + }) + + act(() => { + (getByTestId(container, 'dirty') as HTMLButtonElement).click() + }) + + await act(async () => { + (getByTestId(container, 'persist') as HTMLButtonElement).click() + await new Promise(r => setTimeout(r, 50)) + }) + + // Currently fails — interceptor is never invoked because + // `eventFactory.createBeforeEvent` returns `null` for `SET_PERSISTING`. + expect(persistingCalls).toHaveLength(1) + expect(persistingCalls[0]?.entityType).toBe('Article') + expect(persistingCalls[0]?.entityId).toBe('article-1') + expect(persistingCalls[0]?.isNew).toBe(false) + }) + + test('FAILING: `entity:persisted` listener fires after a successful persist', async () => { + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 0 }) + const persistedCalls: EntityPersistedEvent[] = [] + + function TestComponent() { + const article = useEntity(articleDef, { by: { id: 'article-1' } }, e => e.id().title()) + + useOnEntityEvent('entity:persisted', 'Article', 'article-1', event => { + persistedCalls.push(event) + }) + + if (article.$isLoading) return
Loading…
+ if (article.$isError || article.$isNotFound) return
Error
+ + return ( +
+ + +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'persist')).toBeTruthy() + }) + + act(() => { + (getByTestId(container, 'dirty') as HTMLButtonElement).click() + }) + + await act(async () => { + (getByTestId(container, 'persist') as HTMLButtonElement).click() + await new Promise(r => setTimeout(r, 50)) + }) + + // Persist actually succeeded — the store reflects the new value: + expect(mockData.Article['article-1']!.title).toBe('Updated') + + // …but the after-event listener never fires because + // `eventFactory.createAfterEvent` has no case for `SET_PERSISTING`. + expect(persistedCalls).toHaveLength(1) + expect(persistedCalls[0]?.entityType).toBe('Article') + expect(persistedCalls[0]?.entityId).toBe('article-1') + expect(persistedCalls[0]?.isNew).toBe(false) + expect(persistedCalls[0]?.persistedId).toBe('article-1') + }) + + // Note on `entity:persistFailed`: same root cause — `createAfterEvent` in + // `events/eventFactory.ts` has no case for `SET_PERSISTING` (the action + // fired by `BatchPersister` after a mutation result is processed). + // Adding a failure-injection knob to `MockAdapter` is out of scope for + // this reproducer; once the fix lands for `entity:persisting` / + // `entity:persisted`, mirroring the fix for the failure path is a + // one-line addition to the BatchPersister's catch branch. +}) From 1c19f5f5e7bc8f2d5cb84f0057553be35e3c8f8b Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 19 Aug 2026 15:03:18 +0200 Subject: [PATCH 12/55] fix(bindx): emit the entity persist lifecycle events (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entity:persisting, entity:persisted and entity:persistFailed were declared, registrable through EntityHandle.intercept / useOnEntityEvent, documented in the hook's own JSDoc — and never fired. BatchPersister dispatches SET_PERSISTING around each persist; that flows through ActionDispatcher into events/eventFactory.ts, whose switches have no case for it, so nothing was ever emitted. Consumers registering before-save normalisation or after-save invalidation silently did nothing. BlockEditor's orphaned-reference cleanup, registered via useEntityBeforePersist, is a victim inside this repo. The events are now emitted from BatchPersister rather than the action factory. persistedId is only known after the server response, and the persist lifecycle is multi-step — the factory mapping suits simple state changes, not this. eventFactory.ts is deliberately untouched, so there is no double emission. entity:persisting runs through the interceptor pipeline before mutations are built, so a hook's store writes land in the same save. Cancellation follows the ActionDispatcher precedent — a null from an interceptor vetoes that one entity, the rest of the batch proceeds, and the vetoed entity stays dirty and retryable, counted as skipped rather than failed. markInFlight now claims the batch before the interceptor await, so the re-entrancy guard still holds across the new suspension point. EventEmitter gains hasInterceptors() so a persist with no hooks registered skips the async pipeline entirely and keeps its original synchronous timing. Delete entries in a persist batch also get persisting/persisted. That is the persist lifecycle; entity:deleting / entity:deleted remain unemitted and are a separate unit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee --- packages/bindx/src/events/EventEmitter.ts | 12 + .../bindx/src/persistence/BatchPersister.ts | 176 ++++++++++- tests/cases/persistInterceptorHooks.test.tsx | 195 +++++++++++++ tests/unit/persistence/persistEvents.test.ts | 273 ++++++++++++++++++ 4 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 tests/cases/persistInterceptorHooks.test.tsx create mode 100644 tests/unit/persistence/persistEvents.test.ts diff --git a/packages/bindx/src/events/EventEmitter.ts b/packages/bindx/src/events/EventEmitter.ts index 4631d66e..e2dee657 100644 --- a/packages/bindx/src/events/EventEmitter.ts +++ b/packages/bindx/src/events/EventEmitter.ts @@ -139,6 +139,18 @@ export class EventEmitter { return () => interceptors.delete(interceptor as Interceptor) } + /** + * Whether any interceptor could receive this event for the given entity. + * Lets a caller skip the (async) interceptor pipeline when nothing is listening. + */ + hasInterceptors(eventType: string, entityType: string, entityId: string): boolean { + const global = this.globalInterceptors.get(eventType) + if (global !== undefined && global.size > 0) return true + + const scoped = this.scopedInterceptors.get(this.buildScopeKey(eventType, { entityType, entityId })) + return scoped !== undefined && scoped.size > 0 + } + // ============================================================================ // Dispatch Methods // ============================================================================ diff --git a/packages/bindx/src/persistence/BatchPersister.ts b/packages/bindx/src/persistence/BatchPersister.ts index 0950f441..9bf2d855 100644 --- a/packages/bindx/src/persistence/BatchPersister.ts +++ b/packages/bindx/src/persistence/BatchPersister.ts @@ -21,6 +21,7 @@ import { type ContemberMutationResult } from '../errors/pathMapper.js' import { resolveAllErrors } from '../errors/errorPathResolver.js' import { createServerError } from '../errors/types.js' import { MutationCollector } from './MutationCollector.js' +import type { EntityPersistedEvent, EntityPersistFailedEvent, EntityPersistingEvent } from '../events/types.js' import type { EntitySnapshot } from '../store/snapshots.js' import type { StoredHasManyState, StoredRelationState } from '../store/SnapshotStore.js' import { deepEqual } from '../utils/deepEqual.js' @@ -202,9 +203,59 @@ export class BatchPersister { // Sort by dependencies (creates first) const sortedEntities = this.sortByDependencies(entitiesToPersist) - // Mark all as in-flight + // Claim the batch before the first await, so a concurrent persist of the same + // entity is still skipped while the before-persist hooks run. this.changeRegistry.markInFlight(sortedEntities) + // Before-persist hooks run before any mutation is built, so store writes they + // make (normalisation, orphan cleanup) go out in this very save. With no hook + // registered the pipeline is skipped, keeping a plain persist free of the extra + // microtask it would otherwise cost. + let accepted: DirtyEntity[] = sortedEntities + let cancelled: readonly DirtyEntity[] = [] + + if (this.hasPersistingInterceptors(sortedEntities)) { + const outcome = await this.runPersistingInterceptors(sortedEntities) + accepted = outcome.accepted + cancelled = outcome.cancelled + + // A vetoed entity never entered the persisting state — just release its claim. + if (cancelled.length > 0) { + this.changeRegistry.clearInFlight(cancelled) + } + } + + if (accepted.length === 0) { + return this.mergeCancelled( + { success: true, results: [], successCount: 0, failedCount: 0, skippedCount: 0 }, + cancelled, + ) + } + + let attempted: PersistenceResult + try { + attempted = await this.executePersist(accepted, scope, options, updateMode) + } catch (error) { + this.emitPersistFailed(accepted, toError(error)) + throw error + } + + // Emitted after the persisting flags are cleared, so listeners observe settled state. + this.emitPersistOutcome(attempted.results) + + return this.mergeCancelled(attempted, cancelled) + } + + /** + * Runs the persist lifecycle for entities that passed the before-persist hooks. + * They are already marked in-flight by the caller; this method releases them. + */ + private async executePersist( + sortedEntities: DirtyEntity[], + scope: PersistScope, + options: BatchPersistOptions | undefined, + updateMode: UpdateMode, + ): Promise { // Set persisting state for all entities. In pessimistic mode the flag also // marks the entity for server-baseline presentation while in-flight. for (const entity of sortedEntities) { @@ -264,6 +315,122 @@ export class BatchPersister { } } + /** + * Whether any entity in the batch has an `entity:persisting` interceptor. + */ + private hasPersistingInterceptors(entities: readonly DirtyEntity[]): boolean { + const emitter = this.dispatcher.getEventEmitter() + return entities.some( + entity => emitter.hasInterceptors('entity:persisting', entity.entityType, entity.entityId), + ) + } + + /** + * Runs `entity:persisting` interceptors for every entity in the batch. + * A `null` result vetoes that one entity — mirroring how ActionDispatcher treats + * a cancelling interceptor — while the rest of the batch proceeds. + */ + private async runPersistingInterceptors( + entities: readonly DirtyEntity[], + ): Promise<{ accepted: DirtyEntity[]; cancelled: DirtyEntity[] }> { + const emitter = this.dispatcher.getEventEmitter() + const accepted: DirtyEntity[] = [] + const cancelled: DirtyEntity[] = [] + + for (const entity of entities) { + const event: EntityPersistingEvent = { + type: 'entity:persisting', + timestamp: Date.now(), + entityType: entity.entityType, + entityId: entity.entityId, + isNew: entity.changeType === 'create', + } + const result = await emitter.runInterceptors(event) + if (result === null) { + cancelled.push(entity) + } else { + accepted.push(entity) + } + } + + return { accepted, cancelled } + } + + /** + * Emits `entity:persisted` / `entity:persistFailed` for entities that were sent. + */ + private emitPersistOutcome(results: readonly EntityPersistResult[]): void { + const emitter = this.dispatcher.getEventEmitter() + + for (const entry of results) { + if (entry.success) { + emitter.emit({ + type: 'entity:persisted', + timestamp: Date.now(), + entityType: entry.entityType, + entityId: entry.entityId, + isNew: entry.operation === 'create', + // Updates keep their id; creates carry the server-assigned one. + persistedId: entry.persistedId ?? entry.entityId, + } satisfies EntityPersistedEvent) + } else { + emitter.emit({ + type: 'entity:persistFailed', + timestamp: Date.now(), + entityType: entry.entityType, + entityId: entry.entityId, + isNew: entry.operation === 'create', + error: new Error(entry.error?.message ?? 'Persist failed'), + } satisfies EntityPersistFailedEvent) + } + } + } + + /** + * Emits `entity:persistFailed` for the whole batch when the persist itself threw. + */ + private emitPersistFailed(entities: readonly DirtyEntity[], error: Error): void { + const emitter = this.dispatcher.getEventEmitter() + + for (const entity of entities) { + emitter.emit({ + type: 'entity:persistFailed', + timestamp: Date.now(), + entityType: entity.entityType, + entityId: entity.entityId, + isNew: entity.changeType === 'create', + error, + } satisfies EntityPersistFailedEvent) + } + } + + /** + * Folds vetoed entities into the result as skipped — never as successes, and never + * as server failures, so callers can tell a deliberate veto from a broken save. + */ + private mergeCancelled( + attempted: PersistenceResult, + cancelled: readonly DirtyEntity[], + ): PersistenceResult { + if (cancelled.length === 0) return attempted + + const cancelledResults = cancelled.map((entity): EntityPersistResult => ({ + entityType: entity.entityType, + entityId: entity.entityId, + operation: entity.changeType, + success: false, + error: { message: `Persist of ${entity.entityType}:${entity.entityId} was cancelled by an entity:persisting interceptor` }, + })) + + return { + success: false, + results: [...attempted.results, ...cancelledResults], + successCount: attempted.successCount, + failedCount: attempted.failedCount, + skippedCount: attempted.skippedCount + cancelled.length, + } + } + /** * Collects entities to persist based on the scope. */ @@ -1134,3 +1301,10 @@ export class BatchPersister { this.changeRegistry.clearAllInFlight() } } + +/** + * Normalizes a thrown value into an Error for `entity:persistFailed`. + */ +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/tests/cases/persistInterceptorHooks.test.tsx b/tests/cases/persistInterceptorHooks.test.tsx new file mode 100644 index 00000000..92380583 --- /dev/null +++ b/tests/cases/persistInterceptorHooks.test.tsx @@ -0,0 +1,195 @@ +import '../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + defineSchema, + entityDef, + MockAdapter, + scalar, + useEntity, + useInterceptEntity, + useOnEntityEvent, +} from '@contember/bindx-react' +import type { + BackendAdapter, + EntityPersistFailedEvent, + PersistResult, + Query, + QueryOptions, + QueryResult, +} from '@contember/bindx' + +afterEach(() => { + cleanup() +}) + +// Covers the hook surface a consumer actually uses for a before-persist hook: +// `useInterceptEntity('entity:persisting', ...)` — writes it makes must go out +// in the same save — and `useOnEntityEvent('entity:persistFailed', ...)`. + +interface Article { + id: string + title: string + slug: string +} + +interface TestSchema { + Article: Article +} + +const schema = defineSchema({ + entities: { + Article: { + fields: { + id: scalar(), + title: scalar(), + slug: scalar(), + }, + }, + }, +}) + +const articleDef = entityDef
('Article') + +function createMockData(): { Article: Record> } { + return { + Article: { + 'article-1': { id: 'article-1', title: 'Initial', slug: 'initial' }, + }, + } +} + +interface RecordingAdapter { + adapter: BackendAdapter + persistPayloads: Array> +} + +/** Wraps MockAdapter so the test can see exactly what each persist sent. */ +function createRecordingAdapter( + data: { Article: Record> }, + options?: { failWith?: string }, +): RecordingAdapter { + const inner = new MockAdapter(data, { delay: 0 }) + const persistPayloads: Array> = [] + + const adapter: BackendAdapter = { + query: (queries: readonly Query[], queryOptions?: QueryOptions): Promise => + inner.query(queries, queryOptions), + persist: (entityType: string, id: string, changes: Record): Promise => { + persistPayloads.push(changes) + if (options?.failWith) { + return Promise.resolve({ ok: false, errorMessage: options.failWith }) + } + return inner.persist(entityType, id, changes) + }, + create: (entityType: string, entityData: Record) => inner.create(entityType, entityData), + delete: (entityType: string, id: string) => inner.delete(entityType, id), + } + + return { adapter, persistPayloads } +} + +function getByTestId(container: Element, testId: string): Element { + const el = container.querySelector(`[data-testid="${testId}"]`) + if (!el) throw new Error(`Element with data-testid="${testId}" not found`) + return el +} + +async function clickAndSettle(container: Element, testId: string): Promise { + await act(async () => { + const button = getByTestId(container, testId) + if (!(button instanceof HTMLButtonElement)) throw new Error(`${testId} is not a button`) + button.click() + await new Promise(resolve => setTimeout(resolve, 50)) + }) +} + +describe('persist lifecycle hooks', () => { + test('useInterceptEntity write lands in the same persist', async () => { + const mockData = createMockData() + const { adapter, persistPayloads } = createRecordingAdapter(mockData) + + function TestComponent(): React.ReactNode { + const article = useEntity(articleDef, { by: { id: 'article-1' } }, e => e.id().title().slug()) + + useInterceptEntity('entity:persisting', 'Article', 'article-1', () => { + if (article.$isLoading || article.$isError || article.$isNotFound) return + article.slug.setValue('updated') + return { action: 'continue' } + }) + + if (article.$isLoading) return
Loading…
+ if (article.$isError || article.$isNotFound) return
Error
+ + return ( +
+ + +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'persist')).toBeTruthy() + }) + + await clickAndSettle(container, 'dirty') + await clickAndSettle(container, 'persist') + + // One round trip carrying both the user's edit and the hook's normalization. + expect(persistPayloads).toHaveLength(1) + expect(persistPayloads[0]).toEqual({ title: 'Updated', slug: 'updated' }) + expect(mockData.Article['article-1']?.['slug']).toBe('updated') + }) + + test('useOnEntityEvent receives entity:persistFailed', async () => { + const mockData = createMockData() + const { adapter } = createRecordingAdapter(mockData, { failWith: 'Server said no' }) + const failures: EntityPersistFailedEvent[] = [] + + function TestComponent(): React.ReactNode { + const article = useEntity(articleDef, { by: { id: 'article-1' } }, e => e.id().title()) + + useOnEntityEvent('entity:persistFailed', 'Article', 'article-1', event => { + failures.push(event) + }) + + if (article.$isLoading) return
Loading…
+ if (article.$isError || article.$isNotFound) return
Error
+ + return ( +
+ + +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'persist')).toBeTruthy() + }) + + await clickAndSettle(container, 'dirty') + await clickAndSettle(container, 'persist') + + expect(failures).toHaveLength(1) + expect(failures[0]?.entityId).toBe('article-1') + expect(failures[0]?.isNew).toBe(false) + expect(failures[0]?.error.message).toBe('Server said no') + expect(mockData.Article['article-1']?.['title']).toBe('Initial') + }) +}) diff --git a/tests/unit/persistence/persistEvents.test.ts b/tests/unit/persistence/persistEvents.test.ts new file mode 100644 index 00000000..96474e90 --- /dev/null +++ b/tests/unit/persistence/persistEvents.test.ts @@ -0,0 +1,273 @@ +import { describe, test, expect, beforeEach } from 'bun:test' +import { + SnapshotStore, + ActionDispatcher, + BatchPersister, + setField, + type BackendAdapter, + type EntityPersistedEvent, + type EntityPersistFailedEvent, + type EntityPersistingEvent, +} from '@contember/bindx' + +interface PersistCall { + entityType: string + entityId: string + changes: Record +} + +interface RecordingAdapter { + adapter: BackendAdapter + persistCalls: PersistCall[] + createCalls: Array<{ entityType: string; data: Record }> +} + +function createRecordingAdapter(options?: { failWith?: string }): RecordingAdapter { + const persistCalls: PersistCall[] = [] + const createCalls: Array<{ entityType: string; data: Record }> = [] + + const adapter: BackendAdapter = { + query: () => Promise.resolve([]), + persist: (entityType, entityId, changes) => { + persistCalls.push({ entityType, entityId, changes }) + return options?.failWith + ? Promise.resolve({ ok: false, errorMessage: options.failWith }) + : Promise.resolve({ ok: true }) + }, + create: (entityType, data) => { + createCalls.push({ entityType, data }) + return options?.failWith + ? Promise.resolve({ ok: false, errorMessage: options.failWith }) + : Promise.resolve({ ok: true, data: { id: 'server-id-1', ...data } }) + }, + delete: () => Promise.resolve({ ok: true }), + } + + return { adapter, persistCalls, createCalls } +} + +describe('BatchPersister persist lifecycle events', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + + beforeEach(() => { + store = new SnapshotStore() + dispatcher = new ActionDispatcher(store) + }) + + describe('entity:persisting', () => { + test('a write made by an interceptor goes out in the same persist', async () => { + const { adapter, persistCalls } = createRecordingAdapter() + const persister = new BatchPersister(adapter, store, dispatcher) + + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original', slug: 'original' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'Updated') + + dispatcher.getEventEmitter().intercept('entity:persisting', () => { + // The before-persist hook normalizes another field — it must ride along. + dispatcher.dispatch(setField('Article', 'a-1', ['slug'], 'updated')) + }) + + const result = await persister.persistAll() + + expect(result.success).toBe(true) + expect(persistCalls).toHaveLength(1) + expect(persistCalls[0]?.changes).toEqual({ title: 'Updated', slug: 'updated' }) + expect(store.getDirtyFields('Article', 'a-1')).toHaveLength(0) + }) + + test('entity-scoped interceptor fires only for its own entity', async () => { + const { adapter } = createRecordingAdapter() + const persister = new BatchPersister(adapter, store, dispatcher) + + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'One' }, true) + store.setEntityData('Article', 'a-2', { id: 'a-2', title: 'Two' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'One updated') + store.setFieldValue('Article', 'a-2', ['title'], 'Two updated') + + const scoped: EntityPersistingEvent[] = [] + dispatcher.getEventEmitter().interceptEntity('entity:persisting', 'Article', 'a-1', event => { + scoped.push(event) + return { action: 'continue' } + }) + + await persister.persistAll() + + expect(scoped).toHaveLength(1) + expect(scoped[0]?.entityId).toBe('a-1') + expect(scoped[0]?.isNew).toBe(false) + }) + + test('reports isNew for a create', async () => { + const { adapter } = createRecordingAdapter() + const persister = new BatchPersister(adapter, store, dispatcher) + + const tempId = store.createEntity('Article', { title: 'New' }) + + const events: EntityPersistingEvent[] = [] + dispatcher.getEventEmitter().intercept('entity:persisting', event => { + events.push(event) + }) + + await persister.persist('Article', tempId) + + expect(events).toHaveLength(1) + expect(events[0]?.entityId).toBe(tempId) + expect(events[0]?.isNew).toBe(true) + }) + }) + + describe('entity:persisted', () => { + test('carries the server-assigned id for a create', async () => { + const { adapter } = createRecordingAdapter() + const persister = new BatchPersister(adapter, store, dispatcher) + + const tempId = store.createEntity('Article', { title: 'New' }) + + const events: EntityPersistedEvent[] = [] + dispatcher.getEventEmitter().on('entity:persisted', event => { + events.push(event) + }) + + await persister.persist('Article', tempId) + + expect(events).toHaveLength(1) + expect(events[0]?.entityType).toBe('Article') + expect(events[0]?.entityId).toBe(tempId) + expect(events[0]?.isNew).toBe(true) + expect(events[0]?.persistedId).toBe('server-id-1') + }) + + test('fires once the persisting flag is already cleared', async () => { + const { adapter } = createRecordingAdapter() + const persister = new BatchPersister(adapter, store, dispatcher) + + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'Updated') + + const observed: boolean[] = [] + dispatcher.getEventEmitter().onEntity('entity:persisted', 'Article', 'a-1', () => { + observed.push(store.isPersisting('Article', 'a-1')) + }) + + await persister.persistAll() + + expect(observed).toEqual([false]) + }) + }) + + describe('entity:persistFailed', () => { + test('fires with the server error on a failed persist', async () => { + const { adapter } = createRecordingAdapter({ failWith: 'Server rejected the update' }) + const persister = new BatchPersister(adapter, store, dispatcher) + + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'Updated') + + const failures: EntityPersistFailedEvent[] = [] + const successes: EntityPersistedEvent[] = [] + dispatcher.getEventEmitter().on('entity:persistFailed', event => { + failures.push(event) + }) + dispatcher.getEventEmitter().on('entity:persisted', event => { + successes.push(event) + }) + + const result = await persister.persistAll() + + expect(result.success).toBe(false) + expect(successes).toHaveLength(0) + expect(failures).toHaveLength(1) + expect(failures[0]?.entityType).toBe('Article') + expect(failures[0]?.entityId).toBe('a-1') + expect(failures[0]?.isNew).toBe(false) + expect(failures[0]?.error).toBeInstanceOf(Error) + expect(failures[0]?.error.message).toBe('Server rejected the update') + }) + + test('fires when the persist itself throws, and the error still propagates', async () => { + const { adapter } = createRecordingAdapter() + const persister = new BatchPersister(adapter, store, dispatcher) + + // A dirty relation with no MutationCollector configured makes buildMutations throw. + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original' }, true) + store.setRelation('Article', 'a-1', 'author', { currentId: 'author-1', state: 'connected' }) + + const failures: EntityPersistFailedEvent[] = [] + dispatcher.getEventEmitter().on('entity:persistFailed', event => { + failures.push(event) + }) + + await expect(persister.persistAll()).rejects.toThrow(/MutationCollector/) + + expect(failures).toHaveLength(1) + expect(failures[0]?.entityId).toBe('a-1') + expect(failures[0]?.error.message).toMatch(/MutationCollector/) + }) + }) + + describe('cancellation', () => { + test('a cancelling interceptor excludes just that entity', async () => { + const { adapter, persistCalls } = createRecordingAdapter() + const persister = new BatchPersister(adapter, store, dispatcher) + + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'One' }, true) + store.setEntityData('Article', 'a-2', { id: 'a-2', title: 'Two' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'One updated') + store.setFieldValue('Article', 'a-2', ['title'], 'Two updated') + + dispatcher.getEventEmitter().interceptEntity('entity:persisting', 'Article', 'a-1', () => ({ + action: 'cancel', + })) + + const persisted: EntityPersistedEvent[] = [] + const failed: EntityPersistFailedEvent[] = [] + dispatcher.getEventEmitter().on('entity:persisted', event => persisted.push(event)) + dispatcher.getEventEmitter().on('entity:persistFailed', event => failed.push(event)) + + const result = await persister.persistAll() + + // The sibling still went out. + expect(persistCalls).toHaveLength(1) + expect(persistCalls[0]?.entityId).toBe('a-2') + expect(store.getDirtyFields('Article', 'a-2')).toHaveLength(0) + + // The vetoed entity kept its edit and is left in a clean, non-in-flight state. + expect(store.getDirtyFields('Article', 'a-1')).toContain('title') + expect(store.isPersisting('Article', 'a-1')).toBe(false) + expect(persister.getChangeRegistry().isInFlight('Article', 'a-1')).toBe(false) + + // Counted as skipped, never as a success. + expect(result.success).toBe(false) + expect(result.successCount).toBe(1) + expect(result.failedCount).toBe(0) + expect(result.skippedCount).toBe(1) + const cancelledEntry = result.results.find(r => r.entityId === 'a-1') + expect(cancelledEntry?.success).toBe(false) + expect(cancelledEntry?.error?.message).toMatch(/cancelled/) + + // No after-event is emitted for a vetoed entity. + expect(persisted.map(e => e.entityId)).toEqual(['a-2']) + expect(failed).toHaveLength(0) + }) + + test('cancelling the only entity leaves nothing in flight', async () => { + const { adapter, persistCalls } = createRecordingAdapter() + const persister = new BatchPersister(adapter, store, dispatcher) + + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'One' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'One updated') + + dispatcher.getEventEmitter().intercept('entity:persisting', () => ({ action: 'cancel' })) + + const result = await persister.persist('Article', 'a-1') + + expect(persistCalls).toHaveLength(0) + expect(result.success).toBe(false) + expect(result.error?.message).toMatch(/cancelled/) + expect(store.isPersisting('Article', 'a-1')).toBe(false) + expect(persister.getChangeRegistry().isInFlight('Article', 'a-1')).toBe(false) + expect(persister.getChangeRegistry().hasInFlight()).toBe(false) + }) + }) +}) From cb76909f5ffd1eb134cf87bbe3c536937223bc5d Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 11:33:29 +0200 Subject: [PATCH 13/55] ci: run every non-browser test suite in the unit gate `bun run test` enumerated the directories it covered, so 20 test files never ran in CI: the bindx-form, bindx-uploader and bindx-generator package suites plus tests/bindx-client and tests/repeater. The has-many materialisation bug fixed in #76 sat failing in packages/bindx-form/tests the whole time, behind a green board. Use an ignore pattern instead of an allow-list, so a new test directory is covered by default and cannot silently drop out again. Drop test:all, which now differs only by also running tests/browser and fails without a live playground. Gate: 1531 tests across 129 files -> 1737 across 149, 0 fail. --- CLAUDE.md | 4 ++-- package.json | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cd444a05..333634b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,8 +14,8 @@ bun run build # Type check bun run typecheck -# Run all tests -bun test +# Run all tests (everything except tests/browser, which needs a live playground) +bun run test # Run a specific test file bun test tests/useEntity.test.tsx diff --git a/package.json b/package.json index 9a73a410..516d0d45 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,8 @@ "build": "tsc --build", "dev": "tsc --build --watch", "typecheck": "tsc --build", - "test": "bun test tests/unit tests/react tests/cases tests/*.test.ts tests/*.test.tsx", + "test": "bun test --path-ignore-patterns='**/tests/browser/**'", "test:browser": "bun test --timeout 30000 tests/browser/", - "test:all": "bun test", "playground": "cd packages/example && bun run dev", "playground:contember": "cd packages/example && VITE_CONTEMBER_API_URL=http://localhost:1581 VITE_CONTEMBER_API_TOKEN=0000000000000000000000000000000000000000 bun run dev", "contember:up": "docker compose up -d", From 63dc057c6fd17924cb832312f183f2ed02ec1d8a Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 11:48:22 +0200 Subject: [PATCH 14/55] fix(bindx-react): keep the hook count fixed and subscribe DSL conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit called useField inside a loop over its children, behind a rules-of-hooks eslint-disable and a comment asserting a stable count that nothing enforced. SwitchProps.children is ReactNode, so a conditionally rendered type-checks and then crashes React with "Rendered more hooks than during the previous render". Separately, a cond.* DSL condition subscribed to nothing: If and Case passed null to useField whenever the condition was a Condition object rather than a bare FieldRef, while evaluateCondition kept reading the values live. Inside a memoized subtree, where the parent's re-render does not reach the child, the branch never re-evaluated. Both are the same missing capability — subscribing to a number of refs that is not known at build time. useFields(refs) takes a single useSyncExternalStore subscription over N refs, so the hook count stays constant, and and now feed it their condition fields. It widens ref to accessor through overloads exactly as useAccessor already does, so no cast is involved. The repo has no eslint config, so the rules-of-hooks disable removed here was never enforced by anything — which is how the crash survived. --- packages/bindx-react/src/hooks/index.ts | 1 + packages/bindx-react/src/hooks/useFields.ts | 87 +++++++++++ packages/bindx-react/src/index.ts | 1 + .../bindx-react/src/jsx/components/If.tsx | 6 + .../bindx-react/src/jsx/components/Switch.tsx | 29 ++-- .../react/jsx/ConditionSubscription.test.tsx | 139 ++++++++++++++++++ tests/react/jsx/SwitchHookCount.test.tsx | 76 ++++++++++ 7 files changed, 330 insertions(+), 9 deletions(-) create mode 100644 packages/bindx-react/src/hooks/useFields.ts create mode 100644 tests/react/jsx/ConditionSubscription.test.tsx create mode 100644 tests/react/jsx/SwitchHookCount.test.tsx diff --git a/packages/bindx-react/src/hooks/index.ts b/packages/bindx-react/src/hooks/index.ts index ec33cb9a..d5d43415 100644 --- a/packages/bindx-react/src/hooks/index.ts +++ b/packages/bindx-react/src/hooks/index.ts @@ -65,6 +65,7 @@ export { useEntityBeforePersist } from './useEntityBeforePersist.js' export { useAccessor } from './useAccessor.js' export { useField } from './useField.js' +export { useFields } from './useFields.js' export { useHasMany } from './useHasMany.js' export { useHasOne } from './useHasOne.js' diff --git a/packages/bindx-react/src/hooks/useFields.ts b/packages/bindx-react/src/hooks/useFields.ts new file mode 100644 index 00000000..ee9e6fd9 --- /dev/null +++ b/packages/bindx-react/src/hooks/useFields.ts @@ -0,0 +1,87 @@ +import { useCallback, useSyncExternalStore } from 'react' +import { + FIELD_REF_META, + type FieldAccessor, + type FieldRef, + type FieldRefMeta, +} from '@contember/bindx' +import { useSnapshotStore } from './BackendAdapterContext.js' + +/** Entity to watch, derived from a ref's field metadata. */ +interface SubscriptionTarget { + readonly entityType: string + readonly entityId: string +} + +function hasFieldRefMeta(value: unknown): value is { readonly [FIELD_REF_META]: FieldRefMeta } { + return typeof value === 'object' && value !== null && FIELD_REF_META in value +} + +/** Deduplicates the entities behind `refs`; entries without field metadata are ignored. */ +function collectTargets(refs: ReadonlyArray): SubscriptionTarget[] { + const targets: SubscriptionTarget[] = [] + const seen = new Set() + + for (const ref of refs) { + if (!hasFieldRefMeta(ref)) continue + const meta = ref[FIELD_REF_META] + if (!meta) continue + const key = `${meta.entityType}:${meta.entityId}` + if (seen.has(key)) continue + seen.add(key) + targets.push({ entityType: meta.entityType, entityId: meta.entityId }) + } + + return targets +} + +/** + * Subscribes to every entity behind `refs` with a single hook, so the hook count stays + * constant no matter how many refs are passed. Use it wherever the number of refs is + * driven by data or by children (`` cases, condition DSL fields) — calling + * {@link useField} in a loop breaks the rules of hooks the moment the count changes. + * + * Nulls and values without field metadata are ignored, which makes it safe to pass + * loosely typed collections such as the fields of a `Condition`. + * + * @example + * ```tsx + * // Values + subscription for a variable number of fields + * const accessors = useFields([article.title, article.publishedAt]) + * + * // Subscription only (condition DSL refs are not necessarily FieldRefs) + * useFields(collectConditionFields(condition)) + * ``` + */ +export function useFields(refs: ReadonlyArray | null>): ReadonlyArray | null> +export function useFields(refs: ReadonlyArray): void +export function useFields(refs: ReadonlyArray): unknown { + const store = useSnapshotStore() + const targets = collectTargets(refs) + const subscriptionKey = JSON.stringify(targets) + const hasTargets = targets.length > 0 + + // `subscriptionKey` fully determines `targets`, so keeping the capture from the render that + // last changed the key is equivalent — and it avoids resubscribing on every render. + const subscribe = useCallback( + (callback: () => void): (() => void) => { + const unsubscribes = targets.map(target => + store.subscribeToEntity(target.entityType, target.entityId, callback), + ) + return () => { + for (const unsubscribe of unsubscribes) unsubscribe() + } + }, + [store, subscriptionKey], + ) + + const getSnapshot = useCallback( + (): number => (hasTargets ? store.getVersion() : 0), + [store, hasTargets], + ) + + useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + + // Ref proxies already expose accessor properties at runtime — the overloads widen the type. + return refs +} diff --git a/packages/bindx-react/src/index.ts b/packages/bindx-react/src/index.ts index 59804e1f..6820b319 100644 --- a/packages/bindx-react/src/index.ts +++ b/packages/bindx-react/src/index.ts @@ -309,6 +309,7 @@ export { // Ref → Accessor hooks useAccessor, useField, + useFields, useHasMany, useHasOne, // Notifications diff --git a/packages/bindx-react/src/jsx/components/If.tsx b/packages/bindx-react/src/jsx/components/If.tsx index 5577d329..12008de7 100644 --- a/packages/bindx-react/src/jsx/components/If.tsx +++ b/packages/bindx-react/src/jsx/components/If.tsx @@ -3,6 +3,7 @@ import type { IfProps, SelectionFieldMeta, SelectionMeta, SelectionProvider, Fie import { FIELD_REF_META, BINDX_COMPONENT } from '../types.js' import { mergeSelections, createEmptySelection } from '../SelectionMeta.js' import { useField } from '../../hooks/useField.js' +import { useFields } from '../../hooks/useFields.js' import { type Condition, isCondition, @@ -11,6 +12,8 @@ import { CONDITION_META, } from '../conditions.js' +const NO_CONDITION_FIELDS: readonly unknown[] = [] + /** * If component - conditional rendering that ensures both branches are analyzed * @@ -55,6 +58,9 @@ import { function IfImpl({ condition, then: thenBranch, else: elseBranch }: IfProps): ReactElement | null { const fieldRef = typeof condition !== 'boolean' && !isCondition(condition) ? condition : null const fieldAccessor = useField(fieldRef) + // A Condition is evaluated against live data, so it needs a subscription of its own — + // a memoized is not re-rendered by its parent. + useFields(isCondition(condition) ? collectConditionFields(condition) : NO_CONDITION_FIELDS) let conditionValue: boolean diff --git a/packages/bindx-react/src/jsx/components/Switch.tsx b/packages/bindx-react/src/jsx/components/Switch.tsx index 5c00d3d9..eb75d37d 100644 --- a/packages/bindx-react/src/jsx/components/Switch.tsx +++ b/packages/bindx-react/src/jsx/components/Switch.tsx @@ -13,7 +13,7 @@ import type { SelectionProvider, } from '../types.js' import { FIELD_REF_META, BINDX_COMPONENT } from '../types.js' -import { useField } from '../../hooks/useField.js' +import { useFields } from '../../hooks/useFields.js' import { type Condition, isCondition, @@ -132,6 +132,20 @@ function resolveTriggerField(props: CaseProps): FieldRef | nul return null } +/** + * Fields read by `if={cond...}` cases. They are evaluated against live data, so they need + * a subscription of their own — a memoized is not re-rendered by its parent. + */ +function collectConditionRefs(cases: readonly CaseEntry[]): unknown[] { + const refs: unknown[] = [] + for (const { props } of cases) { + if ('if' in props && props.if !== undefined && isCondition(props.if)) { + refs.push(...collectConditionFields(props.if)) + } + } + return refs +} + // ============================================================================= // Runtime // ============================================================================= @@ -139,17 +153,14 @@ function resolveTriggerField(props: CaseProps): FieldRef | nul function SwitchImpl({ children }: SwitchProps): ReactElement | null { const entries = extractEntries(children) - // Subscribe to one field per case (stable count, stable order). - const accessors: Array<{ value: unknown } | null> = [] - for (const entry of entries.cases) { - const ref = resolveTriggerField(entry.props) - // eslint-disable-next-line react-hooks/rules-of-hooks - accessors.push(useField(ref)) - } + // Two fixed hooks cover every case, so the hook count never depends on how many + // children are rendered (a conditional used to crash React). + const accessors = useFields(entries.cases.map(entry => resolveTriggerField(entry.props))) + useFields(collectConditionRefs(entries.cases)) for (let i = 0; i < entries.cases.length; i++) { const { props } = entries.cases[i]! - const accessor = accessors[i]! + const accessor = accessors[i] if ('show' in props && props.show !== undefined) { const value = accessor?.value diff --git a/tests/react/jsx/ConditionSubscription.test.tsx b/tests/react/jsx/ConditionSubscription.test.tsx new file mode 100644 index 00000000..6e103e3d --- /dev/null +++ b/tests/react/jsx/ConditionSubscription.test.tsx @@ -0,0 +1,139 @@ +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup, act } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + Case, + cond, + Default, + Field, + type FieldRef, + If, + MockAdapter, + Switch, + useEntity, +} from '@contember/bindx-react' +import { createMockData, schema, testSchema } from '../../shared' + +afterEach(() => { + cleanup() +}) + +function queryByTestId(container: Element, testId: string): Element | null { + return container.querySelector(`[data-testid="${testId}"]`) +} + +function getByTestId(container: Element, testId: string): Element { + const el = queryByTestId(container, testId) + if (!el) throw new Error(`Element with data-testid="${testId}" not found`) + return el +} + +interface ProbeProps { + readonly title: FieldRef +} + +/** Render counters let each test prove that memo really bailed out — the precondition for staleness. */ +const renderCounts = { if: 0, switch: 0 } + +const IfProbe = React.memo(function IfProbe({ title }: ProbeProps): React.ReactElement { + renderCounts.if++ + return ( + <> + + then} + else={else} + /> + + ) +}) + +const SwitchProbe = React.memo(function SwitchProbe({ title }: ProbeProps): React.ReactElement { + renderCounts.switch++ + return ( + <> + + + + case + + + default + + + + ) +}) + +let setTitle: ((value: string) => void) | null = null + +interface HostProps { + readonly children: (title: FieldRef) => React.ReactNode +} + +/** + * Owns the entity subscription. Its children callback gets the (stable) title ref, + * so the memoized probes below never re-render when the title changes. + */ +function Host({ children }: HostProps): React.ReactElement { + const article = useEntity(schema.Article, { by: { id: 'article-1' } }, a => a.id().title()) + if (article.$isLoading) return
Loading
+ if (article.$isError || article.$isNotFound) return
Error
+ setTitle = value => article.title.setValue(value) + return
{children(article.title)}
+} + +function renderProbe(probe: (title: FieldRef) => React.ReactNode): Element { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const { container } = render( + + {probe} + , + ) + return container +} + +describe('condition DSL subscriptions', () => { + test(' with a cond DSL condition re-evaluates inside a memoized subtree', async () => { + renderCounts.if = 0 + const container = renderProbe(title => ) + + await waitFor(() => expect(queryByTestId(container, 'loading')).toBeNull()) + expect(queryByTestId(container, 'if-else')).not.toBeNull() + + const rendersBefore = renderCounts.if + act(() => { + setTitle!('Changed') + }) + + // Precondition: the memoized probe does not re-render — only its subscribed leaves may. + expect(renderCounts.if).toBe(rendersBefore) + // Control: is subscribed, so it does show the new value. + expect(getByTestId(container, 'host').textContent).toContain('Changed') + + expect(queryByTestId(container, 'if-then')).not.toBeNull() + expect(queryByTestId(container, 'if-else')).toBeNull() + }) + + test(' with a cond DSL condition re-evaluates inside a memoized subtree', async () => { + renderCounts.switch = 0 + const container = renderProbe(title => ) + + await waitFor(() => expect(queryByTestId(container, 'loading')).toBeNull()) + expect(queryByTestId(container, 'switch-default')).not.toBeNull() + + const rendersBefore = renderCounts.switch + act(() => { + setTitle!('Changed') + }) + + expect(renderCounts.switch).toBe(rendersBefore) + expect(getByTestId(container, 'host').textContent).toContain('Changed') + + expect(queryByTestId(container, 'switch-case')).not.toBeNull() + expect(queryByTestId(container, 'switch-default')).toBeNull() + }) +}) diff --git a/tests/react/jsx/SwitchHookCount.test.tsx b/tests/react/jsx/SwitchHookCount.test.tsx new file mode 100644 index 00000000..d6c2e2c7 --- /dev/null +++ b/tests/react/jsx/SwitchHookCount.test.tsx @@ -0,0 +1,76 @@ +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup, act } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + Case, + Default, + MockAdapter, + Switch, + useEntity, +} from '@contember/bindx-react' +import { createMockData, schema, testSchema } from '../../shared' + +afterEach(() => { + cleanup() +}) + +function queryByTestId(container: Element, testId: string): Element | null { + return container.querySelector(`[data-testid="${testId}"]`) +} + +describe('Switch hook count', () => { + test('survives a conditional appearing and disappearing between renders', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + let setExtraCase: ((extra: boolean) => void) | null = null + + function TestComponent(): React.ReactElement { + const [extra, setExtra] = React.useState(false) + setExtraCase = setExtra + const article = useEntity(schema.Article, { by: { id: 'article-1' } }, a => + a.id().title().published().status().publishedAt(), + ) + if (article.$isLoading) return
Loading
+ if (article.$isError || article.$isNotFound) return
Error
+ return ( + + {extra ? ( + + Extra + + ) : null} + + Title + + + Fallback + + + ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => expect(queryByTestId(container, 'loading')).toBeNull()) + expect(queryByTestId(container, 'title')).not.toBeNull() + + // One more than the previous render. + act(() => { + setExtraCase!(true) + }) + expect(queryByTestId(container, 'extra')).not.toBeNull() + expect(queryByTestId(container, 'title')).toBeNull() + + // ...and one fewer again. + act(() => { + setExtraCase!(false) + }) + expect(queryByTestId(container, 'extra')).toBeNull() + expect(queryByTestId(container, 'title')).not.toBeNull() + }) +}) From 71b8b5da5269bf4ff009da4241445fdd682bfbdb Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 11:48:30 +0200 Subject: [PATCH 15/55] fix(bindx): evict has-many item handles for ids that left the list HasManyListHandle cached an EntityHandle and a proxy per item id and released neither: the whole file had no delete, no clear and no dispose. A paginated, filtered or repeatedly refetched has-many therefore grew for as long as the parent handle lived. The items getter now prunes keys missing from the presented id list, before the handles are resolved, so a still-listed item is never touched and keeps its identity by construction. Two guards go with it. Pruning is skipped while the parent persists, because the presented list hides planned additions and pruning against it would evict a just-added item and re-mint it seconds later. And lookups canonicalise a temp id through its persisted id, so a rekeyed item reuses one handle instead of minting a duplicate for the same entity. A rekeyed entry is evicted rather than migrated. EntityHandle.id returns the id the handle was constructed with, so a carried-over handle would keep reporting a dead temp id while its own $fields.id.value reported the real one. itemHandleCacheRaw goes in the same pass: it was written and never read anywhere in the repo. The proxy is the reference that keeps the handle alive, so caching the raw handle separately bought nothing. --- .../bindx/src/handles/HasManyListHandle.ts | 84 +++++- tests/unit/handles/hasManyItemCache.test.ts | 261 ++++++++++++++++++ 2 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 tests/unit/handles/hasManyItemCache.test.ts diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index 32788835..0ac120e0 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -24,6 +24,7 @@ import type { HasManyDisconnectingEvent, } from '../events/types.js' import { createAliasProxy } from './proxyFactory.js' +import { isPersistedId } from '../store/entityId.js' /** * HasManyListHandle provides access to a has-many relation (list of entities). @@ -33,7 +34,10 @@ import { createAliasProxy } from './proxyFactory.js' * @typeParam TSelected - The selected subset of fields (defaults to TEntity for backwards compatibility) */ export class HasManyListHandle extends EntityRelatedHandle { - private itemHandleCacheRaw = new Map>() + // Per-item accessor cache, keyed by the item's canonical (post-rekey) id. Identity is the + // point: a still-listed item keeps the same proxy — and with it the same handle, which the + // proxy is the only reference to. Kept bounded by syncItemHandleCache, which drops ids + // that have left the list. private itemHandleCacheProxy = new Map>() /** Runtime brand symbols for validation */ @@ -91,7 +95,28 @@ export class HasManyListHandle { - return createAliasProxy, HasManyAccessor>(new HasManyListHandle(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection)) + return createAliasProxy, HasManyAccessor>( + HasManyListHandle.createRaw(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection), + ) + } + + /** + * Creates the handle without the alias proxy. Mirrors {@link EntityHandle.createRaw}: + * the class surface (e.g. {@link itemHandleCacheSize}) is not part of HasManyAccessor. + */ + static createRaw( + parentEntityType: string, + parentEntityId: string, + fieldName: string, + itemType: string, + store: SnapshotStore, + dispatcher: ActionDispatcher, + schema: SchemaRegistry, + brands?: Set, + alias?: string, + selection?: SelectionMeta, + ): HasManyListHandle { + return new HasManyListHandle(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection) } /** @@ -115,6 +140,8 @@ export class HasManyListHandle[] { this.materializeEmbeddedItems() @@ -127,9 +154,54 @@ export class HasManyListHandle this.getItemHandle(id)) } + /** + * Canonical cache key for an item id: a temp id follows its temp→persisted rekey, so a + * lookup by the now-dead temp id reuses the persisted item's handle instead of minting a + * duplicate for the same entity. + */ + private resolveItemKey(itemId: string): string { + if (isPersistedId(itemId)) return itemId + return this.store.getPersistedId(this.itemType, itemId) ?? itemId + } + + /** + * Drops cached item accessors for ids that have left the list. Without it the cache keeps an + * accessor for every id the relation has ever shown, for as long as the parent handle lives + * — a paginated / filtered / refetched has-many grows without bound. + * + * Runs before the accessors are resolved and never touches a live id, so an item that is + * still listed keeps the exact same proxy and the exact same handle behind it. + */ + private syncItemHandleCache(liveIds: readonly string[]): void { + if (this.itemHandleCacheProxy.size === 0) return + + // While the parent persists, the presented list hides planned additions — pruning + // against it would drop handles that reappear as soon as the persist settles. + if (this.store.isPersisting(this.entityType, this.entityId)) return + + const liveKeys = new Set(liveIds) + for (const key of this.itemHandleCacheProxy.keys()) { + if (liveKeys.has(key)) continue + // A key with no live id is dead — including the temp key of a rekeyed item, whose + // handle must be rebuilt under the persisted id (a carried-over handle would keep + // reporting the temp id as its `id`). + this.itemHandleCacheProxy.delete(key) + } + } + + /** + * @internal Occupancy of the item-accessor cache. Exposed only so eviction tests can + * assert the cache stays bounded; not part of the HasManyAccessor API. + */ + get itemHandleCacheSize(): number { + return this.itemHandleCacheProxy.size + } + /** * Propagates the parent's embedded has-many data into per-item snapshots and * ensures the has-many state exists in the store. @@ -280,11 +352,12 @@ export class HasManyListHandle { - let proxy = this.itemHandleCacheProxy.get(itemId) + const key = this.resolveItemKey(itemId) + let proxy = this.itemHandleCacheProxy.get(key) if (!proxy) { const raw = EntityHandle.createRaw( - itemId, + key, this.itemType, this.store, this.dispatcher, @@ -293,8 +366,7 @@ export class HasManyListHandle = { + entities: { + Article: { + fields: { + id: { type: 'scalar' }, + title: { type: 'scalar' }, + tags: { type: 'hasMany', target: 'Tag', relationKind: 'manyHasMany' }, + }, + }, + Tag: { + fields: { + id: { type: 'scalar' }, + name: { type: 'scalar' }, + }, + }, + }, +} + +describe('HasManyListHandle item handle cache', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let schema: SchemaRegistry + + beforeEach(() => { + const setup = createTestDispatcher() + store = setup.store + dispatcher = setup.dispatcher + schema = new SchemaRegistry(testSchemaDefinition) + }) + + /** Raw handle (no alias proxy) so the test can read the cache occupancy. */ + function createListHandle(): HasManyListHandle { + return HasManyListHandle.createRaw('Article', 'a-1', 'tags', 'Tag', store, dispatcher, schema) + } + + /** Simulates a fetch/refetch of the parent with a given page of tags. */ + function loadTags(tags: TestTag[]): void { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test', tags }, true) + } + + function itemIds(handle: HasManyListHandle): string[] { + return handle.items.map(item => item[FIELD_REF_META].entityId) + } + + /** Indexed access that fails loudly instead of yielding undefined. */ + function at(items: EntityAccessor[], index: number): EntityAccessor { + const item = items[index] + if (item === undefined) throw new Error(`no item at index ${index}`) + return item + } + + describe('eviction', () => { + test('releases the cache entry for ids that left the list', () => { + loadTags([{ id: 't-1', name: 'One' }, { id: 't-2', name: 'Two' }, { id: 't-3', name: 'Three' }]) + const handle = createListHandle() + + expect(itemIds(handle)).toEqual(['t-1', 't-2', 't-3']) + expect(handle.itemHandleCacheSize).toBe(3) + + loadTags([{ id: 't-4', name: 'Four' }, { id: 't-5', name: 'Five' }]) + + expect(itemIds(handle)).toEqual(['t-4', 't-5']) + expect(handle.itemHandleCacheSize).toBe(2) + }) + + test('stays bounded across many pages', () => { + const handle = createListHandle() + + for (let page = 0; page < 20; page++) { + loadTags([ + { id: `p${page}-a`, name: 'A' }, + { id: `p${page}-b`, name: 'B' }, + ]) + expect(itemIds(handle)).toEqual([`p${page}-a`, `p${page}-b`]) + } + + expect(handle.itemHandleCacheSize).toBe(2) + }) + + test('releases an id removed from the list', () => { + loadTags([{ id: 't-1', name: 'One' }, { id: 't-2', name: 'Two' }]) + const handle = createListHandle() + expect(handle.items.length).toBe(2) + + handle.disconnect('t-2') + + expect(itemIds(handle)).toEqual(['t-1']) + expect(handle.itemHandleCacheSize).toBe(1) + }) + }) + + describe('identity stability', () => { + test('an item that stays listed keeps the identical proxy across store changes', () => { + loadTags([{ id: 't-1', name: 'One' }, { id: 't-2', name: 'Two' }]) + const handle = createListHandle() + + const before = handle.items + + // Unrelated store changes: a sibling item is edited, an unrelated entity appears. + store.setEntityData('Tag', 't-2', { id: 't-2', name: 'Renamed' }, false) + store.setEntityData('Tag', 't-99', { id: 't-99', name: 'Unrelated' }, true) + + const after = handle.items + + expect(at(after, 0)).toBe(at(before, 0)) + expect(at(after, 1)).toBe(at(before, 1)) + expect(handle.getById('t-1')).toBe(at(before, 0)) + expect(handle.itemHandleCacheSize).toBe(2) + }) + + test('items that stay listed keep identity while another item is evicted', () => { + loadTags([{ id: 't-1', name: 'One' }, { id: 't-2', name: 'Two' }, { id: 't-3', name: 'Three' }]) + const handle = createListHandle() + + const before = handle.items + + loadTags([{ id: 't-1', name: 'One' }, { id: 't-3', name: 'Three' }]) + const after = handle.items + + expect(itemIds(handle)).toEqual(['t-1', 't-3']) + expect(at(after, 0)).toBe(at(before, 0)) + expect(at(after, 1)).toBe(at(before, 2)) + expect(handle.itemHandleCacheSize).toBe(2) + }) + }) + + describe('leave and re-enter', () => { + test('an id that comes back gets a fresh, working accessor', () => { + loadTags([{ id: 't-1', name: 'One' }, { id: 't-2', name: 'Two' }]) + const handle = createListHandle() + + const before = handle.items + const staleTwo = at(before, 1) + + loadTags([{ id: 't-1', name: 'One' }]) + expect(itemIds(handle)).toEqual(['t-1']) + expect(handle.itemHandleCacheSize).toBe(1) + + loadTags([{ id: 't-1', name: 'One' }, { id: 't-2', name: 'Two again' }]) + const after = handle.items + + expect(itemIds(handle)).toEqual(['t-1', 't-2']) + // t-1 never left, so its identity survives; t-2 was really released, not kept. + expect(at(after, 0)).toBe(at(before, 0)) + expect(at(after, 1)).not.toBe(staleTwo) + expect(at(after, 1).$fields.name.value).toBe('Two again') + expect(handle.itemHandleCacheSize).toBe(2) + }) + + test('re-entering does not leave a duplicate behind', () => { + loadTags([{ id: 't-1', name: 'One' }, { id: 't-2', name: 'Two' }]) + const handle = createListHandle() + expect(handle.items.length).toBe(2) + + for (let round = 0; round < 5; round++) { + loadTags([{ id: 't-1', name: 'One' }]) + expect(handle.items.length).toBe(1) + loadTags([{ id: 't-1', name: 'One' }, { id: 't-2', name: 'Two' }]) + expect(handle.items.length).toBe(2) + } + + expect(handle.itemHandleCacheSize).toBe(2) + }) + }) + + describe('temp id rekey', () => { + test('a persisted item leaves no temp entry behind and is addressable by both ids', () => { + loadTags([{ id: 't-1', name: 'One' }]) + const handle = createListHandle() + expect(handle.items.length).toBe(1) + + const tempId = handle.add({ name: 'Fresh' }) + expect(itemIds(handle)).toEqual(['t-1', tempId]) + expect(handle.itemHandleCacheSize).toBe(2) + + store.mapTempIdToPersistedId('Tag', tempId, 't-9') + const after = handle.items + + expect(itemIds(handle)).toEqual(['t-1', 't-9']) + // The temp key is gone — one entry per live item, not one per id ever seen. + expect(handle.itemHandleCacheSize).toBe(2) + // A lookup by the dead temp id resolves to the persisted item's handle. + expect(handle.getById(tempId)).toBe(at(after, 1)) + expect(handle.itemHandleCacheSize).toBe(2) + expect(at(after, 1)[FIELD_REF_META].entityId).toBe('t-9') + }) + + test('a never-persisted temp item keeps its accessor', () => { + loadTags([{ id: 't-1', name: 'One' }]) + const handle = createListHandle() + expect(handle.items.length).toBe(1) + + const tempId = handle.add({ name: 'Fresh' }) + const before = handle.items + const after = handle.items + + expect(at(after, 1)).toBe(at(before, 1)) + expect(handle.getById(tempId)).toBe(at(before, 1)) + expect(handle.itemHandleCacheSize).toBe(2) + }) + }) + + describe('persist in flight', () => { + test('does not evict handles the presented list is temporarily hiding', () => { + loadTags([{ id: 't-1', name: 'One' }]) + const handle = createListHandle() + expect(handle.items.length).toBe(1) + + const tempId = handle.add({ name: 'Fresh' }) + const before = handle.items + expect(itemIds(handle)).toEqual(['t-1', tempId]) + + store.setPersisting('Article', 'a-1', true, true) + + // The pessimistic presentation drops the planned addition, but its handle stays. + expect(itemIds(handle)).toEqual(['t-1']) + expect(handle.itemHandleCacheSize).toBe(2) + + store.setPersisting('Article', 'a-1', false) + const after = handle.items + + expect(itemIds(handle)).toEqual(['t-1', tempId]) + expect(at(after, 1)).toBe(at(before, 1)) + }) + }) +}) From ebe949818bf1c0edd16dfa3d2a7d5c9a44975860 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 11:51:02 +0200 Subject: [PATCH 16/55] test(bindx): pin the store's silent-write notification gaps Four SnapshotStore write paths were suspected of mutating state without notifying subscribers. Investigating each settles them: - createEntity - CONFIRMED, user-visible. roots.register() runs after setEntityData and setExistsOnServer have already notified, and it is the write that makes the entity count as a create. A save indicator built on store.subscribe + getAllDirtyEntities().length renders 0 while the store holds a dirty create. - clearAllServerErrors - CONFIRMED. Its sibling clearAllErrors notifies for the same write. Both in-tree callers happen to self-heal through ordering, so this reaches users through the exported action only. - sweepUnreachableCreated - NOT a bug. Its only mutation is removeEntity, which notifies. - unregisterRootEntity - NOT a bug. Both callers sweep on the next line, and the sweep notifies. A fourth suspect, unregisterParentChild, no longer exists: ae80e75 removed it. docs/issues/032-memory-leaks.md still prescribes wiring it. The two confirmed gaps are pinned with test.failing rather than left as red reproducers, so they can live on main. Bun reports such a test as passing while the bug is present, and fails the run the moment the behaviour is fixed, forcing the marker off. The characterization tests around them record why the other two suspects are non-issues. No fix is included - this commit establishes the evidence. --- .../clearAllServerErrorsStaleness.test.tsx | 132 +++++++++++++++++ .../createDraftRootRegistration.test.tsx | 136 ++++++++++++++++++ .../clearAllServerErrorsNotification.test.ts | 78 ++++++++++ ...EntityRootRegistrationNotification.test.ts | 60 ++++++++ .../rootUnregisterSweepNotification.test.ts | 133 +++++++++++++++++ 5 files changed, 539 insertions(+) create mode 100644 tests/react/storeNotifications/clearAllServerErrorsStaleness.test.tsx create mode 100644 tests/react/storeNotifications/createDraftRootRegistration.test.tsx create mode 100644 tests/unit/store/clearAllServerErrorsNotification.test.ts create mode 100644 tests/unit/store/createEntityRootRegistrationNotification.test.ts create mode 100644 tests/unit/store/rootUnregisterSweepNotification.test.ts diff --git a/tests/react/storeNotifications/clearAllServerErrorsStaleness.test.tsx b/tests/react/storeNotifications/clearAllServerErrorsStaleness.test.tsx new file mode 100644 index 00000000..03d82499 --- /dev/null +++ b/tests/react/storeNotifications/clearAllServerErrorsStaleness.test.tsx @@ -0,0 +1,132 @@ +// KNOWN-BROKEN PIN — the `test.failing` case below asserts what the user should +// see. Bun reports it as passing while `SnapshotStore.clearAllServerErrors` stays +// silent, and turns it into a failure the moment it notifies, which forces +// whoever fixes it to drop the `.failing` marker. The assertions are the real +// symptom; nothing was relaxed to fit the marker. +// +// `clearAllServerErrors` (packages/bindx/src/store/SnapshotStore.ts) clears the +// entity's server errors without calling notifyEntitySubscribers, unlike its +// sibling `clearAllErrors`. Every React consumer of errors reaches them through a +// store subscription — `useField(...).errors` here, and the framework's own +// `useEntityErrors` hook the same way — so with no notification React never +// re-renders and the component keeps painting an error the store no longer holds. +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + Entity, + MockAdapter, + defineSchema, + entityDef, + scalar, + useBindxContext, + useField, + type FieldRef, + type SnapshotStore, +} from '@contember/bindx-react' +import { createServerError } from '@contember/bindx' + +afterEach(() => { + cleanup() +}) + +interface Author { + id: string + name: string +} + +interface AuthorSchema { + Author: Author +} + +const schema = defineSchema({ + entities: { + Author: { fields: { id: scalar(), name: scalar() } }, + }, +}) + +const entityDefs = { Author: entityDef('Author') } as const + +const mockData = { + Author: { 'author-1': { id: 'author-1', name: 'Ada' } }, +} + +function CaptureStore({ onStore }: { onStore: (store: SnapshotStore) => void }): null { + onStore(useBindxContext().store) + return null +} + +/** + * A field-error display, written the way an app writes one: subscribe to the + * field through the public accessor hook and render its error messages. + */ +function NameErrors({ field }: { field: FieldRef }): React.ReactElement { + const name = useField(field) + const messages = name.errors.map(e => e.message).join('|') + return {messages === '' ? 'no-errors' : messages} +} + +function renderAuthorForm(onStore: (store: SnapshotStore) => void): ReturnType { + return render( + + + + {author => } + + , + ) +} + +describe('clearAllServerErrors leaves the rendered error stale', () => { + test.failing('the field error disappears from the DOM when the store clears it (known broken)', async () => { + let store!: SnapshotStore + const { getByTestId } = renderAuthorForm(s => { store = s }) + + await waitFor(() => expect(getByTestId('errors').textContent).toBe('no-errors')) + + // A failed persist put a server error on the field; the form shows it. + act(() => { + store.addFieldError('Author', 'author-1', 'name', createServerError('Name is already taken')) + }) + expect(getByTestId('errors').textContent).toBe('Name is already taken') + + // The store drops every server error for the entity. + act(() => { + store.clearAllServerErrors('Author', 'author-1') + }) + + // Truth in the store: the error is gone. + expect(store.getFieldErrors('Author', 'author-1', 'name')).toEqual([]) + expect(store.hasAnyErrors('Author', 'author-1')).toBe(false) + + // What the user sees: still "Name is already taken", because nothing notified. + expect(getByTestId('errors').textContent).toBe('no-errors') + }) + + // Characterization of the only in-tree caller (BatchPersister, at the top of + // persist: setPersisting(true) immediately followed by clearAllServerErrors). + // The preceding notification is what re-renders the subtree; React re-reads the + // errors at render time, by which point the silent clear has already happened. + // This is why the framework's own persist path does not show the staleness. + test('characterization: a notifying write immediately before the clear hides the bug', async () => { + let store!: SnapshotStore + const { getByTestId } = renderAuthorForm(s => { store = s }) + + await waitFor(() => expect(getByTestId('errors').textContent).toBe('no-errors')) + + act(() => { + store.addFieldError('Author', 'author-1', 'name', createServerError('Name is already taken')) + }) + expect(getByTestId('errors').textContent).toBe('Name is already taken') + + act(() => { + // The BatchPersister sequence, in order. + store.setPersisting('Author', 'author-1', true) + store.clearAllServerErrors('Author', 'author-1') + }) + + expect(getByTestId('errors').textContent).toBe('no-errors') + }) +}) diff --git a/tests/react/storeNotifications/createDraftRootRegistration.test.tsx b/tests/react/storeNotifications/createDraftRootRegistration.test.tsx new file mode 100644 index 00000000..6449c08e --- /dev/null +++ b/tests/react/storeNotifications/createDraftRootRegistration.test.tsx @@ -0,0 +1,136 @@ +// Two halves of the create-draft lifecycle, one broken and one sound. +// +// KNOWN-BROKEN PIN (first test, `test.failing`): it asserts what the user should +// see. Bun reports it as passing while the bug is present and turns it into a +// failure the moment the root registration starts notifying, which forces whoever +// fixes it to drop the `.failing` marker. The assertions are the real symptom; +// nothing was relaxed to fit the marker. `createEntity` writes in +// three steps — setEntityData (notifies), setExistsOnServer (notifies), and +// finally `roots.register()`, which is silent. A created entity only becomes a +// `create` in getAllDirtyEntities() at that third step, so both notifications +// carry the OLD value (0 dirty) and the value that matters is never announced. +// A save indicator built on a global store subscription therefore shows "no +// unsaved changes" while the store holds an unsaved draft, until some unrelated +// write happens to notify. +// +// CHARACTERIZATION (second test, PASSES): the unmount half is sound. +// `unregisterRootEntity` was investigated as a suspected staleness bug and is +// NOT one: it is equally silent, but its callers (Entity.tsx's cleanup, +// useEntityList.ts's draft cleanup) run `sweepUnreachableCreated()` on the next +// line, and the sweep notifies through `removeEntity` for everything it drops. +// +// The consumer in both is the shape every dirty/save indicator has: a global +// subscription over `getAllDirtyEntities()` (what usePersist does internally). +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import React, { useCallback, useSyncExternalStore } from 'react' +import { + BindxProvider, + Entity, + MockAdapter, + defineSchema, + entityDef, + scalar, + useBindxContext, + type BackendAdapter, + type SnapshotStore, +} from '@contember/bindx-react' + +afterEach(() => { + cleanup() +}) + +interface Author { + id: string + name: string +} + +interface AuthorSchema { + Author: Author +} + +const schema = defineSchema({ + entities: { + Author: { fields: { id: scalar(), name: scalar() } }, + }, +}) + +const entityDefs = { Author: entityDef('Author') } as const + +function CaptureStore({ onStore }: { onStore: (store: SnapshotStore) => void }): null { + onStore(useBindxContext().store) + return null +} + +/** An app-shell save indicator: global subscription over the dirty set. */ +function DirtyCount(): React.ReactElement { + const { store } = useBindxContext() + const subscribe = useCallback((callback: () => void) => store.subscribe(callback), [store]) + const getSnapshot = useCallback(() => store.getAllDirtyEntities().length, [store]) + const count = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + return {count} +} + +interface HarnessProps { + adapter: BackendAdapter + showDraft: boolean + onStore: (store: SnapshotStore) => void +} + +function Harness({ adapter, showDraft, onStore }: HarnessProps): React.ReactElement { + return ( + + + + {showDraft && ( + + {author => {author.id}} + + )} + + ) +} + +describe('create-draft root registration and cleanup', () => { + test.failing('the save indicator counts the draft the store already holds (known broken)', async () => { + const adapter = new MockAdapter({}, { delay: 0 }) + let store!: SnapshotStore + + const { getByTestId, rerender } = render( + { store = s }} />, + ) + expect(getByTestId('dirty-count').textContent).toBe('0') + + rerender( { store = s }} />) + await waitFor(() => expect(getByTestId('draft')).toBeTruthy()) + + // Truth in the store: one unsaved create. + expect(store.getAllDirtyEntities()).toHaveLength(1) + + // What the user sees: still "0 unsaved changes", because the root + // registration that made it a create notified nobody. + expect(getByTestId('dirty-count').textContent).toBe('1') + }) + + test('characterization: the save indicator drops back to 0 when the draft form unmounts', async () => { + const adapter = new MockAdapter({}, { delay: 0 }) + let store!: SnapshotStore + + const { getByTestId, rerender } = render( + { store = s }} />, + ) + await waitFor(() => expect(getByTestId('draft')).toBeTruthy()) + + // Sync the indicator past the missing create notification (the bug covered by + // the test above) so this one measures the unmount path alone. + act(() => { store.notify() }) + expect(getByTestId('dirty-count').textContent).toBe('1') + + // Unmounting the form runs unregisterRootEntity() + sweepUnreachableCreated(). + rerender( { store = s }} />) + + expect(store.getAllDirtyEntities()).toHaveLength(0) + expect(getByTestId('dirty-count').textContent).toBe('0') + }) +}) diff --git a/tests/unit/store/clearAllServerErrorsNotification.test.ts b/tests/unit/store/clearAllServerErrorsNotification.test.ts new file mode 100644 index 00000000..6f034799 --- /dev/null +++ b/tests/unit/store/clearAllServerErrorsNotification.test.ts @@ -0,0 +1,78 @@ +// KNOWN-BROKEN PIN — the `test.failing` cases below assert the behaviour the +// store SHOULD have. Bun reports them as passing while the bug is present, and +// turns them into a failure the moment it is fixed, which forces whoever fixes it +// to drop the `.failing` marker. The assertions are the real symptom; nothing was +// relaxed to fit the marker. +// +// `SnapshotStore.clearAllServerErrors` mutates observable state (getFieldErrors / +// getEntityErrors / hasAnyErrors) but never calls notifyEntitySubscribers, while +// its sibling `clearAllErrors` does — an asymmetry with no stated reason. A +// subscriber therefore keeps reading the pre-clear error list until some unrelated +// write happens to notify. +// +// The React-level consequence is in tests/react/storeNotifications/. +import { describe, test, expect, beforeEach } from 'bun:test' +import { SnapshotStore, createServerError } from '@contember/bindx' + +describe('clearAllServerErrors / clearAllErrors notification symmetry', () => { + let store: SnapshotStore + + beforeEach(() => { + store = new SnapshotStore() + store.setEntityData('Author', 'author-1', { id: 'author-1', name: 'Ada' }, true) + store.addFieldError('Author', 'author-1', 'name', createServerError('Name is already taken')) + }) + + /** The value a subscriber would render: the field's error messages. */ + const renderedErrors = (): string => + store.getFieldErrors('Author', 'author-1', 'name').map(e => e.message).join('|') + + test.failing('clearAllServerErrors notifies the entity subscriber (known broken)', () => { + let notifications = 0 + const unsubscribe = store.subscribeToEntity('Author', 'author-1', () => { notifications++ }) + + // Value BEFORE the write — this is what a subscriber is currently showing. + expect(renderedErrors()).toBe('Name is already taken') + + store.clearAllServerErrors('Author', 'author-1') + + // The value changed … + expect(renderedErrors()).toBe('') + expect(store.hasAnyErrors('Author', 'author-1')).toBe(false) + // … so the subscriber must be told, otherwise it keeps rendering the old one. + expect(notifications).toBe(1) + + unsubscribe() + }) + + test.failing('clearAllServerErrors notifies global subscribers (known broken)', () => { + let notifications = 0 + const unsubscribe = store.subscribe(() => { notifications++ }) + const versionBefore = store.getVersion() + + store.clearAllServerErrors('Author', 'author-1') + + expect(renderedErrors()).toBe('') + expect(notifications).toBe(1) + expect(store.getVersion()).toBeGreaterThan(versionBefore) + + unsubscribe() + }) + + // Control (passes today): the sibling method notifies for exactly the same + // class of write. This is the asymmetry that makes the above a bug and not a + // deliberate design. + test('control: clearAllErrors notifies the entity subscriber', () => { + let notifications = 0 + const unsubscribe = store.subscribeToEntity('Author', 'author-1', () => { notifications++ }) + + expect(renderedErrors()).toBe('Name is already taken') + + store.clearAllErrors('Author', 'author-1') + + expect(renderedErrors()).toBe('') + expect(notifications).toBe(1) + + unsubscribe() + }) +}) diff --git a/tests/unit/store/createEntityRootRegistrationNotification.test.ts b/tests/unit/store/createEntityRootRegistrationNotification.test.ts new file mode 100644 index 00000000..a2c5f56b --- /dev/null +++ b/tests/unit/store/createEntityRootRegistrationNotification.test.ts @@ -0,0 +1,60 @@ +// KNOWN-BROKEN PIN — the `test.failing` case below asserts the behaviour the +// store SHOULD have. Bun reports it as passing while the bug is present, and +// turns it into a failure the moment the root registration starts notifying, +// which forces whoever fixes it to drop the `.failing` marker. The assertions are +// the real symptom; nothing was relaxed to fit the marker. +// +// `SnapshotStore.createEntity` writes in three steps: setEntityData (notifies), +// setExistsOnServer (notifies), then `roots.register()` — which is silent. The +// entity only becomes a `create` in getAllDirtyEntities() at that third step, so +// both notifications carry the pre-registration value and the value that matters +// is never announced. A subscriber keeps reading "nothing to save". +// +// The user-visible half of this is in +// tests/react/storeNotifications/createDraftRootRegistration.test.tsx. +import { describe, test, expect, beforeEach } from 'bun:test' +import { SnapshotStore } from '@contember/bindx' + +describe('createEntity root registration notification', () => { + let store: SnapshotStore + + beforeEach(() => { + store = new SnapshotStore() + }) + + test.failing('the dirty count a subscriber last saw includes the new create (known broken)', () => { + // What a save indicator renders on every notification. + const seen: number[] = [] + const unsubscribe = store.subscribe(() => { seen.push(store.getAllDirtyEntities().length) }) + + const draftId = store.createEntity('Author', { name: 'draft' }) + + // Truth in the store after the call. + expect(store.getAllDirtyEntities()).toEqual([ + { entityType: 'Author', entityId: draftId, changeType: 'create' }, + ]) + + // The subscriber was notified … + expect(seen.length).toBeGreaterThan(0) + // … but the last value it observed must match the store, or it renders a + // stale "no unsaved changes". + expect(seen.at(-1)).toBe(1) + + unsubscribe() + }) + + // Control (passes today): an ordinary edit announces the value it produced. + test('control: an update announces the dirty count it produced', () => { + store.setEntityData('Author', 'author-1', { id: 'author-1', name: 'Ada' }, true) + + const seen: number[] = [] + const unsubscribe = store.subscribe(() => { seen.push(store.getAllDirtyEntities().length) }) + + store.setFieldValue('Author', 'author-1', ['name'], 'Ada Lovelace') + + expect(store.getAllDirtyEntities()).toHaveLength(1) + expect(seen.at(-1)).toBe(1) + + unsubscribe() + }) +}) diff --git a/tests/unit/store/rootUnregisterSweepNotification.test.ts b/tests/unit/store/rootUnregisterSweepNotification.test.ts new file mode 100644 index 00000000..39215951 --- /dev/null +++ b/tests/unit/store/rootUnregisterSweepNotification.test.ts @@ -0,0 +1,133 @@ +// NEGATIVE RESULT, CHARACTERIZATION (these tests PASS) — `sweepUnreachableCreated` +// and `unregisterRootEntity` were both investigated as suspected missing-notification +// bugs and neither is one. No `test.failing` pin belongs here; these tests record +// why the two reachability writes that carry no `notify` call of their own are NOT +// a staleness bug. +// +// - `sweepUnreachableCreated` has no notify statement, but every snapshot it +// drops goes through `removeEntity`, which notifies the entity, its live +// parents and the global subscribers. Removing nothing changes nothing. +// - `unregisterRootEntity` is silent, but both of its callers (the `` unmount cleanup in Entity.tsx and the draft cleanup in +// useEntityList.ts) run `sweepUnreachableCreated()` immediately after, so the +// entities whose dirty state the un-root changed are exactly the ones the +// sweep removes — and notifies for. +// +// If either of these ever stops holding, these tests fail and the silent write +// becomes a real staleness bug. +import { describe, test, expect, beforeEach } from 'bun:test' +import { SnapshotStore } from '@contember/bindx' + +describe('reachability writes notify through removeEntity', () => { + let store: SnapshotStore + + beforeEach(() => { + store = new SnapshotStore() + }) + + const dirtyIds = (): string[] => store.getAllDirtyEntities().map(e => e.entityId).sort() + + test('sweepUnreachableCreated notifies the subscribers of every snapshot it removes', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T' }, true) + const commentId = store.createEntity('Comment', { text: 'draft' }) + store.addToHasMany('Article', 'a-1', 'comments', commentId) + store.registerParentChild('Article', 'a-1', 'Comment', commentId) + // The user drops the draft comment from the list again: still in the store, + // no longer reachable from any root. + store.removeFromHasMany('Article', 'a-1', 'comments', commentId, 'disconnect') + + let entityNotifications = 0 + let globalNotifications = 0 + const unsubscribeEntity = store.subscribeToEntity('Comment', commentId, () => { entityNotifications++ }) + const unsubscribeGlobal = store.subscribe(() => { globalNotifications++ }) + + expect(store.hasEntity('Comment', commentId)).toBe(true) + + store.sweepUnreachableCreated() + + expect(store.hasEntity('Comment', commentId)).toBe(false) + expect(entityNotifications).toBeGreaterThanOrEqual(1) + expect(globalNotifications).toBeGreaterThanOrEqual(1) + + unsubscribeEntity() + unsubscribeGlobal() + }) + + test('sweepUnreachableCreated with nothing to reclaim changes no observable value', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'T' }, true) + const commentId = store.createEntity('Comment', { text: 'live' }) + store.addToHasMany('Article', 'a-1', 'comments', commentId) + store.registerParentChild('Article', 'a-1', 'Comment', commentId) + + let notifications = 0 + const unsubscribe = store.subscribe(() => { notifications++ }) + const dirtyBefore = dirtyIds() + + store.sweepUnreachableCreated() + + expect(dirtyIds()).toEqual(dirtyBefore) + expect(store.hasEntity('Comment', commentId)).toBe(true) + expect(notifications).toBe(0) + + unsubscribe() + }) + + test('the unmount sequence (unregisterRootEntity + sweep) notifies while the draft leaves the dirty list', () => { + const draftId = store.createEntity('Author', { name: 'draft' }) + expect(dirtyIds()).toEqual([draftId]) + + let entityNotifications = 0 + let globalNotifications = 0 + const unsubscribeEntity = store.subscribeToEntity('Author', draftId, () => { entityNotifications++ }) + const unsubscribeGlobal = store.subscribe(() => { globalNotifications++ }) + + // Exactly what Entity.tsx / useEntityList.ts do on unmount. + store.unregisterRootEntity('Author', draftId) + store.sweepUnreachableCreated() + + expect(dirtyIds()).toEqual([]) + expect(store.hasEntity('Author', draftId)).toBe(false) + expect(entityNotifications).toBeGreaterThanOrEqual(1) + expect(globalNotifications).toBeGreaterThanOrEqual(1) + + unsubscribeEntity() + unsubscribeGlobal() + }) + + test('a created child orphaned with its un-rooted parent is reclaimed and notified too', () => { + const authorId = store.createEntity('Author', { name: 'draft' }) + const articleId = store.createEntity('Article', { title: 'child draft' }) + store.addToHasMany('Author', authorId, 'articles', articleId) + store.registerParentChild('Author', authorId, 'Article', articleId) + + expect(dirtyIds()).toEqual([articleId, authorId].sort()) + + let childNotifications = 0 + const unsubscribe = store.subscribeToEntity('Article', articleId, () => { childNotifications++ }) + + store.unregisterRootEntity('Author', authorId) + store.sweepUnreachableCreated() + + expect(dirtyIds()).toEqual([]) + expect(store.hasEntity('Article', articleId)).toBe(false) + expect(childNotifications).toBeGreaterThanOrEqual(1) + + unsubscribe() + }) + + test('unregistering the root of an already-persisted entity changes no observable value', () => { + const draftId = store.createEntity('Author', { name: 'draft' }) + store.mapTempIdToPersistedId('Author', draftId, 'author-1') + store.setExistsOnServer('Author', 'author-1', true) + + const dirtyBefore = dirtyIds() + + store.unregisterRootEntity('Author', 'author-1') + store.sweepUnreachableCreated() + + // A server entity is a reachability root on its own, so dropping the + // registry entry is inert — there is nothing for a subscriber to miss. + expect(dirtyIds()).toEqual(dirtyBefore) + expect(store.hasEntity('Author', 'author-1')).toBe(true) + }) +}) From 4470e9727560c8f839cb12d27183541a3d162aa1 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 13:48:50 +0200 Subject: [PATCH 17/55] chore: wire up eslint with the React hooks rules The repo had no eslint config at all, while the source carried `// eslint-disable-next-line react-hooks/rules-of-hooks` comments that nothing has ever enforced. One of them sat over a `useField` call inside a loop in , under a comment asserting a "stable count" that was false; it crashes React as soon as a is conditionally rendered. Enable exactly two rules over packages/*/src and tests: rules-of-hooks as an error, exhaustive-deps as a warning. eslint-plugin-react-hooks v7 ships 28 rules (the React Compiler set); none of the others are turned on and no style preset is added, so the signal stays readable in a repo with no lint history. No CI job is added. `bun run lint` exits 1 today: 9 rules-of-hooks sites (7 genuine hazards, 2 provably-stable false positives) and 25 phantom errors from dead `@typescript-eslint/*` disable directives naming rules no config defines. Turning the gate on has to wait for those. Flat config is named .mjs because the root package.json has no "type": "module". --- bun.lock | 165 +++++++++++++++++++++++++++++++++++++++++++++- eslint.config.mjs | 33 ++++++++++ package.json | 4 ++ 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 eslint.config.mjs diff --git a/bun.lock b/bun.lock index 4a264273..61c52614 100644 --- a/bun.lock +++ b/bun.lock @@ -11,8 +11,11 @@ "@testing-library/react": "^16.3.1", "@types/react": "^19", "@types/react-dom": "^19", + "@typescript-eslint/parser": "^8.67.0", "@vitejs/plugin-react": "^5.1.2", "bun-types": "^1.3.5", + "eslint": "^10.8.1", + "eslint-plugin-react-hooks": "^7.1.1", "react": "^19.0.0", "react-dom": "^19.0.0", "typescript": "^5.3.0", @@ -335,6 +338,20 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], @@ -345,6 +362,16 @@ "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.10.6" } }, "sha512-Nu/IjRkkNxmeG2ywWsyJSO4d1BrWTqVzxCPL+gXj0b97klhmjd6wLzt6Bx/laNpkZ3WLW7zNCqmtMIbIlahqug=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -511,8 +538,12 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/node": ["@types/node@25.9.4", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], @@ -523,8 +554,28 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.67.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", "@typescript-eslint/typescript-estree": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.67.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.67.0", "@typescript-eslint/types": "^8.67.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0" } }, "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.67.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.67.0", "", {}, "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.67.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.67.0", "@typescript-eslint/tsconfig-utils": "8.67.0", "@typescript-eslint/types": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -535,8 +586,12 @@ "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="], + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], @@ -553,10 +608,14 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -577,38 +636,96 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.8.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graphql": ["graphql@16.14.2", "", {}, "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA=="], "happy-dom": ["happy-dom@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw=="], + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-hotkey": ["is-hotkey@0.2.0", "", {}, "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="], "is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -633,6 +750,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -645,24 +764,42 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], @@ -687,7 +824,11 @@ "scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "slate": ["slate@0.103.0", "", { "dependencies": { "immer": "^10.0.3", "is-plain-object": "^5.0.0", "tiny-warning": "^1.0.3" } }, "sha512-eCUOVqUpADYMZ59O37QQvUdnFG+8rin0OGQAXNHvHbQeVJ67Bu0spQbcy621vtf8GQUXTEQBlk6OP9atwwob4w=="], @@ -709,14 +850,20 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], @@ -725,10 +872,24 @@ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@contember/client-content/@contember/graphql-builder": ["@contember/graphql-builder@2.1.0-beta.1", "", { "dependencies": { "@contember/schema": "2.1.0-beta.1" } }, "sha512-lGl8dWz8N4ed8sWK/ZvAxFv4x6v0Wg/wl3wHbG7JAlyjDvkwNBW75nI1QMUhTW9xsCl4HOAskkzM85FcxmqWhw=="], "@contember/client-content/@contember/graphql-client": ["@contember/graphql-client@2.1.0-beta.1", "", {}, "sha512-Ff61UUusAXzKqAzx/hTpD26H/CLVjvix3NOr5BVQqy7Cz2UebuzKOL+Q5Posfavo1r5RQqRRQOykfRmmO6E6sw=="], @@ -743,6 +904,8 @@ "@contember/schema-utils/@contember/schema": ["@contember/schema@2.1.0-beta.1", "", {}, "sha512-EBVELhD5bpubuXY8rWQnNilILrY4s54R5BG75ztIbTZHy262Dr1LQSxSa+b0e8x4ahAwyoe/NFrZp08m/eSggg=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..3eea4ed8 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,33 @@ +import reactHooks from 'eslint-plugin-react-hooks' +import tsParser from '@typescript-eslint/parser' + +// Deliberately narrow: only the React hooks rules. The repo has no lint culture +// yet, so a broad style ruleset would bury the signal. See PR that added this. +export default [ + { + ignores: [ + '**/dist/**', + '**/node_modules/**', + '**/generated/**', + '**/*.d.ts', + ], + }, + { + files: ['packages/*/src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'], + languageOptions: { + parser: tsParser, + ecmaVersion: 2022, + sourceType: 'module', + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, + plugins: { + 'react-hooks': reactHooks, + }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + }, + }, +] diff --git a/package.json b/package.json index 516d0d45..7fc0da5a 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "tsc --build", "dev": "tsc --build --watch", "typecheck": "tsc --build", + "lint": "eslint .", "test": "bun test --path-ignore-patterns='**/tests/browser/**'", "test:browser": "bun test --timeout 30000 tests/browser/", "playground": "cd packages/example && bun run dev", @@ -25,8 +26,11 @@ "@testing-library/react": "^16.3.1", "@types/react": "^19", "@types/react-dom": "^19", + "@typescript-eslint/parser": "^8.67.0", "@vitejs/plugin-react": "^5.1.2", "bun-types": "^1.3.5", + "eslint": "^10.8.1", + "eslint-plugin-react-hooks": "^7.1.1", "react": "^19.0.0", "react-dom": "^19.0.0", "typescript": "^5.3.0", From 570181424eda88915b4aa5a1f7005877d0830220 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 13:49:39 +0200 Subject: [PATCH 18/55] fix(bindx-dataview): analyze the JSX a relation column renderer returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three selection-collection sites in createRelationColumn invoked the cell renderer against a collector proxy and threw the returned JSX away. Selections were captured only as a side effect of property accesses on the proxy, so a declarative renderer such as {p => {tag => }} registered `tags` but never `tag.name` — the inner callback is owned by a nested component and is never invoked during collection. Rows came back with their nested fields missing, which forced consumers to write cells imperatively as `p.tags.map(t => t.name.value)` purely so the proxy would see the accesses. Feed the renderer's return value through collectSelection() and merge the result into the relation's own child SelectionScope. The scope matters: the collected fields are relative to the related entity, so merging them into the row-level selection would attach them to the wrong entity. The merge is additive — / getSelection already register into the child scope as a side effect of map()/$entity, and Field returns null during collection, so nothing is double-counted. The existing .map()-on-proxy pattern keeps working; it is guarded by a test. buildLeaf's relatedSelection had the same defect. It feeds extractScalarFieldNames, so a declaratively-rendered has-many stayed in the list as a bare scalar and was handed to the fulltext filter handler as a searchable path — a `contains` against a relation. It now carries its nested selection and is correctly excluded. Known gap, left alone: buildLeaf builds its proxy without a schemaRegistry, which is not reachable from staticRender(props). At nesting depth >= 2 a related field named like a collector built-in (`value`, `items`, `length`, ...) resolves to the stub and is dropped. Top level is immune - the root proxy uses an allowlist. --- .../src/createRelationColumn.tsx | 29 +- .../dataview/relationColumnSelection.test.tsx | 308 ++++++++++++++++++ 2 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 tests/react/dataview/relationColumnSelection.test.tsx diff --git a/packages/bindx-dataview/src/createRelationColumn.tsx b/packages/bindx-dataview/src/createRelationColumn.tsx index d841484a..340099b8 100644 --- a/packages/bindx-dataview/src/createRelationColumn.tsx +++ b/packages/bindx-dataview/src/createRelationColumn.tsx @@ -13,7 +13,7 @@ import React from 'react' import type { FieldRef, FilterArtifact, FilterHandler, EntityAccessor, SelectionMeta } from '@contember/bindx' import { SelectionScope } from '@contember/bindx' -import { createCollectorProxy } from '@contember/bindx-react' +import { createCollectorProxy, collectSelection as collectJsxSelection, SCOPE_REF } from '@contember/bindx-react' import type { ColumnTypeDef } from './columnTypes.js' import { accessField } from './columnTypes.js' @@ -66,6 +66,25 @@ export interface RelationColumnProps { children: (entity: EntityAccessor) => React.ReactNode } +/** Reads a collector proxy's own SelectionScope; runtime refs do not carry one. */ +function readSelectionScope(target: unknown): SelectionScope | null { + if (target === null || typeof target !== 'object' || !(SCOPE_REF in target)) { + return null + } + const scope = target[SCOPE_REF] + return scope instanceof SelectionScope ? scope : null +} + +/** + * Merges the selection declared by the cell renderer's returned JSX into the + * given scope. Proxy touches alone only see the refs passed as props, so fields + * declared by nested components would otherwise never be fetched. + */ +function mergeRenderedSelection(rendered: React.ReactNode, scope: SelectionScope | null): void { + if (!scope) return + scope.mergeFromSelectionMeta(collectJsxSelection(rendered)) +} + // ============================================================================ // Factory // ============================================================================ @@ -97,7 +116,7 @@ export function createRelationColumn( if (relatedEntityName && renderer) { const scope = new SelectionScope() const proxy = createCollectorProxy(scope, relatedEntityName) - renderer(proxy) + mergeRenderedSelection(renderer(proxy), scope) relatedSelection = scope.toSelectionMeta() } @@ -177,7 +196,7 @@ export interface RelationColumnComponent { export const hasOneCellConfig: RelationCellConfig = { collectSelection: (renderer, fieldRef) => { - renderer(fieldRef) + mergeRenderedSelection(renderer(fieldRef), readSelectionScope(fieldRef)) }, renderCell: (accessor, fieldName, renderer) => { const related = getRelatedAccessor(accessor, fieldName) @@ -189,7 +208,9 @@ export const hasOneCellConfig: RelationCellConfig = { export const hasManyCellConfig: RelationCellConfig = { collectSelection: (renderer, fieldRef) => { const ref = fieldRef as { map?: (fn: (item: unknown, index: number) => unknown) => unknown[] } - ref.map?.((item) => { renderer(item); return null }) + const rendered: React.ReactNode[] = [] + ref.map?.((item) => { rendered.push(renderer(item)); return null }) + mergeRenderedSelection(rendered, readSelectionScope(fieldRef)) }, renderCell: (accessor, fieldName, renderer) => { const ref = accessField(accessor, fieldName) as { items?: EntityAccessor[] } | null diff --git a/tests/react/dataview/relationColumnSelection.test.tsx b/tests/react/dataview/relationColumnSelection.test.tsx new file mode 100644 index 00000000..0b18e166 --- /dev/null +++ b/tests/react/dataview/relationColumnSelection.test.tsx @@ -0,0 +1,308 @@ +/** + * Selection collection for relation columns (`createRelationColumn`). + * + * A relation column's cell renderer may be written declaratively — returning + * `` / `` / `` JSX instead of touching the collector + * proxy imperatively. The fields such JSX declares must end up in both places + * the column collects into: the row query selection (nested under the column's + * own relation) and the standalone `relatedSelection` the filter popover fetches. + */ +import '../../setup' +import { describe, test, expect } from 'bun:test' +import React from 'react' +import { + DataGridHasOneColumn, + DataGridHasManyColumn, + extractColumnLeaves, + type ColumnLeafProps, +} from '@contember/bindx-dataview' +import { + Field, + HasMany, + HasOne, + createCollectorProxy, + defineSchema, + scalar, + hasOne, + hasMany, +} from '@contember/bindx-react' +import { SelectionScope, SchemaRegistry, type EntityAccessor, type SelectionMeta } from '@contember/bindx' + +// ============================================================================ +// Schema +// ============================================================================ + +interface Country { + id: string + code: string +} + +interface Member { + id: string + fullName: string +} + +interface Organization { + id: string + name: string + country: Country | null + members: Member[] +} + +interface Tag { + id: string + label: string + members: Member[] +} + +interface Project { + id: string + name: string + organization: Organization | null + tags: Tag[] +} + +interface TestSchema { + Project: Project + Organization: Organization + Tag: Tag + Member: Member + Country: Country +} + +const testSchema = defineSchema({ + entities: { + Project: { + fields: { + id: scalar(), + name: scalar(), + organization: hasOne('Organization'), + tags: hasMany('Tag'), + }, + }, + Organization: { + fields: { + id: scalar(), + name: scalar(), + country: hasOne('Country'), + members: hasMany('Member'), + }, + }, + Tag: { + fields: { + id: scalar(), + label: scalar(), + members: hasMany('Member'), + }, + }, + Member: { + fields: { + id: scalar(), + fullName: scalar(), + }, + }, + Country: { + fields: { + id: scalar(), + code: scalar(), + }, + }, + }, +}) + +const schemaRegistry = new SchemaRegistry(testSchema) + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Runs the DataGrid collection phase for a single column against a fresh row + * scope and returns the resulting selection. + */ +function collectColumnSelection(build: (it: EntityAccessor) => React.ReactNode): SelectionMeta { + const scope = new SelectionScope() + const collector = createCollectorProxy(scope, 'Project', schemaRegistry) + const leaves = extractColumnLeaves(build(collector)) + expect(leaves).toHaveLength(1) + for (const leaf of leaves) { + leaf.collectSelection?.(collector) + } + return scope.toSelectionMeta() +} + +/** Builds a single column leaf, exactly as the DataGrid's `analyzeChildren` pass does. */ +function buildColumnLeaf(build: (it: EntityAccessor) => React.ReactNode): ColumnLeafProps { + const scope = new SelectionScope() + const collector = createCollectorProxy(scope, 'Project', schemaRegistry) + const leaves = extractColumnLeaves(build(collector)) + expect(leaves).toHaveLength(1) + return leaves[0]! +} + +/** Descend into a relation's nested selection, failing loudly when it is absent. */ +function nested(selection: SelectionMeta, ...path: readonly string[]): SelectionMeta { + let current = selection + for (const segment of path) { + const field = current.fields.get(segment) + if (!field?.nested) { + throw new Error(`Expected relation "${segment}" with a nested selection, got: ${[...current.fields.keys()].join(', ') || ''}`) + } + current = field.nested + } + return current +} + +function fieldNames(selection: SelectionMeta): string[] { + return [...selection.fields.keys()].sort() +} + +// ============================================================================ +// hasOne column +// ============================================================================ + +describe('relation column selection — hasOne', () => { + test('renderer returning JSX registers the field', () => { + const selection = collectColumnSelection(it => ( + + {org => } + + )) + + expect(fieldNames(nested(selection, 'organization'))).toEqual(['id', 'name']) + }) + + test('nested in the renderer registers its children fields', () => { + const selection = collectColumnSelection(it => ( + + {org => ( + + {member => } + + )} + + )) + + const members = nested(selection, 'organization', 'members') + expect(fieldNames(members)).toEqual(['fullName', 'id']) + expect(selection.fields.get('organization')?.nested?.fields.get('members')?.isArray).toBe(true) + }) + + test('nested in the renderer registers its children fields', () => { + const selection = collectColumnSelection(it => ( + + {org => ( + + {country => } + + )} + + )) + + expect(fieldNames(nested(selection, 'organization', 'country'))).toEqual(['code', 'id']) + }) + + test('the .map()-on-proxy pattern still registers the same selection', () => { + const selection = collectColumnSelection(it => ( + + {org => org.members.map(member => member.fullName.value).join(', ')} + + )) + + const members = nested(selection, 'organization', 'members') + expect(fieldNames(members)).toEqual(['fullName', 'id']) + expect(selection.fields.get('organization')?.nested?.fields.get('members')?.isArray).toBe(true) + }) +}) + +// ============================================================================ +// hasMany column +// ============================================================================ + +describe('relation column selection — hasMany', () => { + test('renderer returning JSX registers the field', () => { + const selection = collectColumnSelection(it => ( + + {tag => } + + )) + + expect(fieldNames(nested(selection, 'tags'))).toEqual(['id', 'label']) + expect(selection.fields.get('tags')?.isArray).toBe(true) + }) + + test('nested in the renderer registers its children fields', () => { + const selection = collectColumnSelection(it => ( + + {tag => ( + + {member => } + + )} + + )) + + const members = nested(selection, 'tags', 'members') + expect(fieldNames(members)).toEqual(['fullName', 'id']) + }) + + test('the .map()-on-proxy pattern still registers the same selection', () => { + const selection = collectColumnSelection(it => ( + + {tag => tag.members.map(member => member.fullName.value).join(', ')} + + )) + + const members = nested(selection, 'tags', 'members') + expect(fieldNames(members)).toEqual(['fullName', 'id']) + }) +}) + +// ============================================================================ +// relatedSelection — the standalone selection the filter popover fetches with +// ============================================================================ + +describe('relation column relatedSelection', () => { + test('nested in the renderer reaches relatedSelection', () => { + const leaf = buildColumnLeaf(it => ( + + {org => ( + + {member => } + + )} + + )) + + const related = leaf.relatedSelection + if (!related) throw new Error('expected relatedSelection to be built') + expect(fieldNames(nested(related, 'members'))).toEqual(['fullName', 'id']) + expect(related.fields.get('members')?.isArray).toBe(true) + }) + + test('renderer returning JSX reaches relatedSelection', () => { + const leaf = buildColumnLeaf(it => ( + + {org => } + + )) + + expect(leaf.relatedSelection?.fields.has('name')).toBe(true) + }) + + test('hasMany column: nested in the renderer reaches relatedSelection', () => { + const leaf = buildColumnLeaf(it => ( + + {tag => ( + + {member => } + + )} + + )) + + const related = leaf.relatedSelection + if (!related) throw new Error('expected relatedSelection to be built') + expect(fieldNames(nested(related, 'members'))).toEqual(['fullName', 'id']) + }) +}) From b583dc0d038f131cad79c854c36c4b5fe15d7e98 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 13:50:37 +0200 Subject: [PATCH 19/55] feat(bindx): add selection-erased entity view types Consumers hand a selection-branded accessor to a helper typed for a different selection and bridge the gap with `as unknown as`. The diagnosis this was meant to fix - "the __selected brand is invariant" - is wrong. `readonly __selected?: TSelected` is a readonly optional property, i.e. an output position, and is already covariant: widening a full accessor to a narrower selection compiles today. What actually fails is the opposite direction, for three reasons, and only one of them is the brand: 1. narrow -> full is rejected by the brand AND, independently, by EntityFieldsRef/EntityFieldsAccessor, which are keyed on `keyof TSelected` and so are missing the properties outright. It SHOULD be rejected: EntityHandle.fields throws UnfetchedFieldError for any field outside the selection, so widening in place is a real runtime bug. Loosening the brand would legalise it. 2. A free `TSelected` in a generic helper cannot be resolved at all - no variance annotation can fix an unknowable mapped-type key set. 3. TEntityName is invariant through FieldRefMeta.entityType, so a `string`-named accessor does not flow into a literal-named parameter. A second, independent cast generator. So instead of changing an existing type, add two erased views: EntityRefLike = EntityRefInterface EntityAccessorLike = EntityRefLike & { $data } They erase TSelected and TEntityName, keep __entityType as the discriminator, and deliberately omit the field proxy. Use them in parameter positions that need entity identity and the selection-independent API. A receiver that reads fields must still declare the selection it needs - that cast was hiding a bug. Nothing existing is modified. `__selected` also turned out to be load-bearing for inference, not just checking: replacing it with `unknown` breaks selection inference across ~30 sites. --- packages/bindx/src/handles/index.ts | 3 + packages/bindx/src/handles/types.ts | 39 +++ packages/bindx/src/index.ts | 3 + tests/unit/types/selectionErasure.test.ts | 279 ++++++++++++++++++++++ 4 files changed, 324 insertions(+) create mode 100644 tests/unit/types/selectionErasure.test.ts diff --git a/packages/bindx/src/handles/index.ts b/packages/bindx/src/handles/index.ts index 975156a0..e2fc2c67 100644 --- a/packages/bindx/src/handles/index.ts +++ b/packages/bindx/src/handles/index.ts @@ -27,6 +27,9 @@ export { type HasManyAccessor, type HasOneAccessor, type EntityAccessor, + // Selection-erased views (parameter positions that accept any selection) + type EntityRefLike, + type EntityAccessorLike, type Unsubscribe, // Type extraction helpers type ExtractHasOneEntityName, diff --git a/packages/bindx/src/handles/types.ts b/packages/bindx/src/handles/types.ts index 819d760b..02a1f87f 100644 --- a/packages/bindx/src/handles/types.ts +++ b/packages/bindx/src/handles/types.ts @@ -364,6 +364,45 @@ export type EntityAccessor< readonly $data: TSelected | null } & EntityFieldsAccessor +// ============================================================================ +// SELECTION-ERASED VIEWS +// ============================================================================ + +/** + * Selection-erased pointer to an entity of type `TEntity`. + * + * `EntityRef` means "an entity ref whose selection is the *whole* + * entity", so a ref carrying a narrower selection is (correctly) not assignable + * to it, and neither is a ref whose selection is still a free type parameter. + * Use `EntityRefLike` in parameter positions that only need entity *identity* + * and the selection-independent API (`id`, `$isNew`, `$errors`, `$on`, ...) and + * accept any selection, including one not yet known: + * + * ```ts + * // accepts EntityRef + * function trackVisit(website: EntityRefLike): void { ... } + * ``` + * + * It deliberately drops the field proxy: erasing the selection must never grant + * field access, because reading an unfetched field throws `UnfetchedFieldError` + * at runtime. To read fields, take an `EntityRef` instead. + * + * `TEntityName` is erased to `string`, so refs produced with a literal entity + * name (hooks) and refs produced without one (createComponent) both fit. + */ +export type EntityRefLike = EntityRefInterface + +/** + * Selection-erased live accessor for an entity of type `TEntity`. + * + * Same erasure as {@link EntityRefLike}, but only satisfied by *live* accessors + * (`EntityAccessor` and `HasOneAccessor`), not by pointer-only refs. Like + * `EntityRefLike` it exposes no field proxy — see the note there. + */ +export type EntityAccessorLike = EntityRefLike & { + readonly $data: unknown +} + // ============================================================================ // ENTITY FIELDS MAPPING TYPES // ============================================================================ diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 4f611776..f2b012cf 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -67,6 +67,9 @@ export type { HasManyAccessor, HasOneAccessor, EntityAccessor, + // Selection-erased views (parameter positions that accept any selection) + EntityRefLike, + EntityAccessorLike, // Type extraction helpers ExtractHasOneEntityName, ExtractHasManyEntityName, diff --git a/tests/unit/types/selectionErasure.test.ts b/tests/unit/types/selectionErasure.test.ts new file mode 100644 index 00000000..aa844428 --- /dev/null +++ b/tests/unit/types/selectionErasure.test.ts @@ -0,0 +1,279 @@ +/** + * Type-level tests for the selection-erased views `EntityRefLike` / `EntityAccessorLike`. + * + * Background: `EntityRef` / `EntityAccessor` default `TSelected` + * to `TEntity`, i.e. "fully selected". The selection payload is *covariant* + * (`readonly __selected?: TSelected` plus the `EntityFieldsRef` / `EntityFieldsAccessor` + * mapped types keyed on `keyof TSelected`), so narrowing a selection already works + * and widening one correctly does not — reading an unfetched field throws + * `UnfetchedFieldError` at runtime (see `EntityHandle.fields`). + * + * What genuinely did not work is a parameter that means "an entity ref for + * `TEntity`, whatever its selection" — in particular when the selection is still a + * free type parameter (generic helpers, repeater children). `EntityRefLike` / + * `EntityAccessorLike` are that parameter type. They deliberately expose no field + * proxy, so erasure can never turn into unchecked field access. + * + * Positive cases are written as real assignments (return-position), so they exercise + * the compiler's actual assignability check, not an approximation. + * + * Negative cases use `assertFalse>()` rather than `@ts-expect-error`: + * a suppression comment is satisfied by *any* error on the line (including an + * unrelated one, e.g. a typo), while asserting on the result of `[S] extends [T]` + * — the compiler's own assignability relation, tuple-wrapped so unions do not + * distribute — fails to compile exactly when the rejection we care about stops + * happening, and never for another reason. + */ + +import { describe, expect, test } from 'bun:test' +import type { + AnyBrand, + EntityAccessor, + EntityAccessorLike, + EntityRef, + EntityRefLike, + FieldRef, + HasManyRef, +} from '@contember/bindx' +import { createComponent, entityDef } from '@contember/bindx-react' +import type { ComponentProps } from 'react' + +// ============================================================================ +// Assertion Helpers +// ============================================================================ + +type IsAssignable = [TSource] extends [TTarget] ? true : false + +function assertTrue(): void {} +function assertFalse(): void {} + +// ============================================================================ +// Test Entity Types +// ============================================================================ + +interface Image { + id: string + url: string + alt: string + width: number +} + +interface Author { + id: string + name: string + email: string +} + +interface Website { + id: string + title: string + slug: string + image: Image | null + author: Author +} + +/** What `e => e.id().title()` selects. */ +type NarrowWebsite = { id: string; title: string } + +/** What `e => e.id().image(i => i.url().alt())` selects. */ +type WebsiteWithNarrowImage = { id: string; image: { url: string; alt: string } } + +const websiteDef = entityDef('Website') + +// ============================================================================ +// Case 1 — a narrowly-selected accessor reaching a parameter that only needs identity +// ============================================================================ + +function case1_narrowAccessorToErasedParam( + website: EntityAccessor, +): EntityAccessorLike { + return website +} + +function case1_narrowRefToErasedParam( + website: EntityRef, +): EntityRefLike { + return website +} + +/** The same, against the prop type `createComponent` actually generates. */ +const WebsiteTitle = createComponent() + .entity('website', websiteDef, e => e.id().title()) + .render(() => null) + +type WebsiteTitleProp = ComponentProps['website'] + +function case1_componentPropToErasedParam(website: WebsiteTitleProp): EntityRefLike { + return website +} + +// ============================================================================ +// Case 2 — a generic helper whose TSelected is still free +// ============================================================================ + +function case2_freeSelectionToErasedParam( + block: EntityRef, +): EntityRefLike { + return block +} + +function case2_freeEntityToErasedParam( + entity: EntityAccessor, +): EntityAccessorLike { + return entity +} + +// ============================================================================ +// Case 3 — repeater children forwarding items +// ============================================================================ + +function case3_forwardItems( + items: readonly EntityRef[], +): readonly EntityRefLike[] { + return items +} + +// ============================================================================ +// Case 4 — a has-one narrowed by a useEntity selector +// ============================================================================ + +/** `useEntity(schema.Website, …, e => e.id().image(i => i.url().alt())).image` */ +type NarrowImageFromSelection = EntityAccessor['image'] + +function case4_narrowedHasOneToErasedParam( + image: NarrowImageFromSelection, +): EntityAccessorLike { + return image +} + +// ============================================================================ +// Case 5 — entity-name erasure (hooks produce a literal name, createComponent does not) +// ============================================================================ + +function case5_literalNameToErasedParam( + website: EntityAccessor, +): EntityAccessorLike { + return website +} + +function case5_unnamedToErasedParam( + website: EntityAccessor, +): EntityAccessorLike { + return website +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('selection erasure — EntityRefLike / EntityAccessorLike', () => { + describe('accepted (previously needed `as unknown as`)', () => { + test('a narrowly-selected accessor/ref satisfies the erased view', () => { + assertTrue) => EntityAccessorLike>>() + assertTrue, EntityAccessorLike>>() + assertTrue, EntityRefLike>>() + expect(typeof case1_narrowRefToErasedParam).toBe('function') + }) + + test('a createComponent prop (TEntityName erased to `string`) satisfies the erased view', () => { + assertTrue>>() + expect(typeof case1_componentPropToErasedParam).toBe('function') + }) + + test('a free TSelected / free TEntity satisfies the erased view', () => { + // The interesting half is compile-time; see case2_* above, where TSelected / + // TEntity are genuinely unresolved type parameters. + expect(typeof case2_freeSelectionToErasedParam).toBe('function') + expect(typeof case2_freeEntityToErasedParam).toBe('function') + }) + + test('repeater items forward without a per-call-site cast', () => { + expect(typeof case3_forwardItems).toBe('function') + }) + + test('a has-one narrowed by a selector satisfies the erased entity view', () => { + assertTrue>>() + expect(typeof case4_narrowedHasOneToErasedParam).toBe('function') + }) + + test('a literal and a `string` entity name both satisfy the erased view', () => { + assertTrue, EntityAccessorLike>>() + assertTrue, EntityAccessorLike>>() + expect(typeof case5_literalNameToErasedParam).toBe('function') + expect(typeof case5_unnamedToErasedParam).toBe('function') + }) + }) + + describe('still rejected (the erasure is not a hole)', () => { + test('an unrelated entity is not accepted', () => { + assertFalse, EntityRefLike>>() + assertFalse, EntityRefLike>>() + assertFalse, EntityAccessorLike>>() + assertFalse, EntityRefLike>>() + expect(true).toBe(true) + }) + + test('a pointer-only ref is not a live accessor', () => { + assertFalse, EntityAccessorLike>>() + assertFalse, EntityAccessorLike>>() + expect(true).toBe(true) + }) + + test('a has-many list or a scalar field is not an entity', () => { + assertFalse, EntityRefLike>>() + assertFalse, EntityRefLike>>() + expect(true).toBe(true) + }) + + test('a plain object shaped like the entity is not an entity ref', () => { + assertFalse>>() + assertFalse>>() + assertFalse>>() + expect(true).toBe(true) + }) + + test('erasure is one-way — a selection is not handed back for free', () => { + assertFalse, EntityRef>>() + assertFalse, EntityRef>>() + assertFalse, EntityAccessor>>() + assertFalse, EntityAccessor>>() + expect(true).toBe(true) + }) + + test('the erased view exposes no field proxy', () => { + assertTrue>>() + assertFalse>>() + assertFalse>>() + assertFalse>>() + assertFalse>>() + expect(true).toBe(true) + }) + + test('widening a selection in place is still rejected', () => { + // This is the direction that throws UnfetchedFieldError at runtime; the + // erased views must not have made it legal. + assertFalse, EntityAccessor>>() + assertFalse, EntityRef>>() + assertFalse['image'], EntityAccessor>>() + expect(true).toBe(true) + }) + + test('the selection brand is not vacuous — a selection the source cannot supply is rejected', () => { + // `__selected` still carries weight: a target selection that asks for more + // than the source selected is refused. + assertFalse, EntityAccessor>>() + assertFalse, EntityAccessor>>() + // Not asserted here: `EntityAccessor` against `EntityRef` + // with a *free* `TSelected`. `[S] extends [T]` stays deferred (`boolean`) while + // `TSelected` is unresolved, so it cannot be asserted `false` — but the compiler + // does reject that call, which is exactly why case 2 above needs the erased view. + expect(true).toBe(true) + }) + + test('narrowing a selection in place keeps working (pre-existing covariance)', () => { + assertTrue, EntityAccessor>>() + assertTrue, EntityRef>>() + expect(true).toBe(true) + }) + }) +}) From c00c7fac6cdea6c0e6c282ba9146c2d6fb1e7e37 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 13:51:27 +0200 Subject: [PATCH 20/55] fix(bindx): announce the writes that made the store go stale Two SnapshotStore paths mutated observable state without telling anyone. The pins added in the parent commit reproduced both; this removes them and turns the pins into ordinary regression tests. createEntity registered the create-root AFTER setEntityData and setExistsOnServer had both already notified - and the root registration is precisely the write that makes the entity count as a create. So both notifications carried the pre-registration value and a save indicator built on store.subscribe + getAllDirtyEntities().length rendered 0 while the store held a dirty create. Fixed by reordering rather than adding a notification, so the cost stays at two notifications per create instead of three: setEntityData -> roots.register -> setExistsOnServer The order is forced from both sides. The root must come after setEntityData, because ReachabilityAnalyzer.walk() seeds a root only if the snapshot exists, so registering first would be inert and would leave a dangling root if the snapshot write threw. And it must come before setExistsOnServer, which then carries the final notification. Moving setExistsOnServer(false) last is observationally free: EntityMetaStore defaults an unknown key to false. roots.register bumps mutationVersion, which invalidates the reachability memo, so the subscriber woken by that last notification recomputes and sees the create. The undo journal is unaffected: the pre-image is captured inside setEntityData before any root write in either order, and the per-kind write guard already fuses snapshot, meta and roots into one `entity` kind recorded by that same call. clearAllServerErrors was simply silent where its sibling clearAllErrors notifies for the same kind of write. Both in-tree callers self-heal by ordering, so this only ever reached users through the exported action. Known and unchanged: both siblings clear relation errors under the key prefix but notify only the entity, so a subscribeToRelation consumer still sees a stale value after either call. --- packages/bindx/src/store/SnapshotStore.ts | 9 +++++- .../clearAllServerErrorsStaleness.test.tsx | 29 +++++++---------- .../createDraftRootRegistration.test.tsx | 32 ++++++++----------- .../clearAllServerErrorsNotification.test.ts | 21 +++++------- ...EntityRootRegistrationNotification.test.ts | 19 ++++------- 5 files changed, 48 insertions(+), 62 deletions(-) diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 0ac203dc..4380ad49 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -441,15 +441,21 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { const data = { ...initialData, id } this.setEntityData(entityType, id, data, false) - this.setExistsOnServer(entityType, id, false) // A freshly created entity is pending-persist by default — a root for // reachability-based create detection. It stops being a root the moment a // relation anchors it as a child (see registerParentChild). A top-level // create (, useEntityList add) is never anchored, so it stays // a root and is reported as a `create`. + // + // Registered after the snapshot exists (the reachability seed skips a root + // without one) but BEFORE the notifying setExistsOnServer, so the last + // notification of the create already carries it — a subscriber reading + // getAllDirtyEntities() would otherwise miss the new create entirely. this.roots.register(this.getEntityKey(entityType, id)) + this.setExistsOnServer(entityType, id, false) + return id } @@ -834,6 +840,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { const entityKey = this.getEntityKey(entityType, id) const keyPrefix = `${entityType}:${id}:` this.errors.clearAllServerErrors(entityKey, keyPrefix) + this.notifyEntitySubscribers(entityKey) } clearAllErrors(entityType: string, id: string): void { diff --git a/tests/react/storeNotifications/clearAllServerErrorsStaleness.test.tsx b/tests/react/storeNotifications/clearAllServerErrorsStaleness.test.tsx index 03d82499..aa4b4354 100644 --- a/tests/react/storeNotifications/clearAllServerErrorsStaleness.test.tsx +++ b/tests/react/storeNotifications/clearAllServerErrorsStaleness.test.tsx @@ -1,15 +1,10 @@ -// KNOWN-BROKEN PIN — the `test.failing` case below asserts what the user should -// see. Bun reports it as passing while `SnapshotStore.clearAllServerErrors` stays -// silent, and turns it into a failure the moment it notifies, which forces -// whoever fixes it to drop the `.failing` marker. The assertions are the real -// symptom; nothing was relaxed to fit the marker. -// -// `clearAllServerErrors` (packages/bindx/src/store/SnapshotStore.ts) clears the -// entity's server errors without calling notifyEntitySubscribers, unlike its -// sibling `clearAllErrors`. Every React consumer of errors reaches them through a -// store subscription — `useField(...).errors` here, and the framework's own -// `useEntityErrors` hook the same way — so with no notification React never -// re-renders and the component keeps painting an error the store no longer holds. +// Regression: `clearAllServerErrors` (packages/bindx/src/store/SnapshotStore.ts) +// used to clear the entity's server errors without calling notifyEntitySubscribers, +// unlike its sibling `clearAllErrors`. Every React consumer of errors reaches them +// through a store subscription — `useField(...).errors` here, and the framework's +// own `useEntityErrors` hook the same way — so React never re-rendered and the +// component kept painting an error the store no longer held. The clear now +// notifies; this test guards that the error leaves the DOM. import '../../setup' import { afterEach, describe, expect, test } from 'bun:test' import { act, cleanup, render, waitFor } from '@testing-library/react' @@ -80,7 +75,7 @@ function renderAuthorForm(onStore: (store: SnapshotStore) => void): ReturnType { - test.failing('the field error disappears from the DOM when the store clears it (known broken)', async () => { + test('the field error disappears from the DOM when the store clears it', async () => { let store!: SnapshotStore const { getByTestId } = renderAuthorForm(s => { store = s }) @@ -101,15 +96,15 @@ describe('clearAllServerErrors leaves the rendered error stale', () => { expect(store.getFieldErrors('Author', 'author-1', 'name')).toEqual([]) expect(store.hasAnyErrors('Author', 'author-1')).toBe(false) - // What the user sees: still "Name is already taken", because nothing notified. + // What the user sees: the error is gone too, because the clear notifies. expect(getByTestId('errors').textContent).toBe('no-errors') }) // Characterization of the only in-tree caller (BatchPersister, at the top of // persist: setPersisting(true) immediately followed by clearAllServerErrors). - // The preceding notification is what re-renders the subtree; React re-reads the - // errors at render time, by which point the silent clear has already happened. - // This is why the framework's own persist path does not show the staleness. + // The preceding notification re-renders the subtree on its own, which is why the + // framework's own persist path never showed the staleness even while the clear + // was silent. The sequence must keep working now that the clear notifies too. test('characterization: a notifying write immediately before the clear hides the bug', async () => { let store!: SnapshotStore const { getByTestId } = renderAuthorForm(s => { store = s }) diff --git a/tests/react/storeNotifications/createDraftRootRegistration.test.tsx b/tests/react/storeNotifications/createDraftRootRegistration.test.tsx index 6449c08e..2d03e833 100644 --- a/tests/react/storeNotifications/createDraftRootRegistration.test.tsx +++ b/tests/react/storeNotifications/createDraftRootRegistration.test.tsx @@ -1,19 +1,13 @@ -// Two halves of the create-draft lifecycle, one broken and one sound. +// Both halves of the create-draft lifecycle, at the level the user sees. // -// KNOWN-BROKEN PIN (first test, `test.failing`): it asserts what the user should -// see. Bun reports it as passing while the bug is present and turns it into a -// failure the moment the root registration starts notifying, which forces whoever -// fixes it to drop the `.failing` marker. The assertions are the real symptom; -// nothing was relaxed to fit the marker. `createEntity` writes in -// three steps — setEntityData (notifies), setExistsOnServer (notifies), and -// finally `roots.register()`, which is silent. A created entity only becomes a -// `create` in getAllDirtyEntities() at that third step, so both notifications -// carry the OLD value (0 dirty) and the value that matters is never announced. -// A save indicator built on a global store subscription therefore shows "no -// unsaved changes" while the store holds an unsaved draft, until some unrelated -// write happens to notify. +// REGRESSION (first test): `createEntity` used to register the create-root after +// both of its notifying writes, and that registration is what makes the entity a +// `create` in getAllDirtyEntities(). A save indicator built on a global store +// subscription therefore showed "no unsaved changes" while the store held an +// unsaved draft. The root is now registered before the final setExistsOnServer, so +// the notification that closes the create carries the right count. // -// CHARACTERIZATION (second test, PASSES): the unmount half is sound. +// CHARACTERIZATION (second test): the unmount half is sound. // `unregisterRootEntity` was investigated as a suspected staleness bug and is // NOT one: it is equally silent, but its callers (Entity.tsx's cleanup, // useEntityList.ts's draft cleanup) run `sweepUnreachableCreated()` on the next @@ -93,7 +87,7 @@ function Harness({ adapter, showDraft, onStore }: HarnessProps): React.ReactElem } describe('create-draft root registration and cleanup', () => { - test.failing('the save indicator counts the draft the store already holds (known broken)', async () => { + test('the save indicator counts the draft the store already holds', async () => { const adapter = new MockAdapter({}, { delay: 0 }) let store!: SnapshotStore @@ -108,8 +102,8 @@ describe('create-draft root registration and cleanup', () => { // Truth in the store: one unsaved create. expect(store.getAllDirtyEntities()).toHaveLength(1) - // What the user sees: still "0 unsaved changes", because the root - // registration that made it a create notified nobody. + // What the user sees: the same count, because the root registration now + // lands before the notification that announces the create. expect(getByTestId('dirty-count').textContent).toBe('1') }) @@ -122,8 +116,8 @@ describe('create-draft root registration and cleanup', () => { ) await waitFor(() => expect(getByTestId('draft')).toBeTruthy()) - // Sync the indicator past the missing create notification (the bug covered by - // the test above) so this one measures the unmount path alone. + // Redundant now that the create notifies — kept so this test measures the + // unmount path alone, whatever the create path does. act(() => { store.notify() }) expect(getByTestId('dirty-count').textContent).toBe('1') diff --git a/tests/unit/store/clearAllServerErrorsNotification.test.ts b/tests/unit/store/clearAllServerErrorsNotification.test.ts index 6f034799..52c92741 100644 --- a/tests/unit/store/clearAllServerErrorsNotification.test.ts +++ b/tests/unit/store/clearAllServerErrorsNotification.test.ts @@ -1,14 +1,9 @@ -// KNOWN-BROKEN PIN — the `test.failing` cases below assert the behaviour the -// store SHOULD have. Bun reports them as passing while the bug is present, and -// turns them into a failure the moment it is fixed, which forces whoever fixes it -// to drop the `.failing` marker. The assertions are the real symptom; nothing was -// relaxed to fit the marker. -// -// `SnapshotStore.clearAllServerErrors` mutates observable state (getFieldErrors / -// getEntityErrors / hasAnyErrors) but never calls notifyEntitySubscribers, while -// its sibling `clearAllErrors` does — an asymmetry with no stated reason. A -// subscriber therefore keeps reading the pre-clear error list until some unrelated -// write happens to notify. +// Regression: `SnapshotStore.clearAllServerErrors` mutated observable state +// (getFieldErrors / getEntityErrors / hasAnyErrors) without calling +// notifyEntitySubscribers, while its sibling `clearAllErrors` did. A subscriber +// therefore kept reading the pre-clear error list until some unrelated write +// happened to notify. The clear now notifies like its sibling; these tests guard +// that symmetry. // // The React-level consequence is in tests/react/storeNotifications/. import { describe, test, expect, beforeEach } from 'bun:test' @@ -27,7 +22,7 @@ describe('clearAllServerErrors / clearAllErrors notification symmetry', () => { const renderedErrors = (): string => store.getFieldErrors('Author', 'author-1', 'name').map(e => e.message).join('|') - test.failing('clearAllServerErrors notifies the entity subscriber (known broken)', () => { + test('clearAllServerErrors notifies the entity subscriber', () => { let notifications = 0 const unsubscribe = store.subscribeToEntity('Author', 'author-1', () => { notifications++ }) @@ -45,7 +40,7 @@ describe('clearAllServerErrors / clearAllErrors notification symmetry', () => { unsubscribe() }) - test.failing('clearAllServerErrors notifies global subscribers (known broken)', () => { + test('clearAllServerErrors notifies global subscribers', () => { let notifications = 0 const unsubscribe = store.subscribe(() => { notifications++ }) const versionBefore = store.getVersion() diff --git a/tests/unit/store/createEntityRootRegistrationNotification.test.ts b/tests/unit/store/createEntityRootRegistrationNotification.test.ts index a2c5f56b..0b3499a8 100644 --- a/tests/unit/store/createEntityRootRegistrationNotification.test.ts +++ b/tests/unit/store/createEntityRootRegistrationNotification.test.ts @@ -1,14 +1,9 @@ -// KNOWN-BROKEN PIN — the `test.failing` case below asserts the behaviour the -// store SHOULD have. Bun reports it as passing while the bug is present, and -// turns it into a failure the moment the root registration starts notifying, -// which forces whoever fixes it to drop the `.failing` marker. The assertions are -// the real symptom; nothing was relaxed to fit the marker. -// -// `SnapshotStore.createEntity` writes in three steps: setEntityData (notifies), -// setExistsOnServer (notifies), then `roots.register()` — which is silent. The -// entity only becomes a `create` in getAllDirtyEntities() at that third step, so -// both notifications carry the pre-registration value and the value that matters -// is never announced. A subscriber keeps reading "nothing to save". +// Regression: `SnapshotStore.createEntity` used to register the create-root AFTER +// both of its notifying writes (setEntityData, setExistsOnServer). The root +// registration is what makes the entity a `create` in getAllDirtyEntities(), so +// every notification carried the pre-registration value and a subscriber kept +// reading "nothing to save". The root is now registered before the final +// setExistsOnServer, so the last notification already carries it. // // The user-visible half of this is in // tests/react/storeNotifications/createDraftRootRegistration.test.tsx. @@ -22,7 +17,7 @@ describe('createEntity root registration notification', () => { store = new SnapshotStore() }) - test.failing('the dirty count a subscriber last saw includes the new create (known broken)', () => { + test('the dirty count a subscriber last saw includes the new create', () => { // What a save indicator renders on every notification. const seen: number[] = [] const unsubscribe = store.subscribe(() => { seen.push(store.getAllDirtyEntities().length) }) From 418aa9bda132d289711b0f03ff4e8a1f5e117134 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 14:40:24 +0200 Subject: [PATCH 21/55] chore: remove stale TypeScript lint directives --- packages/bindx-dataview/src/HasManyDataGrid.tsx | 1 - packages/bindx-dataview/src/columnLeaf.ts | 4 ---- packages/bindx-dataview/src/markers.ts | 1 - packages/bindx-dataview/src/useDataGridSetup.ts | 2 -- packages/bindx-react/src/hooks/useEntityCount.ts | 1 - packages/bindx-react/src/hooks/useEntityList.ts | 2 -- packages/bindx-react/src/jsx/componentBuilder.ts | 2 -- packages/bindx-react/src/jsx/componentBuilder.types.ts | 2 -- packages/bindx-react/src/jsx/componentBuilderCompat.ts | 2 -- packages/bindx-react/src/jsx/standaloneCreateComponent.ts | 2 -- packages/bindx-ui/src/datagrid/column-header.tsx | 1 - packages/bindx-ui/src/defaults/BindxUIDefaults.tsx | 1 - packages/bindx-ui/src/utils/uic.tsx | 3 --- tests/react/jsx/Switch.test.tsx | 1 - 14 files changed, 25 deletions(-) diff --git a/packages/bindx-dataview/src/HasManyDataGrid.tsx b/packages/bindx-dataview/src/HasManyDataGrid.tsx index 594c8b11..a245eb09 100644 --- a/packages/bindx-dataview/src/HasManyDataGrid.tsx +++ b/packages/bindx-dataview/src/HasManyDataGrid.tsx @@ -49,7 +49,6 @@ import { useDataGridSetup } from './useDataGridSetup.js' export interface HasManyDataGridProps { /** Has-many relation field from parent entity */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any field: HasManyRef /** Children render function: receives entity proxy `it`, returns column markers + layout */ children: (it: EntityAccessor) => ReactNode diff --git a/packages/bindx-dataview/src/columnLeaf.ts b/packages/bindx-dataview/src/columnLeaf.ts index fd4e8582..3077b657 100644 --- a/packages/bindx-dataview/src/columnLeaf.ts +++ b/packages/bindx-dataview/src/columnLeaf.ts @@ -97,10 +97,8 @@ export interface ChildrenAnalysisResult { */ export function analyzeChildren( elements: React.ReactNode, - // eslint-disable-next-line @typescript-eslint/no-explicit-any markerTypes: ReadonlySet>, ): ChildrenAnalysisResult { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const collected = new Map, unknown[]>() for (const type of markerTypes) { collected.set(type, []) @@ -121,9 +119,7 @@ export function analyzeChildren( function walkTree( elements: React.ReactNode, - // eslint-disable-next-line @typescript-eslint/no-explicit-any markerTypes: ReadonlySet>, - // eslint-disable-next-line @typescript-eslint/no-explicit-any collected: Map, unknown[]>, ): void { React.Children.forEach(elements, (child) => { diff --git a/packages/bindx-dataview/src/markers.ts b/packages/bindx-dataview/src/markers.ts index e6f14e32..c294ae83 100644 --- a/packages/bindx-dataview/src/markers.ts +++ b/packages/bindx-dataview/src/markers.ts @@ -8,7 +8,6 @@ import type React from 'react' import type { ReactNode } from 'react' -// eslint-disable-next-line @typescript-eslint/no-unused-vars import type { EntityAccessor } from '@contember/bindx' // ============================================================================ diff --git a/packages/bindx-dataview/src/useDataGridSetup.ts b/packages/bindx-dataview/src/useDataGridSetup.ts index c415864a..a81ca6b2 100644 --- a/packages/bindx-dataview/src/useDataGridSetup.ts +++ b/packages/bindx-dataview/src/useDataGridSetup.ts @@ -31,14 +31,12 @@ import { DataViewElement, type DataViewElementProps } from './selectionComponent export const QUERY_FILTER_NAME = '__query' -// eslint-disable-next-line @typescript-eslint/no-explicit-any const MARKER_TYPES: ReadonlySet> = new Set([ ColumnLeaf, DataGridToolbarContent, DataGridLayout, ]) -// eslint-disable-next-line @typescript-eslint/no-explicit-any const ELEMENT_MARKER_TYPES: ReadonlySet> = new Set([ DataViewElement, ]) diff --git a/packages/bindx-react/src/hooks/useEntityCount.ts b/packages/bindx-react/src/hooks/useEntityCount.ts index 1c5ad18d..8773ad11 100644 --- a/packages/bindx-react/src/hooks/useEntityCount.ts +++ b/packages/bindx-react/src/hooks/useEntityCount.ts @@ -38,7 +38,6 @@ export interface UseEntityCountResult { * recompute the count. Batched into the same request as any sibling list query. */ export function useEntityCount( - // eslint-disable-next-line @typescript-eslint/no-explicit-any entity: EntityDef, options: UseEntityCountOptions = {}, ): UseEntityCountResult { diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index eb7eddcc..497ae800 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -163,11 +163,9 @@ export function useEntityList( // Implementation // ============================================================================ -// eslint-disable-next-line @typescript-eslint/no-explicit-any export function useEntityList( entity: EntityDef, options: UseEntityListOptions, - // eslint-disable-next-line @typescript-eslint/no-explicit-any definer?: SelectionInput, ): UseEntityListResult { const schemaRegistry = useSchemaRegistry() diff --git a/packages/bindx-react/src/jsx/componentBuilder.ts b/packages/bindx-react/src/jsx/componentBuilder.ts index cf761b19..4a51a357 100644 --- a/packages/bindx-react/src/jsx/componentBuilder.ts +++ b/packages/bindx-react/src/jsx/componentBuilder.ts @@ -159,13 +159,11 @@ export class ComponentBuilderImpl< export function createComponentBuilder( schemaRegistry: SchemaRegistry> | null, roles: readonly string[] = [], -// eslint-disable-next-line @typescript-eslint/ban-types ): ComponentBuilder> { return new ComponentBuilderImpl( schemaRegistry, new Map(), roles, - // eslint-disable-next-line @typescript-eslint/ban-types ) as unknown as ComponentBuilder> } diff --git a/packages/bindx-react/src/jsx/componentBuilder.types.ts b/packages/bindx-react/src/jsx/componentBuilder.types.ts index e58313d0..615a5286 100644 --- a/packages/bindx-react/src/jsx/componentBuilder.types.ts +++ b/packages/bindx-react/src/jsx/componentBuilder.types.ts @@ -103,7 +103,6 @@ export type AnyEntityPropConfig = EntityPropConfig | InterfaceEntityPropConfig * Grows as you chain builder methods. */ export interface ComponentBuilderState< - // eslint-disable-next-line @typescript-eslint/ban-types TEntityProps extends Record = {}, TScalarProps extends object = object, TRoles extends readonly string[] = readonly string[], @@ -492,7 +491,6 @@ export interface ComponentBuilder< */ export type InitialBuilderState< TRoles extends readonly string[] = readonly string[], - // eslint-disable-next-line @typescript-eslint/ban-types > = ComponentBuilderState<{}, object, TRoles> /** diff --git a/packages/bindx-react/src/jsx/componentBuilderCompat.ts b/packages/bindx-react/src/jsx/componentBuilderCompat.ts index bcdab472..c2b1526e 100644 --- a/packages/bindx-react/src/jsx/componentBuilderCompat.ts +++ b/packages/bindx-react/src/jsx/componentBuilderCompat.ts @@ -13,7 +13,6 @@ import { BINDX_COMPONENT } from './types.js' * @internal */ export function assignFragmentProperties( - // eslint-disable-next-line @typescript-eslint/no-explicit-any component: ComponentType, selectionsMap: Map, ): void { @@ -28,7 +27,6 @@ export function assignFragmentProperties( * @internal */ export function assignComponentMarkers( - // eslint-disable-next-line @typescript-eslint/no-explicit-any component: ComponentType, selectionsMap: Map, ): void { diff --git a/packages/bindx-react/src/jsx/standaloneCreateComponent.ts b/packages/bindx-react/src/jsx/standaloneCreateComponent.ts index 2666641f..9ae760a9 100644 --- a/packages/bindx-react/src/jsx/standaloneCreateComponent.ts +++ b/packages/bindx-react/src/jsx/standaloneCreateComponent.ts @@ -32,11 +32,9 @@ import { createComponentBuilder } from './componentBuilder.js' * )) * ``` */ -// eslint-disable-next-line @typescript-eslint/ban-types export function createComponent(): ComponentBuilder> export function createComponent( options: CreateComponentOptions, -// eslint-disable-next-line @typescript-eslint/ban-types ): ComponentBuilder> export function createComponent(options?: CreateComponentOptions): ComponentBuilder { const roles = options?.roles ?? [] diff --git a/packages/bindx-ui/src/datagrid/column-header.tsx b/packages/bindx-ui/src/datagrid/column-header.tsx index 2ffd6e20..b53badbf 100644 --- a/packages/bindx-ui/src/datagrid/column-header.tsx +++ b/packages/bindx-ui/src/datagrid/column-header.tsx @@ -17,7 +17,6 @@ import { Popover, PopoverContent, PopoverTrigger } from '#bindx-ui/ui/popover' export interface DataGridColumnHeaderUIProps { children: ReactNode - // eslint-disable-next-line @typescript-eslint/no-explicit-any sortingField?: FieldRef hidingName?: string filterName?: string diff --git a/packages/bindx-ui/src/defaults/BindxUIDefaults.tsx b/packages/bindx-ui/src/defaults/BindxUIDefaults.tsx index b191c363..27cbcd47 100644 --- a/packages/bindx-ui/src/defaults/BindxUIDefaults.tsx +++ b/packages/bindx-ui/src/defaults/BindxUIDefaults.tsx @@ -12,7 +12,6 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react' * } * ``` */ -// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface BindxUIDefaultsMap {} type DefaultsRecord = { diff --git a/packages/bindx-ui/src/utils/uic.tsx b/packages/bindx-ui/src/utils/uic.tsx index dc0fbd41..5e3dfde2 100644 --- a/packages/bindx-ui/src/utils/uic.tsx +++ b/packages/bindx-ui/src/utils/uic.tsx @@ -41,11 +41,9 @@ function dataAttribute(value: unknown): '' | undefined { return value ? '' : undefined } -// eslint-disable-next-line @typescript-eslint/no-explicit-any export const uic = ( Component: El, config: UicConfig, -// eslint-disable-next-line @typescript-eslint/no-explicit-any ): any => { const cls = cva(config?.baseClass as string, { variants: config?.variants as Record> | undefined, @@ -55,7 +53,6 @@ export const uic = ((props, ref) => { const { className: classNameProp, children: childrenBase, ...rest } = props diff --git a/tests/react/jsx/Switch.test.tsx b/tests/react/jsx/Switch.test.tsx index 469182be..e9fa2432 100644 --- a/tests/react/jsx/Switch.test.tsx +++ b/tests/react/jsx/Switch.test.tsx @@ -27,7 +27,6 @@ function getByTestId(container: Element, testId: string): Element { return el } -// eslint-disable-next-line @typescript-eslint/no-explicit-any type ArticleAccessor = any function renderWithArticle( From 53136c79a1673f77aeacffb218921a0abbecd39d Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 14:40:24 +0200 Subject: [PATCH 22/55] fix(bindx-react): keep JSX component hook order stable --- packages/bindx-react/src/jsx/componentFactory.ts | 6 ++---- packages/bindx-react/src/jsx/components/Attribute.tsx | 4 ++-- packages/bindx-react/src/jsx/components/Field.tsx | 6 ++---- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/bindx-react/src/jsx/componentFactory.ts b/packages/bindx-react/src/jsx/componentFactory.ts index 7f031cb9..52dd1e8a 100644 --- a/packages/bindx-react/src/jsx/componentFactory.ts +++ b/packages/bindx-react/src/jsx/componentFactory.ts @@ -145,10 +145,8 @@ export function buildComponent( function ComponentImpl(props: TProps): ReactNode { ensureImplicitCollected() - // Subscribe every entity ref prop (stable hook count — entityPropNames is fixed) - const renderProps = entityPropNames.length > 0 - ? useRenderProps(props, entityPropNames) - : props + // Subscribe every entity ref prop; an empty fixed list is a no-op. + const renderProps = useRenderProps(props, entityPropNames) // Evaluate condition at runtime if (conditionFn) { diff --git a/packages/bindx-react/src/jsx/components/Attribute.tsx b/packages/bindx-react/src/jsx/components/Attribute.tsx index 056eeb31..6c8311d5 100644 --- a/packages/bindx-react/src/jsx/components/Attribute.tsx +++ b/packages/bindx-react/src/jsx/components/Attribute.tsx @@ -34,11 +34,11 @@ export interface AttributeProps { * ``` */ function AttributeImpl({ field, format, children }: AttributeProps): ReactElement | null { - if (field === undefined || field === null) { + const accessor = useField(field ?? null) + if (accessor === null) { return children } - const accessor = useField(field) const extraProps = { ...format(accessor), ...(isDevAnnotationsEnabled() ? { 'data-field': field[FIELD_REF_META]?.fieldName } : {}), diff --git a/packages/bindx-react/src/jsx/components/Field.tsx b/packages/bindx-react/src/jsx/components/Field.tsx index df2c17e8..f868d176 100644 --- a/packages/bindx-react/src/jsx/components/Field.tsx +++ b/packages/bindx-react/src/jsx/components/Field.tsx @@ -22,13 +22,11 @@ import { annotateElement, isDevAnnotationsEnabled } from '../devAnnotations.js' * ``` */ function FieldImpl({ field, children, format }: FieldProps): ReactElement | null { - // Handle undefined field (e.g., when accessing field on disconnected has-one relation) - if (field === undefined || field === null) { + const accessor = useField(field ?? null) + if (accessor === null) { return null } - // useField() subscribes to store and returns FieldAccessor with .value access - const accessor = useField(field) const fieldName = field[FIELD_REF_META]?.fieldName if (children) { From 329d7750b72cc0c347848fa1065a6531982592e6 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 14:42:46 +0200 Subject: [PATCH 23/55] ci: enforce React hooks lint --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ae2d1ba..fbb62a4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,15 @@ on: pull_request: jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run lint + typecheck: name: Typecheck runs-on: ubuntu-latest From 7447dff8ae844dc6ba01ebf576d0c7cdfa1a9511 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 14:43:31 +0200 Subject: [PATCH 24/55] docs: generalize test scenario comments --- tests/nestedHasManyCreate.test.ts | 2 +- tests/placeholderHasManyRoundtrip.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/nestedHasManyCreate.test.ts b/tests/nestedHasManyCreate.test.ts index 478577d3..cef31374 100644 --- a/tests/nestedHasManyCreate.test.ts +++ b/tests/nestedHasManyCreate.test.ts @@ -11,7 +11,7 @@ import { } from '@contember/bindx' /** - * Schema modeling the exact pattern from NPI: + * Schema modeling a deeply nested consumer pattern: * Program → Approval (hasOne) → Round (hasMany) → Review (hasMany) * Review → Guarantor (hasOne) * diff --git a/tests/placeholderHasManyRoundtrip.test.ts b/tests/placeholderHasManyRoundtrip.test.ts index c4c45ad4..e5aeeecf 100644 --- a/tests/placeholderHasManyRoundtrip.test.ts +++ b/tests/placeholderHasManyRoundtrip.test.ts @@ -11,7 +11,7 @@ import { } from '@contember/bindx' import { createTestDispatcher } from './unit/shared/unitTestHelpers.js' -// NPI shape: Post → content (hasOne Content) → references (hasMany ContentReference) +// Consumer shape: Post → content (hasOne Content) → references (hasMany ContentReference) interface TestPost { id: string; title: string; content: TestContent } interface TestContent { id: string; data: string; references: TestRef[] } interface TestRef { id: string; type: string } From f979ac6d7fdcd2166becd9dcb776bbc1881c4759 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 14:58:34 +0200 Subject: [PATCH 25/55] fix(bindx-dataview): keep optional filter hooks stable --- .../bindx-dataview/src/filterComponents.tsx | 35 ++-- .../bindx-ui/src/datagrid/column-header.tsx | 14 +- .../bindx-ui/src/datagrid/filters/common.tsx | 7 +- .../bindx-ui/src/datagrid/filters/mobile.tsx | 7 +- tests/react/dataview/filterHookOrder.test.tsx | 158 ++++++++++++++++++ 5 files changed, 189 insertions(+), 32 deletions(-) create mode 100644 tests/react/dataview/filterHookOrder.test.tsx diff --git a/packages/bindx-dataview/src/filterComponents.tsx b/packages/bindx-dataview/src/filterComponents.tsx index d9f7e4e9..295d9fbb 100644 --- a/packages/bindx-dataview/src/filterComponents.tsx +++ b/packages/bindx-dataview/src/filterComponents.tsx @@ -62,10 +62,9 @@ export { } from './filterHooks.js' export type { UseDataViewFilterResult as DataViewFilterState } from './filterHooks.js' -function resolveFilterName(name: string | undefined): string { - if (name !== undefined) return name - // eslint-disable-next-line react-hooks/rules-of-hooks +function useResolvedFilterName(name: string | undefined): string { const contextName = useOptionalDataViewFilterName() + if (name !== undefined) return name if (contextName !== null) return contextName throw new Error('Filter name must be provided via `name` prop or filter scope context') } @@ -86,7 +85,7 @@ export interface DataViewTextFilterInputProps { export const DataViewTextFilterInput = forwardRef( ({ name, debounceMs, ...props }, ref) => { - name = resolveFilterName(name) + name = useResolvedFilterName(name) return ( ( ({ name, mode, ...props }, ref) => { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const [active, cb] = useDataViewTextFilterMatchMode(name, mode) const { onClick, ...otherProps } = props as React.ButtonHTMLAttributes @@ -145,7 +144,7 @@ const TEXT_MODE_LABELS: Record = { export function DataViewTextFilterMatchModeLabel({ name, }: DataViewTextFilterMatchModeLabelProps): ReactElement { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const [state] = useDataViewFilter(name) const mode = state?.mode ?? 'contains' return <>{TEXT_MODE_LABELS[mode]} @@ -162,7 +161,7 @@ export interface DataViewTextFilterResetTriggerProps { export const DataViewTextFilterResetTrigger = forwardRef( ({ name, ...props }, ref) => { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const [state, setFilter] = useDataViewFilter(name) const hasQuery = (state?.query?.length ?? 0) > 0 @@ -202,7 +201,7 @@ export interface DataViewNumberFilterInputProps { export const DataViewNumberFilterInput = forwardRef( ({ name, type, allowFloat, ...props }, ref) => { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const [artifact, setFilter] = useDataViewFilter(name) const min = artifact?.min ?? null const max = artifact?.max ?? null @@ -248,7 +247,7 @@ export interface DataViewDateFilterInputProps { export const DataViewDateFilterInput = forwardRef( ({ name, type, ...props }, ref) => { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const [artifact, setFilter] = useDataViewFilter(name) const start = artifact?.start ?? null const end = artifact?.end ?? null @@ -289,7 +288,7 @@ export interface DataViewBooleanFilterTriggerProps { export const DataViewBooleanFilterTrigger = forwardRef( ({ name, action = 'include', value, ...props }, ref) => { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const [current, setFilter] = useDataViewBooleanFilter(name, value) const toggleFilter = useCallback((): void => { @@ -331,7 +330,7 @@ export interface DataViewEnumFilterTriggerProps { export const DataViewEnumFilterTrigger = forwardRef( ({ name, action = 'include', value, ...props }, ref) => { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const [current, setFilter] = useDataViewEnumFilter(name, value) const toggleFilter = useCallback((): void => { @@ -377,7 +376,7 @@ export function DataViewEnumFilterState({ value, children, }: DataViewEnumFilterStateProps): ReactElement { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const [artifact] = useDataViewFilter(name) const includedValues = artifact?.values ?? [] const excludedValues = artifact?.notValues ?? [] @@ -401,7 +400,7 @@ export interface DataViewRelationFilterTriggerProps { export const DataViewRelationFilterTrigger = forwardRef( ({ name, action = 'include', id, ...props }, ref) => { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const [current, setFilter] = useDataViewRelationFilter(name, id) const toggleFilter = useCallback((): void => { @@ -447,7 +446,7 @@ export function DataViewRelationFilterState({ id, children, }: DataViewRelationFilterStateProps): ReactElement { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const [artifact] = useDataViewFilter(name) const includedIds = artifact?.id ?? [] const excludedIds = artifact?.notId ?? [] @@ -470,7 +469,7 @@ export interface DataViewNullFilterTriggerProps { export const DataViewNullFilterTrigger = forwardRef( ({ name, action, ...props }, ref) => { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const [current, setFilter] = useDataViewNullFilter(name) const toggleFilter = useCallback((): void => { @@ -512,7 +511,7 @@ export interface DataViewFilterResetTriggerProps { export const DataViewFilterResetTrigger = forwardRef( ({ name, ...props }, ref) => { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const { filtering } = useDataViewContext() const filter = filtering.filters.get(name) const isActive = filter ? filter.handler.isActive(filter.artifact) : false @@ -549,7 +548,7 @@ export interface DataViewDateFilterResetTriggerProps { export const DataViewDateFilterResetTrigger = forwardRef( ({ name, type, ...props }, ref) => { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const [state, setFilter] = useDataViewFilter(name) const hasValue = type === 'start' ? (state?.start ?? null) !== null @@ -593,7 +592,7 @@ export interface DataViewNumberFilterResetTriggerProps { export const DataViewNumberFilterResetTrigger = forwardRef( ({ name, ...props }, ref) => { - name = resolveFilterName(name) + name = useResolvedFilterName(name) const [state, setFilter] = useDataViewFilter(name) const hasValue = (state?.min ?? null) !== null || (state?.max ?? null) !== null diff --git a/packages/bindx-ui/src/datagrid/column-header.tsx b/packages/bindx-ui/src/datagrid/column-header.tsx index b53badbf..bdc49fd2 100644 --- a/packages/bindx-ui/src/datagrid/column-header.tsx +++ b/packages/bindx-ui/src/datagrid/column-header.tsx @@ -24,6 +24,11 @@ export interface DataGridColumnHeaderUIProps { className?: string } +function DataGridColumnFilterIcon({ filterName }: { filterName: string }): ReactElement | null { + const [, , { isEmpty }] = useDataViewFilter(filterName) + return isEmpty ? null : +} + export function DataGridColumnHeaderUI({ sortingField, hidingName, @@ -32,13 +37,6 @@ export function DataGridColumnHeaderUI({ filterName, className, }: DataGridColumnHeaderUIProps): ReactElement { - let hasFilter = false - if (filterName) { - // eslint-disable-next-line react-hooks/rules-of-hooks - const [, , { isEmpty }] = useDataViewFilter(filterName) - hasFilter = !isEmpty - } - if (!sortingField && !hidingName && !filter) { return
{children}
} @@ -61,7 +59,7 @@ export function DataGridColumnHeaderUI({ none={} /> )} - {hasFilter && } + {filterName && } diff --git a/packages/bindx-ui/src/datagrid/filters/common.tsx b/packages/bindx-ui/src/datagrid/filters/common.tsx index 1baa1125..abf1ef56 100644 --- a/packages/bindx-ui/src/datagrid/filters/common.tsx +++ b/packages/bindx-ui/src/datagrid/filters/common.tsx @@ -2,13 +2,14 @@ * Null filter control — shared across all filter types. */ import { type ReactElement, useCallback } from 'react' -import { useDataViewFilterName, useDataViewNullFilter } from '@contember/bindx-dataview' +import { useDataViewNullFilter, useOptionalDataViewFilterName } from '@contember/bindx-dataview' import { DataGridFilterSelectItemUI } from '#bindx-ui/datagrid/ui' import { dict } from '../../dict.js' export const DataGridNullFilter = ({ name }: { name?: string }): ReactElement => { - // eslint-disable-next-line react-hooks/rules-of-hooks - name ??= useDataViewFilterName() + const contextName = useOptionalDataViewFilterName() + name ??= contextName ?? undefined + if (name === undefined) throw new Error('DataGridNullFilter requires a name prop or filter scope context') const [nullFilter, setNullFilter] = useDataViewNullFilter(name) const toggleExcludeNull = useCallback(() => setNullFilter('toggleExclude'), [setNullFilter]) const toggleIncludeNull = useCallback(() => setNullFilter('toggleInclude'), [setNullFilter]) diff --git a/packages/bindx-ui/src/datagrid/filters/mobile.tsx b/packages/bindx-ui/src/datagrid/filters/mobile.tsx index caa43d13..473a0120 100644 --- a/packages/bindx-ui/src/datagrid/filters/mobile.tsx +++ b/packages/bindx-ui/src/datagrid/filters/mobile.tsx @@ -2,13 +2,14 @@ * Mobile filter hiding — hides inactive filters on mobile screens. */ import { createContext, type ReactElement, type ReactNode, useContext } from 'react' -import { useDataViewFilter, useDataViewFilterName } from '@contember/bindx-dataview' +import { useDataViewFilter, useOptionalDataViewFilterName } from '@contember/bindx-dataview' export const DataGridShowFiltersContext = createContext(true) export const DataGridFilterMobileHiding = ({ name, children }: { name?: string; children: ReactNode }): ReactElement => { - // eslint-disable-next-line react-hooks/rules-of-hooks - name ??= useDataViewFilterName() + const contextName = useOptionalDataViewFilterName() + name ??= contextName ?? undefined + if (name === undefined) throw new Error('DataGridFilterMobileHiding requires a name prop or filter scope context') const [, , { isEmpty }] = useDataViewFilter(name) const isActive = !isEmpty const alwaysShow = useContext(DataGridShowFiltersContext) diff --git a/tests/react/dataview/filterHookOrder.test.tsx b/tests/react/dataview/filterHookOrder.test.tsx new file mode 100644 index 00000000..09b34edb --- /dev/null +++ b/tests/react/dataview/filterHookOrder.test.tsx @@ -0,0 +1,158 @@ +import '../../setup' +import { afterEach, describe, expect, spyOn, test } from 'bun:test' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import React, { type ReactElement } from 'react' +import { + BindxProvider, + MockAdapter, + defineSchema, + entityDef, + scalar, +} from '@contember/bindx-react' +import { + DataGrid, + DataViewFilterScope, + DataViewTextFilterMatchModeLabel, + useDataViewContext, +} from '@contember/bindx-dataview' +import { + DataGridColumnHeaderUI, + DataGridNullFilter, +} from '@contember/bindx-ui' +import { DataGridFilterMobileHiding } from '../../../packages/bindx-ui/src/datagrid/filters/mobile.js' + +afterEach(() => { + cleanup() +}) + +interface Article { + id: string + title: string +} + +const localSchema = defineSchema<{ Article: Article }>({ + entities: { + Article: { + fields: { + id: scalar(), + title: scalar(), + }, + }, + }, +}) + +const articleEntity = entityDef
('Article') +const adapter = new MockAdapter({ Article: {} }, { delay: 0 }) + +function LoaderState(): ReactElement { + const { loaderState } = useDataViewContext() + return {loaderState} +} + +function Harness({ children }: { children: ReactElement }): ReactElement { + return ( + + + {() => <>{children}} + + + ) +} + +describe('filter components with optional names', () => { + test('composable filter components can switch from a context name to an explicit name', async () => { + const renderHarness = (explicit: boolean): ReactElement => ( + + + + + + ) + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}) + try { + const { container, getByTestId, rerender } = render(renderHarness(false)) + await waitFor(() => expect(getByTestId('loader-state').textContent).toBe('loaded')) + expect(container.textContent).toContain('Contains') + await act(async () => rerender(renderHarness(true))) + expect(container.textContent).toContain('Contains') + expect(errorSpy.mock.calls.some(call => call.some(value => + typeof value === 'string' && value.includes('change in the order of Hooks'), + ))).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) + + test('null filters can switch from a context name to an explicit name', async () => { + const renderHarness = (explicit: boolean): ReactElement => ( + + + + + + ) + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}) + try { + const { getByTestId, rerender } = render(renderHarness(false)) + await waitFor(() => expect(getByTestId('loader-state').textContent).toBe('loaded')) + await act(async () => rerender(renderHarness(true))) + expect(errorSpy.mock.calls.some(call => call.some(value => + typeof value === 'string' && value.includes('change in the order of Hooks'), + ))).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) + + test('mobile filter wrappers can switch from a context name to an explicit name', async () => { + const renderHarness = (explicit: boolean): ReactElement => ( + + + + Filter + + + + ) + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}) + try { + const { container, getByTestId, rerender } = render(renderHarness(false)) + await waitFor(() => expect(getByTestId('loader-state').textContent).toBe('loaded')) + expect(container.textContent).toContain('Filter') + await act(async () => rerender(renderHarness(true))) + expect(container.textContent).toContain('Filter') + expect(errorSpy.mock.calls.some(call => call.some(value => + typeof value === 'string' && value.includes('change in the order of Hooks'), + ))).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) + + test('column headers can add a filter name after the first render', async () => { + const renderHarness = (filtered: boolean): ReactElement => ( + + Filter}> + Title + + + ) + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}) + try { + const { container, getByTestId, rerender } = render(renderHarness(false)) + await waitFor(() => expect(getByTestId('loader-state').textContent).toBe('loaded')) + expect(container.textContent).toContain('Title') + await act(async () => rerender(renderHarness(true))) + expect(container.textContent).toContain('Title') + expect(errorSpy.mock.calls.some(call => call.some(value => + typeof value === 'string' && value.includes('change in the order of Hooks'), + ))).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) +}) From 51a658074c60c6dab7da52be414af1bc08cbf579 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 14:58:38 +0200 Subject: [PATCH 26/55] fix(bindx): propagate nested relation notifications --- .../src/hooks/ItemAccessorCache.ts | 10 ++---- .../bindx/src/store/SubscriptionManager.ts | 31 +++++++++---------- tests/react/jsx/HasOneNullRelation.test.tsx | 2 +- .../store/notificationPropagation.test.ts | 31 +++++++++++++++---- 4 files changed, 42 insertions(+), 32 deletions(-) diff --git a/packages/bindx-react/src/hooks/ItemAccessorCache.ts b/packages/bindx-react/src/hooks/ItemAccessorCache.ts index 35d3b469..edbd38aa 100644 --- a/packages/bindx-react/src/hooks/ItemAccessorCache.ts +++ b/packages/bindx-react/src/hooks/ItemAccessorCache.ts @@ -10,14 +10,8 @@ import { EntityHandle } from '@contember/bindx' * (`React.memo` rows, `useMemo`) skip work. Change delivery is the subscription's job — a memoized * consumer must subscribe via `` / `useField` / `useAccessor` to observe changes. * - * Subscribe to the entity that OWNS the changed relation, not merely to the row's own entity: - * `notifyRelationSubscribers` notifies the relation key and its owning entity but — unlike - * `notifyEntitySubscribers` — does not walk up the parent chain, so a membership change on a - * descendant relation never reaches a subscriber on the root item. A memoized row rendering - * `item.profile.tags` subscribes with `useAccessor(item.profile.tags)`, not `useField(item.name)`. - * Beware `useAccessor(item.profile)`: a has-one ref reports its OWNER, so that subscribes to the - * row itself rather than the target — reach through to the nested relation or `.$entity`. The - * composed primitives (`` / ``) already resolve the right key. + * Relation and field notifications propagate through live parent edges, so subscribers on a root + * item still update when one of its descendants changes. * * The cache belongs to one hook instance and is thrown away whenever a handle construction input * changes; handles validate field access against the selection they were built with. diff --git a/packages/bindx/src/store/SubscriptionManager.ts b/packages/bindx/src/store/SubscriptionManager.ts index 3ca2ae38..ceb26837 100644 --- a/packages/bindx/src/store/SubscriptionManager.ts +++ b/packages/bindx/src/store/SubscriptionManager.ts @@ -194,7 +194,15 @@ export class SubscriptionManager implements Rekeyable { notifyEntitySubscribers( key: string, bumper: SnapshotVersionBumper, - notifiedKeys: Set = new Set(), + ): void { + this.notifyEntityAndParentSubscribers(key, bumper, new Set(), true) + } + + private notifyEntityAndParentSubscribers( + key: string, + bumper: SnapshotVersionBumper, + notifiedKeys: Set, + incrementGlobalVersion: boolean, ): void { // Prevent infinite recursion if (notifiedKeys.has(key)) return @@ -205,7 +213,7 @@ export class SubscriptionManager implements Rekeyable { const isRoot = notifiedKeys.size === 0 notifiedKeys.add(key) - this.globalVersion++ + if (incrementGlobalVersion) this.globalVersion++ // Notify entity-specific subscribers const entitySubs = this.entitySubscribers.get(key) @@ -222,7 +230,7 @@ export class SubscriptionManager implements Rekeyable { for (const parentKey of parents) { // Bump parent snapshot version so useSyncExternalStore detects a change bumper.bumpEntitySnapshotVersion(parentKey) - this.notifyEntitySubscribers(parentKey, bumper, notifiedKeys) + this.notifyEntityAndParentSubscribers(parentKey, bumper, notifiedKeys, true) } // Notify global subscribers (only once, from the root invocation — not @@ -235,8 +243,8 @@ export class SubscriptionManager implements Rekeyable { } /** - * Notifies relation subscribers and the parent entity's subscribers. - * The bumper callback is used to bump the parent entity snapshot version. + * Notifies relation subscribers, the owning entity, and its ancestors. + * The bumper callback is used to bump entity snapshot versions. * The entityKey is the parent entity key derived from the relation key. */ notifyRelationSubscribers( @@ -257,18 +265,7 @@ export class SubscriptionManager implements Rekeyable { // Bump entity snapshot version so isEqual detects a change bumper.bumpEntitySnapshotVersion(entityKey) - // Notify entity subscribers - const entitySubs = this.entitySubscribers.get(entityKey) - if (entitySubs) { - for (const sub of entitySubs) { - sub() - } - } - - // Notify global subscribers - for (const sub of this.globalSubscribers) { - sub() - } + this.notifyEntityAndParentSubscribers(entityKey, bumper, new Set(), false) } /** diff --git a/tests/react/jsx/HasOneNullRelation.test.tsx b/tests/react/jsx/HasOneNullRelation.test.tsx index b70ad474..b7dd757a 100644 --- a/tests/react/jsx/HasOneNullRelation.test.tsx +++ b/tests/react/jsx/HasOneNullRelation.test.tsx @@ -176,7 +176,7 @@ describe('HasOne JSX — nested nullable has-one with no connected row', () => { expect(getByTestId(container, 'profile-bio').textContent).toBe('empty') }) - test.failing('$connect(id) re-points sibling field subscriptions to a warm target', async () => { + test('$connect(id) re-points sibling field subscriptions to a warm target', async () => { const adapter = new MockAdapter(mockData, { delay: 0 }) function ConnectProfile({ field }: { field: HasOneRef }): React.ReactElement { diff --git a/tests/unit/store/notificationPropagation.test.ts b/tests/unit/store/notificationPropagation.test.ts index e4e2b2da..a019b6c8 100644 --- a/tests/unit/store/notificationPropagation.test.ts +++ b/tests/unit/store/notificationPropagation.test.ts @@ -11,12 +11,8 @@ import { createTestStore, createMockSubscriber } from '../shared/unitTestHelpers * harness is the regression oracle: every assertion encodes today's behavior so * the rework can prove it introduced no re-render regression. * - * Mechanics worth keeping in mind while reading these tests: - * - `setRelation` / `addToHasMany` notify the relation's own subscribers and the - * relation OWNER's entity subscribers — they do NOT walk `childToParents`. - * - `setFieldValue` on the child entity calls `notifyEntitySubscribers`, which - * walks `childToParents` UP the tree and bumps each ancestor's snapshot version. - * So the child-field mutation is what exercises parent propagation here. + * Field and relation changes both walk live parent edges and bump each ancestor's + * snapshot version. */ describe('Notification propagation', () => { let store: SnapshotStore @@ -73,6 +69,29 @@ describe('Notification propagation', () => { expect(parent.callCount()).toBe(1) }) + test('ancestor re-renders when a nested relation changes', () => { + store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'Draft' }, true) + store.setEntityData('Author', 'author-1', { id: 'author-1', name: 'Alice' }, true) + store.setEntityData('Profile', 'profile-1', { id: 'profile-1', bio: 'Writer' }, true) + store.setRelation('Article', 'article-1', 'author', { + currentId: 'author-1', + state: 'connected', + }) + + const article = createMockSubscriber() + const global = createMockSubscriber() + store.subscribeToEntity('Article', 'article-1', article.fn) + store.subscribe(global.fn) + + store.setRelation('Author', 'author-1', 'profile', { + currentId: 'profile-1', + state: 'connected', + }) + + expect(article.callCount()).toBe(1) + expect(global.callCount()).toBe(1) + }) + test('disconnect stops notifying the former parent', () => { // Server parent A with a child B connected via has-one. store.setEntityData('Author', 'author-1', { id: 'author-1', name: 'Alice' }, true) From b0b65dceceb38afc1534b97fe6d217ca3835af89 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 15:02:35 +0200 Subject: [PATCH 27/55] test(browser): select from stable option lists --- tests/browser/articleEditor.test.ts | 8 ++------ tests/browser/authorSelect.test.ts | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/browser/articleEditor.test.ts b/tests/browser/articleEditor.test.ts index 26fb0ad0..57c6afe3 100644 --- a/tests/browser/articleEditor.test.ts +++ b/tests/browser/articleEditor.test.ts @@ -21,9 +21,7 @@ browserTest('Article Editor', () => { test('changing author enables save and shows dirty notice', () => { // Open the author SelectField popover el(`${tid('article-author-select')} [aria-haspopup="dialog"]`).click() - // Type in the search input to filter, then click the filtered option - waitFor(() => el('[role="dialog"] input').exists) - el('[role="dialog"] input').fill('Jane') + // Select from the stable initial list; filtering remounts options asynchronously. const janeOption = () => el('xpath=//*[@role="dialog"]//button[contains(normalize-space(.), "Jane")]') waitFor(() => janeOption().exists) clickUntil( @@ -49,9 +47,7 @@ browserTest('Article Editor', () => { test('adding a tag shows it in the list', () => { // Open the tags MultiSelectField popover el(`${tid('article-tags')} [aria-haspopup="dialog"]`).click() - // Search for the tag and click it - waitFor(() => el('[role="dialog"] input').exists) - el('[role="dialog"] input').fill('TypeScript') + // Select from the stable initial list; filtering remounts options asynchronously. const typeScriptOption = () => el('xpath=//*[@role="dialog"]//button[contains(normalize-space(.), "TypeScript")]') waitFor(() => typeScriptOption().exists) clickUntil( diff --git a/tests/browser/authorSelect.test.ts b/tests/browser/authorSelect.test.ts index 6219f901..71b7798c 100644 --- a/tests/browser/authorSelect.test.ts +++ b/tests/browser/authorSelect.test.ts @@ -18,9 +18,7 @@ browserTest('Article with Author Select', () => { test('changing author enables save and updates display', () => { // Open the author SelectField popover el(`${tid('article-with-author-select')} [aria-haspopup="dialog"]`).click() - // Type to filter and click an option - waitFor(() => el('[role="dialog"] input').exists) - el('[role="dialog"] input').fill('Bob') + // Select from the stable initial list; filtering remounts options asynchronously. const bobOption = () => el('xpath=//*[@role="dialog"]//button[contains(normalize-space(.), "Bob")]') waitFor(() => bobOption().exists) clickUntil( From 520bf1f227653f250aa82741a70c0ce987079d6c Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 15:07:10 +0200 Subject: [PATCH 28/55] test(browser): choose a visible author option --- tests/browser/authorSelect.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/browser/authorSelect.test.ts b/tests/browser/authorSelect.test.ts index 71b7798c..263501c5 100644 --- a/tests/browser/authorSelect.test.ts +++ b/tests/browser/authorSelect.test.ts @@ -19,12 +19,12 @@ browserTest('Article with Author Select', () => { // Open the author SelectField popover el(`${tid('article-with-author-select')} [aria-haspopup="dialog"]`).click() // Select from the stable initial list; filtering remounts options asynchronously. - const bobOption = () => el('xpath=//*[@role="dialog"]//button[contains(normalize-space(.), "Bob")]') - waitFor(() => bobOption().exists) + const janeOption = () => el('xpath=//*[@role="dialog"]//button[contains(normalize-space(.), "Jane")]') + waitFor(() => janeOption().exists) clickUntil( () => { - const option = bobOption() - expect(option.text).toContain('Bob') + const option = janeOption() + expect(option.text).toContain('Jane') return option }, () => !el('author-select-save-button').isDisabled, From 7ff074a37c5f63949c6688e67a1bbcfd061c1c4e Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 15:10:41 +0200 Subject: [PATCH 29/55] test(browser): verify author selection before dirty state --- tests/browser/authorSelect.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/browser/authorSelect.test.ts b/tests/browser/authorSelect.test.ts index 263501c5..10ceabc8 100644 --- a/tests/browser/authorSelect.test.ts +++ b/tests/browser/authorSelect.test.ts @@ -27,8 +27,9 @@ browserTest('Article with Author Select', () => { expect(option.text).toContain('Jane') return option }, - () => !el('author-select-save-button').isDisabled, + () => el('current-author-display').text.includes('Jane'), ) + expect(el('author-select-save-button').isDisabled).toBe(false) expect(el('current-author-display').text).toContain('Changes will be applied on save') }) From 44d3c617557ed6215d73cab6c473185797542301 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 15:14:34 +0200 Subject: [PATCH 30/55] fix(bindx-ui): expose stable select option identity --- packages/bindx-ui/src/select/list.tsx | 2 +- tests/browser/articleEditor.test.ts | 4 ++-- tests/browser/authorSelect.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/bindx-ui/src/select/list.tsx b/packages/bindx-ui/src/select/list.tsx index 52fbe6a2..32ba5be6 100644 --- a/packages/bindx-ui/src/select/list.tsx +++ b/packages/bindx-ui/src/select/list.tsx @@ -78,7 +78,7 @@ function SelectListInner({ - + {children(item)} diff --git a/tests/browser/articleEditor.test.ts b/tests/browser/articleEditor.test.ts index 57c6afe3..19a2f71b 100644 --- a/tests/browser/articleEditor.test.ts +++ b/tests/browser/articleEditor.test.ts @@ -22,7 +22,7 @@ browserTest('Article Editor', () => { // Open the author SelectField popover el(`${tid('article-author-select')} [aria-haspopup="dialog"]`).click() // Select from the stable initial list; filtering remounts options asynchronously. - const janeOption = () => el('xpath=//*[@role="dialog"]//button[contains(normalize-space(.), "Jane")]') + const janeOption = () => el('[role="dialog"] button[data-entity-id="00000000-0000-0000-0000-000000000a02"]') waitFor(() => janeOption().exists) clickUntil( () => { @@ -48,7 +48,7 @@ browserTest('Article Editor', () => { // Open the tags MultiSelectField popover el(`${tid('article-tags')} [aria-haspopup="dialog"]`).click() // Select from the stable initial list; filtering remounts options asynchronously. - const typeScriptOption = () => el('xpath=//*[@role="dialog"]//button[contains(normalize-space(.), "TypeScript")]') + const typeScriptOption = () => el('[role="dialog"] button[data-entity-id="00000000-0000-0000-0000-000000000b03"]') waitFor(() => typeScriptOption().exists) clickUntil( () => { diff --git a/tests/browser/authorSelect.test.ts b/tests/browser/authorSelect.test.ts index 10ceabc8..a9cf84d9 100644 --- a/tests/browser/authorSelect.test.ts +++ b/tests/browser/authorSelect.test.ts @@ -19,7 +19,7 @@ browserTest('Article with Author Select', () => { // Open the author SelectField popover el(`${tid('article-with-author-select')} [aria-haspopup="dialog"]`).click() // Select from the stable initial list; filtering remounts options asynchronously. - const janeOption = () => el('xpath=//*[@role="dialog"]//button[contains(normalize-space(.), "Jane")]') + const janeOption = () => el('[role="dialog"] button[data-entity-id="00000000-0000-0000-0000-000000000a02"]') waitFor(() => janeOption().exists) clickUntil( () => { From 041b85e6c23324a2b82491ad3a5f98cbd8c01277 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 15:44:51 +0200 Subject: [PATCH 31/55] fix(bindx): honor nested persist cancellation and events --- .../bindx/src/persistence/BatchPersister.ts | 48 ++++++- .../src/persistence/MutationCollector.ts | 8 +- .../nestedPersistLifecycle.test.ts | 135 ++++++++++++++++++ 3 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 tests/unit/persistence/nestedPersistLifecycle.test.ts diff --git a/packages/bindx/src/persistence/BatchPersister.ts b/packages/bindx/src/persistence/BatchPersister.ts index 9bf2d855..06985dae 100644 --- a/packages/bindx/src/persistence/BatchPersister.ts +++ b/packages/bindx/src/persistence/BatchPersister.ts @@ -234,7 +234,13 @@ export class BatchPersister { let attempted: PersistenceResult try { - attempted = await this.executePersist(accepted, scope, options, updateMode) + attempted = await this.executePersist( + accepted, + new Set(cancelled.map(entity => entity.entityId)), + scope, + options, + updateMode, + ) } catch (error) { this.emitPersistFailed(accepted, toError(error)) throw error @@ -252,6 +258,7 @@ export class BatchPersister { */ private async executePersist( sortedEntities: DirtyEntity[], + excludedNestedEntityIds: ReadonlySet, scope: PersistScope, options: BatchPersistOptions | undefined, updateMode: UpdateMode, @@ -272,7 +279,7 @@ export class BatchPersister { // mutated to the server view — pessimistic mode presents the server // baseline via getPresentationSnapshot instead — so there is nothing to // capture or restore. - const mutations = this.buildMutations(sortedEntities, scope) + const mutations = this.buildMutations(sortedEntities, excludedNestedEntityIds, scope) if (mutations.length === 0) { // Nothing to persist @@ -361,8 +368,10 @@ export class BatchPersister { */ private emitPersistOutcome(results: readonly EntityPersistResult[]): void { const emitter = this.dispatcher.getEventEmitter() + const emittedEntityKeys = new Set() for (const entry of results) { + emittedEntityKeys.add(`${entry.entityType}:${entry.entityId}`) if (entry.success) { emitter.emit({ type: 'entity:persisted', @@ -384,6 +393,33 @@ export class BatchPersister { } satisfies EntityPersistFailedEvent) } } + + if (!(this.mutationCollector instanceof MutationCollector)) return + + const failure = results.find(entry => !entry.success)?.error?.message ?? 'Persist failed' + for (const [entityId, entityType] of this.mutationCollector.getNestedEntityTypes()) { + if (emittedEntityKeys.has(`${entityType}:${entityId}`)) continue + + if (this.store.existsOnServer(entityType, entityId)) { + emitter.emit({ + type: 'entity:persisted', + timestamp: Date.now(), + entityType, + entityId, + isNew: true, + persistedId: this.store.getPersistedId(entityType, entityId) ?? entityId, + } satisfies EntityPersistedEvent) + } else { + emitter.emit({ + type: 'entity:persistFailed', + timestamp: Date.now(), + entityType, + entityId, + isNew: true, + error: new Error(failure), + } satisfies EntityPersistFailedEvent) + } + } } /** @@ -543,15 +579,17 @@ export class BatchPersister { */ private buildMutations( entities: DirtyEntity[], + excludedNestedEntityIds: ReadonlySet, scope: PersistScope, ): TransactionMutation[] { // Exclude only non-create entities from nesting — // new entities should be nested inside their parent's mutation // to maintain correct relation connections without transaction support. if (this.mutationCollector instanceof MutationCollector) { - const excludedIds = new Set( - entities.filter(e => e.changeType !== 'create').map(e => e.entityId), - ) + const excludedIds = new Set(excludedNestedEntityIds) + for (const entity of entities) { + if (entity.changeType !== 'create') excludedIds.add(entity.entityId) + } this.mutationCollector.setExcludedEntities(excludedIds) } diff --git a/packages/bindx/src/persistence/MutationCollector.ts b/packages/bindx/src/persistence/MutationCollector.ts index bac4b471..f104b4de 100644 --- a/packages/bindx/src/persistence/MutationCollector.ts +++ b/packages/bindx/src/persistence/MutationCollector.ts @@ -42,8 +42,8 @@ export class MutationCollector implements MutationDataCollector { /** * Sets entity IDs that should be excluded from nested mutation generation. - * These entities get their own top-level mutations, so nested updates are skipped - * to avoid duplicate changes. + * These entities either get their own top-level mutation or were vetoed by a + * persistence interceptor. */ setExcludedEntities(ids: ReadonlySet): void { this.excludedEntityIds = ids @@ -353,6 +353,7 @@ export class MutationCollector implements MutationDataCollector { if (currentId && this.isExistingEntity(currentId)) { return { connect: { id: currentId } } } else if (currentId && isTempId(currentId)) { + if (this.excludedEntityIds.has(currentId)) return null // Temp entity — generate inline create with its collected data this._nestedEntityIds.add(currentId) const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName) @@ -389,6 +390,7 @@ export class MutationCollector implements MutationDataCollector { return null case 'deleted': + if (serverId !== null && this.excludedEntityIds.has(serverId)) return null // Delete the related entity return { delete: true } @@ -466,6 +468,7 @@ export class MutationCollector implements MutationDataCollector { // Planned removals -> disconnect/delete for (const [removedId, removalType] of hasManyState.plannedRemovals) { if (removalType === 'delete') { + if (this.excludedEntityIds.has(removedId)) continue operations.push({ delete: { id: removedId }, alias: removedId }) } else { operations.push({ disconnect: { id: removedId }, alias: removedId }) @@ -475,6 +478,7 @@ export class MutationCollector implements MutationDataCollector { // Planned additions -> create (newly created) or connect (existing persisted) for (const [additionId, kind] of hasManyState.plannedAdditions) { if (kind === 'created') { + if (this.excludedEntityIds.has(additionId)) continue if (!targetType) continue this._nestedEntityIds.add(additionId) this._nestedEntityTypes.set(additionId, targetType) diff --git a/tests/unit/persistence/nestedPersistLifecycle.test.ts b/tests/unit/persistence/nestedPersistLifecycle.test.ts new file mode 100644 index 00000000..5740170c --- /dev/null +++ b/tests/unit/persistence/nestedPersistLifecycle.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + ActionDispatcher, + BatchPersister, + ContemberSchemaMutationAdapter, + MutationCollector, + SnapshotStore, + type BackendAdapter, + type EntityPersistedEvent, + type EntityPersistFailedEvent, + type SchemaNames, +} from '@contember/bindx' + +const schema: SchemaNames = { + entities: { + Page: { + name: 'Page', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + blocks: { type: 'many', entity: 'Block' }, + }, + }, + Block: { + name: 'Block', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + }, + }, + }, + enums: {}, +} + +interface PersistCall { + readonly entityType: string + readonly entityId: string + readonly changes: Record +} + +function createAdapter(calls: PersistCall[], failWith?: string): BackendAdapter { + return { + query: () => Promise.resolve([]), + persist: (entityType, entityId, changes) => { + calls.push({ entityType, entityId, changes }) + if (failWith) return Promise.resolve({ ok: false, errorMessage: failWith }) + return Promise.resolve({ + ok: true, + data: { + id: entityId, + title: changes['title'], + blocks: [{ id: 'block-1', title: 'Draft block' }], + }, + }) + }, + create: (_entityType, data) => Promise.resolve({ ok: true, data: { id: 'created-1', ...data } }), + delete: () => Promise.resolve({ ok: true }), + } +} + +describe('nested persist lifecycle', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let blockId: string + + function createPersister(calls: PersistCall[], failWith?: string): BatchPersister { + const schemaAdapter = new ContemberSchemaMutationAdapter(schema) + return new BatchPersister(createAdapter(calls, failWith), store, dispatcher, { + mutationCollector: new MutationCollector(store, schemaAdapter), + }) + } + + beforeEach(() => { + store = new SnapshotStore() + dispatcher = new ActionDispatcher(store) + + store.setEntityData('Page', 'page-1', { id: 'page-1', title: 'Original title' }, true) + store.getOrCreateHasMany('Page', 'page-1', 'blocks', []) + store.setFieldValue('Page', 'page-1', ['title'], 'Updated title') + + blockId = store.createEntity('Block', { title: 'Draft block' }) + store.addToHasMany('Page', 'page-1', 'blocks', blockId) + }) + + test('does not include a vetoed nested create in its parent mutation', async () => { + const calls: PersistCall[] = [] + const persister = createPersister(calls) + dispatcher.getEventEmitter().interceptEntity('entity:persisting', 'Block', blockId, () => ({ + action: 'cancel', + })) + + const result = await persister.persistAll() + + expect(calls).toHaveLength(1) + expect(calls[0]?.changes).toEqual({ title: 'Updated title' }) + expect(result.success).toBe(false) + expect(result.successCount).toBe(1) + expect(result.skippedCount).toBe(1) + expect(store.existsOnServer('Block', blockId)).toBe(false) + expect(store.getAllDirtyEntities()).toContainEqual({ + entityType: 'Block', + entityId: blockId, + changeType: 'create', + }) + }) + + test('emits entity:persisted for a successful nested create', async () => { + const events: EntityPersistedEvent[] = [] + dispatcher.getEventEmitter().on('entity:persisted', event => events.push(event)) + const persister = createPersister([]) + + expect((await persister.persistAll()).success).toBe(true) + + expect(store.getPersistedId('Block', blockId)).toBe('block-1') + expect(events.find(event => event.entityId === blockId)).toMatchObject({ + entityType: 'Block', + isNew: true, + persistedId: 'block-1', + }) + }) + + test('emits entity:persistFailed for a failed nested create', async () => { + const events: EntityPersistFailedEvent[] = [] + dispatcher.getEventEmitter().on('entity:persistFailed', event => events.push(event)) + const persister = createPersister([], 'Server rejected the page') + + expect((await persister.persistAll()).success).toBe(false) + + const nestedFailure = events.find(event => event.entityId === blockId) + expect(nestedFailure).toMatchObject({ entityType: 'Block', isNew: true }) + expect(nestedFailure?.error.message).toBe('Server rejected the page') + }) +}) From 45f2374e250fe97283b7a5bab61c4bd561dac7b7 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Thu, 20 Aug 2026 15:44:57 +0200 Subject: [PATCH 32/55] fix(bindx-react): subscribe implicit interface props --- .../bindx-react/src/jsx/componentFactory.ts | 5 +- .../implicitEntityPropSubscription.test.tsx | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/bindx-react/src/jsx/componentFactory.ts b/packages/bindx-react/src/jsx/componentFactory.ts index 52dd1e8a..8327a90a 100644 --- a/packages/bindx-react/src/jsx/componentFactory.ts +++ b/packages/bindx-react/src/jsx/componentFactory.ts @@ -123,7 +123,7 @@ export function buildComponent( // Every entity prop is subscribed, selector or not: accessor identity is stable, so a // memo()-wrapped component only learns about its entity through its own subscription. - // The list is fixed at build time, which keeps the hook count in ComponentImpl stable. + // Interface props are appended during lazy collection, before the first runtime render. const entityPropNames = [...entityConfigs.keys()] // 2. Implicit entities - collect lazily to avoid TDZ errors @@ -139,6 +139,9 @@ export function buildComponent( } implicitCollected = true collectImplicitSelections(implicitConfigs, renderFn, selectionsMap, componentBrand, roles, hasInterfacesMode, schemaRegistry, conditionFn) + for (const propName of selectionsMap.keys()) { + if (!entityPropNames.includes(propName)) entityPropNames.push(propName) + } } // 3. Create React component diff --git a/tests/react/jsx/implicitEntityPropSubscription.test.tsx b/tests/react/jsx/implicitEntityPropSubscription.test.tsx index 67eae6dd..3f5550bc 100644 --- a/tests/react/jsx/implicitEntityPropSubscription.test.tsx +++ b/tests/react/jsx/implicitEntityPropSubscription.test.tsx @@ -17,6 +17,10 @@ afterEach(() => { * collection) is a first-party API and must subscribe exactly like the explicit-selector form. */ describe('createComponent entity prop subscriptions', () => { + interface HasName { + name: string + } + test('re-renders an implicit entity prop when its entity changes', async () => { const adapter = new MockAdapter(createMockData(), { delay: 0 }) @@ -60,6 +64,48 @@ describe('createComponent entity prop subscriptions', () => { }) }) + test('re-renders an implicit interface prop when its entity changes', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + const InterfaceName = createComponent() + .interfaces<{ item: HasName }>() + .render(({ item }) => {item.name.inputProps.value}) + + let rename: (() => void) | null = null + + function Rename({ name }: { name: FieldRef }): null { + const field = useField(name) + rename = () => field.setValue('Renamed') + return null + } + + const { container } = render( + + + {author => ( + <> + + + + )} + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'interface')).not.toBeNull() + }) + expect(getByTestId(container, 'interface').textContent).toBe('John Doe') + + act(() => { + rename!() + }) + + await waitFor(() => { + expect(getByTestId(container, 'interface').textContent).toBe('Renamed') + }) + }) + test('re-renders an explicit entity prop when its entity changes', async () => { const adapter = new MockAdapter(createMockData(), { delay: 0 }) From 54bf2b062d89c89d9769672fa6abd1c61e56a1b3 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 14:58:43 +0200 Subject: [PATCH 33/55] fix(bindx): keep parent-side deletes of entities that have their own mutation The interceptor-veto change reused the collector's excluded-entity set for vetoed entities and taught the delete branches to skip anything in it. That set also holds every non-create dirty entity, so an item edited and then removed with delete lost its delete: only its top-level update went out and the persist reported success. Split the two concerns. Excluded entities (own top-level mutation) only skip their nested update; vetoed entities skip every nested write, deletes included. Vetoed result entries now carry `skipped: true` so `failedCount` stays in step with `results`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011YXekDQ4PbrgeNwZWESGfs --- .../bindx/src/persistence/BatchPersister.ts | 13 +- .../src/persistence/MutationCollector.ts | 26 +++- packages/bindx/src/persistence/types.ts | 10 +- .../nestedDeleteOfDirtyEntity.test.ts | 122 ++++++++++++++++++ tests/unit/persistence/persistEvents.test.ts | 1 + 5 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 tests/unit/persistence/nestedDeleteOfDirtyEntity.test.ts diff --git a/packages/bindx/src/persistence/BatchPersister.ts b/packages/bindx/src/persistence/BatchPersister.ts index 06985dae..c648f617 100644 --- a/packages/bindx/src/persistence/BatchPersister.ts +++ b/packages/bindx/src/persistence/BatchPersister.ts @@ -258,7 +258,7 @@ export class BatchPersister { */ private async executePersist( sortedEntities: DirtyEntity[], - excludedNestedEntityIds: ReadonlySet, + vetoedEntityIds: ReadonlySet, scope: PersistScope, options: BatchPersistOptions | undefined, updateMode: UpdateMode, @@ -279,7 +279,7 @@ export class BatchPersister { // mutated to the server view — pessimistic mode presents the server // baseline via getPresentationSnapshot instead — so there is nothing to // capture or restore. - const mutations = this.buildMutations(sortedEntities, excludedNestedEntityIds, scope) + const mutations = this.buildMutations(sortedEntities, vetoedEntityIds, scope) if (mutations.length === 0) { // Nothing to persist @@ -443,6 +443,7 @@ export class BatchPersister { /** * Folds vetoed entities into the result as skipped — never as successes, and never * as server failures, so callers can tell a deliberate veto from a broken save. + * Their entries carry `skipped`, which keeps `failedCount` in step with `results`. */ private mergeCancelled( attempted: PersistenceResult, @@ -455,6 +456,7 @@ export class BatchPersister { entityId: entity.entityId, operation: entity.changeType, success: false, + skipped: true, error: { message: `Persist of ${entity.entityType}:${entity.entityId} was cancelled by an entity:persisting interceptor` }, })) @@ -579,18 +581,21 @@ export class BatchPersister { */ private buildMutations( entities: DirtyEntity[], - excludedNestedEntityIds: ReadonlySet, + vetoedEntityIds: ReadonlySet, scope: PersistScope, ): TransactionMutation[] { // Exclude only non-create entities from nesting — // new entities should be nested inside their parent's mutation // to maintain correct relation connections without transaction support. + // Vetoed entities are kept apart: an excluded entity still has its parent-side + // delete emitted, a vetoed one must not be written at all. if (this.mutationCollector instanceof MutationCollector) { - const excludedIds = new Set(excludedNestedEntityIds) + const excludedIds = new Set() for (const entity of entities) { if (entity.changeType !== 'create') excludedIds.add(entity.entityId) } this.mutationCollector.setExcludedEntities(excludedIds) + this.mutationCollector.setVetoedEntities(vetoedEntityIds) } const mutations: TransactionMutation[] = [] diff --git a/packages/bindx/src/persistence/MutationCollector.ts b/packages/bindx/src/persistence/MutationCollector.ts index f104b4de..cbef4e7e 100644 --- a/packages/bindx/src/persistence/MutationCollector.ts +++ b/packages/bindx/src/persistence/MutationCollector.ts @@ -30,7 +30,10 @@ export interface EntityMutationResult { * implementing MutationSchemaProvider interface (SchemaRegistry, Contember SchemaNames via adapter). */ export class MutationCollector implements MutationDataCollector { + /** Entities with their own top-level mutation; only their nested update is skipped. */ private excludedEntityIds: ReadonlySet = new Set() + /** Entities vetoed by an `entity:persisting` interceptor; nothing is emitted for them. */ + private vetoedEntityIds: ReadonlySet = new Set() private readonly _nestedEntityIds: Set = new Set() /** Maps nested entity temp IDs to their entity types for post-persist processing */ private readonly _nestedEntityTypes: Map = new Map() @@ -41,9 +44,9 @@ export class MutationCollector implements MutationDataCollector { ) {} /** - * Sets entity IDs that should be excluded from nested mutation generation. - * These entities either get their own top-level mutation or were vetoed by a - * persistence interceptor. + * Sets entity IDs that get their own top-level mutation, so their nested + * update is skipped to avoid duplicate changes. Relation operations that only + * exist on the parent (delete, disconnect) are still emitted for them. */ setExcludedEntities(ids: ReadonlySet): void { this.excludedEntityIds = ids @@ -51,6 +54,15 @@ export class MutationCollector implements MutationDataCollector { this._nestedEntityTypes.clear() } + /** + * Sets entity IDs vetoed by an `entity:persisting` interceptor. Unlike excluded + * entities they have no top-level mutation either, so every nested operation + * that would write them — create, update or delete — is dropped. + */ + setVetoedEntities(ids: ReadonlySet): void { + this.vetoedEntityIds = ids + } + /** * Returns IDs of entities that were included as nested inline creates * inside another entity's mutation data. These entities don't need @@ -353,7 +365,7 @@ export class MutationCollector implements MutationDataCollector { if (currentId && this.isExistingEntity(currentId)) { return { connect: { id: currentId } } } else if (currentId && isTempId(currentId)) { - if (this.excludedEntityIds.has(currentId)) return null + if (this.vetoedEntityIds.has(currentId)) return null // Temp entity — generate inline create with its collected data this._nestedEntityIds.add(currentId) const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName) @@ -390,7 +402,7 @@ export class MutationCollector implements MutationDataCollector { return null case 'deleted': - if (serverId !== null && this.excludedEntityIds.has(serverId)) return null + if (serverId !== null && this.vetoedEntityIds.has(serverId)) return null // Delete the related entity return { delete: true } @@ -468,7 +480,7 @@ export class MutationCollector implements MutationDataCollector { // Planned removals -> disconnect/delete for (const [removedId, removalType] of hasManyState.plannedRemovals) { if (removalType === 'delete') { - if (this.excludedEntityIds.has(removedId)) continue + if (this.vetoedEntityIds.has(removedId)) continue operations.push({ delete: { id: removedId }, alias: removedId }) } else { operations.push({ disconnect: { id: removedId }, alias: removedId }) @@ -478,7 +490,7 @@ export class MutationCollector implements MutationDataCollector { // Planned additions -> create (newly created) or connect (existing persisted) for (const [additionId, kind] of hasManyState.plannedAdditions) { if (kind === 'created') { - if (this.excludedEntityIds.has(additionId)) continue + if (this.vetoedEntityIds.has(additionId)) continue if (!targetType) continue this._nestedEntityIds.add(additionId) this._nestedEntityTypes.set(additionId, targetType) diff --git a/packages/bindx/src/persistence/types.ts b/packages/bindx/src/persistence/types.ts index 0cbb5bd3..b030a249 100644 --- a/packages/bindx/src/persistence/types.ts +++ b/packages/bindx/src/persistence/types.ts @@ -97,6 +97,12 @@ export interface EntityPersistResult { readonly entityId: string readonly operation: 'create' | 'update' | 'delete' readonly success: boolean + /** + * The entity was deliberately not sent (vetoed by an `entity:persisting` + * interceptor). `success` is false, but it counts towards `skippedCount`, + * not `failedCount`. + */ + readonly skipped?: true readonly error?: PersistError readonly fieldResults?: readonly FieldPersistResult[] /** Server-assigned ID for creates (when tempId was used) */ @@ -121,9 +127,9 @@ export interface PersistenceResult { readonly results: readonly EntityPersistResult[] /** Number of successfully persisted entities */ readonly successCount: number - /** Number of failed entities */ + /** Number of entities the server rejected — results with `success: false` and no `skipped` flag */ readonly failedCount: number - /** Number of entities skipped (e.g., already in-flight) */ + /** Number of entities not sent: already in-flight, or vetoed by an interceptor (listed in `results` with `skipped`) */ readonly skippedCount: number } diff --git a/tests/unit/persistence/nestedDeleteOfDirtyEntity.test.ts b/tests/unit/persistence/nestedDeleteOfDirtyEntity.test.ts new file mode 100644 index 00000000..8027a537 --- /dev/null +++ b/tests/unit/persistence/nestedDeleteOfDirtyEntity.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + ActionDispatcher, + BatchPersister, + ContemberSchemaMutationAdapter, + MutationCollector, + SnapshotStore, + type BackendAdapter, + type SchemaNames, +} from '@contember/bindx' + +const schema: SchemaNames = { + entities: { + Page: { + name: 'Page', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + blocks: { type: 'many', entity: 'Block' }, + cover: { type: 'one', entity: 'Block' }, + }, + }, + Block: { + name: 'Block', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + }, + }, + }, + enums: {}, +} + +interface PersistCall { + readonly entityType: string + readonly entityId: string + readonly changes: Record +} + +function createAdapter(calls: PersistCall[]): BackendAdapter { + return { + query: () => Promise.resolve([]), + persist: (entityType, entityId, changes) => { + calls.push({ entityType, entityId, changes }) + return Promise.resolve({ ok: true, data: { id: entityId, ...changes } }) + }, + create: (_entityType, data) => Promise.resolve({ ok: true, data: { id: 'created-1', ...data } }), + delete: () => Promise.resolve({ ok: true }), + } +} + +/** + * A relation-level delete lives on the parent mutation. The deleted entity may + * also be dirty in its own right (edited before it was removed), which gives it + * a top-level update and puts it on the collector's excluded list — that list + * must only suppress its nested *update*, never the parent-side delete. + */ +describe('nested delete of a dirty entity', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let calls: PersistCall[] + let persister: BatchPersister + + beforeEach(() => { + store = new SnapshotStore() + dispatcher = new ActionDispatcher(store) + calls = [] + persister = new BatchPersister(createAdapter(calls), store, dispatcher, { + mutationCollector: new MutationCollector(store, new ContemberSchemaMutationAdapter(schema)), + }) + store.setEntityData('Page', 'page-1', { id: 'page-1', title: 'Page' }, true) + store.setEntityData('Block', 'block-1', { id: 'block-1', title: 'Block' }, true) + }) + + test('has-many: an edited item removed with delete is still deleted', async () => { + store.getOrCreateHasMany('Page', 'page-1', 'blocks', ['block-1']) + store.setFieldValue('Block', 'block-1', ['title'], 'Edited') + store.planHasManyRemoval('Page', 'page-1', 'blocks', 'block-1', 'delete') + + const result = await persister.persistAll() + + expect(result.success).toBe(true) + const pageCall = calls.find(call => call.entityType === 'Page') + expect(pageCall?.changes).toEqual({ + blocks: [{ delete: { id: 'block-1' }, alias: 'block-1' }], + }) + }) + + test('has-one: an edited target marked deleted is still deleted', async () => { + store.getOrCreateRelation('Page', 'page-1', 'cover', { + currentId: 'block-1', + serverId: 'block-1', + state: 'connected', + serverState: 'connected', + placeholderData: {}, + }) + store.setFieldValue('Block', 'block-1', ['title'], 'Edited') + store.setRelation('Page', 'page-1', 'cover', { state: 'deleted' }) + + const result = await persister.persistAll() + + expect(result.success).toBe(true) + const pageCall = calls.find(call => call.entityType === 'Page') + expect(pageCall?.changes).toEqual({ cover: { delete: true } }) + }) + + test('a vetoed entity is not deleted through its parent either', async () => { + store.getOrCreateHasMany('Page', 'page-1', 'blocks', ['block-1']) + store.setFieldValue('Block', 'block-1', ['title'], 'Edited') + store.planHasManyRemoval('Page', 'page-1', 'blocks', 'block-1', 'delete') + dispatcher.getEventEmitter().interceptEntity('entity:persisting', 'Block', 'block-1', () => ({ + action: 'cancel', + })) + + const result = await persister.persistAll() + + expect(result.skippedCount).toBe(1) + expect(calls).toEqual([]) + }) +}) diff --git a/tests/unit/persistence/persistEvents.test.ts b/tests/unit/persistence/persistEvents.test.ts index 96474e90..3a08127f 100644 --- a/tests/unit/persistence/persistEvents.test.ts +++ b/tests/unit/persistence/persistEvents.test.ts @@ -244,6 +244,7 @@ describe('BatchPersister persist lifecycle events', () => { expect(result.skippedCount).toBe(1) const cancelledEntry = result.results.find(r => r.entityId === 'a-1') expect(cancelledEntry?.success).toBe(false) + expect(cancelledEntry?.skipped).toBe(true) expect(cancelledEntry?.error?.message).toMatch(/cancelled/) // No after-event is emitted for a vetoed entity. From ae5898cdb8c2b6e50195a787887dacf7ace3fd91 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 14:58:43 +0200 Subject: [PATCH 34/55] refactor: bump the global version once per notification; key the list cache by accessor cache - SubscriptionManager: the ancestor walk no longer takes an incrementGlobalVersion flag that only the root honoured; both entry points bump once before the first subscriber runs. - useEntityList: include the ItemAccessorCache in the listCacheRef hit key, so a widened selection never serves the previous result with its narrow accessors. - select/list.tsx: restore indentation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011YXekDQ4PbrgeNwZWESGfs --- packages/bindx-react/src/hooks/useEntityList.ts | 10 ++++++---- packages/bindx-ui/src/select/list.tsx | 2 +- packages/bindx/src/store/SubscriptionManager.ts | 15 +++++++++------ 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index 497ae800..ecc73426 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -230,15 +230,15 @@ export function useEntityList( storeVersion: number status: string isRefetching: boolean + accessorCache: ItemAccessorCache result: UseEntityListResult } | null>(null) // --- Item accessor cache --- // Kept for the hook's lifetime so item identity survives a list snapshot rebuild. Dropped // whenever a handle construction input changes — handles are built against `selectionMeta` and - // validate field access against it. Note this does not fully close the stale-selection window: - // `listCacheRef` below does not include the selection in its hit key, so the render on which - // the selection widens still serves the previous result and its narrow accessors. + // validate field access against it. A new cache also invalidates `listCacheRef`, so a widened + // selection never serves the previous result with its narrow accessors. const itemAccessorCache = useMemo( () => new ItemAccessorCache((id) => EntityHandle.createRaw( id, @@ -340,7 +340,8 @@ export function useEntityList( cache.version === version && cache.storeVersion === storeVersion && cache.status === state.status && - cache.isRefetching === state.isRefetching + cache.isRefetching === state.isRefetching && + cache.accessorCache === itemAccessorCache ) { return cache.result } @@ -377,6 +378,7 @@ export function useEntityList( storeVersion, status: state.status, isRefetching: state.isRefetching, + accessorCache: itemAccessorCache, result, } diff --git a/packages/bindx-ui/src/select/list.tsx b/packages/bindx-ui/src/select/list.tsx index 32ba5be6..8f6e0340 100644 --- a/packages/bindx-ui/src/select/list.tsx +++ b/packages/bindx-ui/src/select/list.tsx @@ -78,7 +78,7 @@ function SelectListInner({ - + {children(item)} diff --git a/packages/bindx/src/store/SubscriptionManager.ts b/packages/bindx/src/store/SubscriptionManager.ts index ceb26837..de1a1400 100644 --- a/packages/bindx/src/store/SubscriptionManager.ts +++ b/packages/bindx/src/store/SubscriptionManager.ts @@ -195,14 +195,19 @@ export class SubscriptionManager implements Rekeyable { key: string, bumper: SnapshotVersionBumper, ): void { - this.notifyEntityAndParentSubscribers(key, bumper, new Set(), true) + this.globalVersion++ + this.notifyEntityAndParentSubscribers(key, bumper, new Set()) } + /** + * Walks the entity and its live ancestors. The global version is bumped once by the + * caller, before the first subscriber runs — a subscriber reading getVersion() from + * inside its callback must already see the new value. + */ private notifyEntityAndParentSubscribers( key: string, bumper: SnapshotVersionBumper, notifiedKeys: Set, - incrementGlobalVersion: boolean, ): void { // Prevent infinite recursion if (notifiedKeys.has(key)) return @@ -213,8 +218,6 @@ export class SubscriptionManager implements Rekeyable { const isRoot = notifiedKeys.size === 0 notifiedKeys.add(key) - if (incrementGlobalVersion) this.globalVersion++ - // Notify entity-specific subscribers const entitySubs = this.entitySubscribers.get(key) if (entitySubs) { @@ -230,7 +233,7 @@ export class SubscriptionManager implements Rekeyable { for (const parentKey of parents) { // Bump parent snapshot version so useSyncExternalStore detects a change bumper.bumpEntitySnapshotVersion(parentKey) - this.notifyEntityAndParentSubscribers(parentKey, bumper, notifiedKeys, true) + this.notifyEntityAndParentSubscribers(parentKey, bumper, notifiedKeys) } // Notify global subscribers (only once, from the root invocation — not @@ -265,7 +268,7 @@ export class SubscriptionManager implements Rekeyable { // Bump entity snapshot version so isEqual detects a change bumper.bumpEntitySnapshotVersion(entityKey) - this.notifyEntityAndParentSubscribers(entityKey, bumper, new Set(), false) + this.notifyEntityAndParentSubscribers(entityKey, bumper, new Set()) } /** From 40bebf46aa244ec0424e58359bef5c70f3f7d3bd Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 15:19:11 +0200 Subject: [PATCH 35/55] fix(bindx): drop nested updates of vetoed entities too Splitting vetoed from excluded ids left the two nested-update branches checking only the excluded set, so a vetoed server item edited alongside its parent was still written inline through the parent's mutation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011YXekDQ4PbrgeNwZWESGfs --- .../src/persistence/MutationCollector.ts | 6 +- .../persistence/vetoedNestedUpdate.test.ts | 105 ++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 tests/unit/persistence/vetoedNestedUpdate.test.ts diff --git a/packages/bindx/src/persistence/MutationCollector.ts b/packages/bindx/src/persistence/MutationCollector.ts index cbef4e7e..15ed5970 100644 --- a/packages/bindx/src/persistence/MutationCollector.ts +++ b/packages/bindx/src/persistence/MutationCollector.ts @@ -379,8 +379,8 @@ export class MutationCollector implements MutationDataCollector { return { connect: { id: currentId } } } } else if (currentId && serverId && currentId === serverId) { - // Skip if entity has its own top-level mutation - if (this.excludedEntityIds.has(currentId)) { + // Skip if entity has its own top-level mutation or was vetoed + if (this.excludedEntityIds.has(currentId) || this.vetoedEntityIds.has(currentId)) { return null } // Same entity - check if we need to update it @@ -505,7 +505,7 @@ export class MutationCollector implements MutationDataCollector { if (targetType) { for (const itemId of hasManyState.serverIds) { if (hasManyState.plannedRemovals.has(itemId)) continue - if (this.excludedEntityIds.has(itemId)) continue + if (this.excludedEntityIds.has(itemId) || this.vetoedEntityIds.has(itemId)) continue const itemSnapshot = this.store.getEntitySnapshot(targetType, itemId) if (!itemSnapshot) continue diff --git a/tests/unit/persistence/vetoedNestedUpdate.test.ts b/tests/unit/persistence/vetoedNestedUpdate.test.ts new file mode 100644 index 00000000..6a767ad5 --- /dev/null +++ b/tests/unit/persistence/vetoedNestedUpdate.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + ActionDispatcher, + BatchPersister, + ContemberSchemaMutationAdapter, + MutationCollector, + SnapshotStore, + type BackendAdapter, + type SchemaNames, +} from '@contember/bindx' + +const schema: SchemaNames = { + entities: { + Page: { + name: 'Page', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + blocks: { type: 'many', entity: 'Block' }, + cover: { type: 'one', entity: 'Block' }, + }, + }, + Block: { + name: 'Block', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + }, + }, + }, + enums: {}, +} + +interface PersistCall { + readonly entityType: string + readonly entityId: string + readonly changes: Record +} + +function createAdapter(calls: PersistCall[]): BackendAdapter { + return { + query: () => Promise.resolve([]), + persist: (entityType, entityId, changes) => { + calls.push({ entityType, entityId, changes }) + return Promise.resolve({ ok: true, data: { id: entityId } }) + }, + create: (_entityType, data) => Promise.resolve({ ok: true, data: { id: 'created-1', ...data } }), + delete: () => Promise.resolve({ ok: true }), + } +} + +/** + * An entity vetoed by an `entity:persisting` interceptor must not be written at all — + * not even as a nested update inside an accepted parent's mutation. + */ +describe('vetoed entity nested update', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let calls: PersistCall[] + let persister: BatchPersister + + beforeEach(() => { + store = new SnapshotStore() + dispatcher = new ActionDispatcher(store) + calls = [] + persister = new BatchPersister(createAdapter(calls), store, dispatcher, { + mutationCollector: new MutationCollector(store, new ContemberSchemaMutationAdapter(schema)), + }) + store.setEntityData('Page', 'page-1', { id: 'page-1', title: 'Page' }, true) + store.setEntityData('Block', 'block-1', { id: 'block-1', title: 'Block' }, true) + store.setFieldValue('Page', 'page-1', ['title'], 'Edited page') + store.setFieldValue('Block', 'block-1', ['title'], 'Edited block') + dispatcher.getEventEmitter().interceptEntity('entity:persisting', 'Block', 'block-1', () => ({ + action: 'cancel', + })) + }) + + test('has-many: a vetoed server item is not updated through its parent', async () => { + store.getOrCreateHasMany('Page', 'page-1', 'blocks', ['block-1']) + + const result = await persister.persistAll() + + expect(result.skippedCount).toBe(1) + expect(calls).toHaveLength(1) + expect(calls[0]?.changes).toEqual({ title: 'Edited page' }) + }) + + test('has-one: a vetoed connected target is not updated through its parent', async () => { + store.getOrCreateRelation('Page', 'page-1', 'cover', { + currentId: 'block-1', + serverId: 'block-1', + state: 'connected', + serverState: 'connected', + placeholderData: {}, + }) + + const result = await persister.persistAll() + + expect(result.skippedCount).toBe(1) + expect(calls).toHaveLength(1) + expect(calls[0]?.changes).toEqual({ title: 'Edited page' }) + }) +}) From 3c699df0b4790ce11c1d746680cf42552bdc99cc Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 15:44:46 +0200 Subject: [PATCH 36/55] fix(bindx): subscription bookkeeping edge cases in the store - notifyAll(): iterate the live subscriber sets, so a callback unsubscribed by a sibling during the same pass is not invoked afterwards. - rekey(): merge subscriber sets into the destination key instead of replacing whatever was already subscribed there. - Ancestor walk: skip already-notified ancestors before bumping them, so a grandparent reached through two parents is bumped once per write. The transitive walk through shared lookup entities stays (documented cost). - connectExistingToHasMany(): cancel a pending planned removal of the same item, as planHasManyConnection already does. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011YXekDQ4PbrgeNwZWESGfs --- packages/bindx/src/store/HasManyStore.ts | 6 ++ .../bindx/src/store/SubscriptionManager.ts | 59 ++++++++++++------- .../hasManyReconnectCancelsRemoval.test.ts | 39 ++++++++++++ .../notifyAllUnsubscribeDuringPass.test.ts | 42 +++++++++++++ tests/unit/store/rekeySubscriberMerge.test.ts | 56 ++++++++++++++++++ .../unit/store/sharedAncestorBumpOnce.test.ts | 30 ++++++++++ 6 files changed, 211 insertions(+), 21 deletions(-) create mode 100644 tests/unit/store/hasManyReconnectCancelsRemoval.test.ts create mode 100644 tests/unit/store/notifyAllUnsubscribeDuringPass.test.ts create mode 100644 tests/unit/store/rekeySubscriberMerge.test.ts create mode 100644 tests/unit/store/sharedAncestorBumpOnce.test.ts diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index c72d0da4..3e4cb19c 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -368,6 +368,11 @@ export class HasManyStore { // re-appending an id that is already listed: this path re-runs whenever an // embedded connect reference is re-materialized, and an unconditional // append would surface the same item twice (mirrors planHasManyConnection). + // A re-connect cancels a pending removal of the same item (mirrors + // planHasManyConnection); leaving both recorded hides the item from the + // live-edge index while it is still listed. + const newPlannedRemovals = new Map(existing.plannedRemovals) + newPlannedRemovals.delete(itemId) let newOrderedIds = existing.orderedIds if (newOrderedIds !== null && !newOrderedIds.includes(itemId)) { newOrderedIds = [...newOrderedIds, itemId] @@ -376,6 +381,7 @@ export class HasManyStore { ...existing, orderedIds: newOrderedIds, plannedAdditions: newPlannedAdditions, + plannedRemovals: newPlannedRemovals, version: existing.version + 1, }) } diff --git a/packages/bindx/src/store/SubscriptionManager.ts b/packages/bindx/src/store/SubscriptionManager.ts index de1a1400..09d4ef90 100644 --- a/packages/bindx/src/store/SubscriptionManager.ts +++ b/packages/bindx/src/store/SubscriptionManager.ts @@ -142,19 +142,16 @@ export class SubscriptionManager implements Rekeyable { notifyAll(): void { this.globalVersion++ - // Snapshot first — a subscriber may unsubscribe itself or a sibling while being notified. - const subscribers: Subscriber[] = [] + // Iterate the live sets, as the per-key paths do: a subscriber that unsubscribes a + // not-yet-visited sibling removes it from the iteration, whereas a copied array would + // still invoke it after its unsubscribe() returned. for (const subs of this.entitySubscribers.values()) { - subscribers.push(...subs) + for (const sub of subs) sub() } for (const subs of this.relationSubscribers.values()) { - subscribers.push(...subs) - } - subscribers.push(...this.globalSubscribers) - - for (const sub of subscribers) { - sub() + for (const sub of subs) sub() } + for (const sub of this.globalSubscribers) sub() } // ==================== Parent-Child Relationships ==================== @@ -203,6 +200,11 @@ export class SubscriptionManager implements Rekeyable { * Walks the entity and its live ancestors. The global version is bumped once by the * caller, before the first subscriber runs — a subscriber reading getVersion() from * inside its callback must already see the new value. + * + * The walk is a transitive closure over live edges, so an entity many rows point at + * (a shared lookup entity whose own has-many is loaded) acts as a hub: a write in one + * row reaches every other row through it. That is the price of not knowing which part + * of the hub each row presents; a selection-aware edge index would be the fix. */ private notifyEntityAndParentSubscribers( key: string, @@ -231,6 +233,8 @@ export class SubscriptionManager implements Rekeyable { // disconnected child no longer reaches its former parent. const parents = this.getParentKeys(key) for (const parentKey of parents) { + // An ancestor reachable through several edges is bumped and walked once. + if (notifiedKeys.has(parentKey)) continue // Bump parent snapshot version so useSyncExternalStore detects a change bumper.bumpEntitySnapshotVersion(parentKey) this.notifyEntityAndParentSubscribers(parentKey, bumper, notifiedKeys) @@ -325,21 +329,17 @@ export class SubscriptionManager implements Rekeyable { } this.rekeyedKeys.set(oldKey, newKey) - // Move entity subscribers - const entitySubs = this.entitySubscribers.get(oldKey) - if (entitySubs) { - this.entitySubscribers.delete(oldKey) - this.entitySubscribers.set(newKey, entitySubs) - } + // Move entity subscribers, merging into anything already subscribed under the new key + this.moveSubscribers(this.entitySubscribers, oldKey, newKey) // Move relation subscribers by prefix (e.g. "Entity:tempId:" → "Entity:persistedId:") - const toMoveRelations: [string, Set][] = [] - for (const [key, subs] of this.relationSubscribers) { + const toMoveRelations: string[] = [] + for (const key of this.relationSubscribers.keys()) { if (key.startsWith(oldKeyPrefix)) { - toMoveRelations.push([key, subs]) + toMoveRelations.push(key) } } - for (const [oldRelKey, subs] of toMoveRelations) { + for (const oldRelKey of toMoveRelations) { const newRelKey = newKeyPrefix + oldRelKey.slice(oldKeyPrefix.length) // Register redirect for relation key (update existing chains first) @@ -350,8 +350,25 @@ export class SubscriptionManager implements Rekeyable { } this.rekeyedKeys.set(oldRelKey, newRelKey) - this.relationSubscribers.delete(oldRelKey) - this.relationSubscribers.set(newRelKey, subs) + this.moveSubscribers(this.relationSubscribers, oldRelKey, newRelKey) + } + } + + /** + * Re-homes a subscriber set under a new key. The destination may already hold + * subscribers (a component mounted on the persisted id before the draft was rekeyed + * onto it); replacing the set would silently orphan them, so the two are merged. + */ + private moveSubscribers(map: Map>, oldKey: string, newKey: string): void { + const moved = map.get(oldKey) + if (!moved) return + map.delete(oldKey) + + const existing = map.get(newKey) + if (!existing) { + map.set(newKey, moved) + return } + for (const sub of moved) existing.add(sub) } } diff --git a/tests/unit/store/hasManyReconnectCancelsRemoval.test.ts b/tests/unit/store/hasManyReconnectCancelsRemoval.test.ts new file mode 100644 index 00000000..d205b5a3 --- /dev/null +++ b/tests/unit/store/hasManyReconnectCancelsRemoval.test.ts @@ -0,0 +1,39 @@ +/** + * connectExistingToHasMany after a planned removal cancels the removal, so the item + * is back on the live-edge index and notifies its parent. + */ +import { describe, test, expect, beforeEach } from 'bun:test' +import { SnapshotStore } from '@contember/bindx' +import { createTestStore, createMockSubscriber } from '../shared/unitTestHelpers.js' + +describe('has-many: connectExistingToHasMany after a planned removal', () => { + let store: SnapshotStore + + beforeEach(() => { + store = createTestStore() + store.setEntityData('Author', 'a1', { id: 'a1', name: 'Alice' }, true) + store.setEntityData('Article', 'x', { id: 'x', title: 'X' }, true) + store.getOrCreateHasMany('Author', 'a1', 'articles', ['x']) + store.removeFromHasMany('Author', 'a1', 'articles', 'x', 'disconnect') + store.connectExistingToHasMany('Author', 'a1', 'articles', 'x') + }) + + test('the re-connected item is back in the list', () => { + expect(store.getHasManyOrderedIds('Author', 'a1', 'articles')).toEqual(['x']) + }) + + test('the planned removal is cancelled by the re-connect', () => { + const state = store.getHasMany('Author', 'a1', 'articles')! + expect([...state.plannedRemovals.keys()]).toEqual([]) + }) + + test('a write to the re-connected item notifies the owning parent', () => { + const parent = createMockSubscriber() + store.subscribeToEntity('Author', 'a1', parent.fn) + parent.reset() + + store.setFieldValue('Article', 'x', ['title'], 'X2') + + expect(parent.callCount()).toBe(1) + }) +}) diff --git a/tests/unit/store/notifyAllUnsubscribeDuringPass.test.ts b/tests/unit/store/notifyAllUnsubscribeDuringPass.test.ts new file mode 100644 index 00000000..e9d2c389 --- /dev/null +++ b/tests/unit/store/notifyAllUnsubscribeDuringPass.test.ts @@ -0,0 +1,42 @@ +/** + * notifyAll() (the store.clear() path) must honour an unsubscribe made by another + * subscriber during the same pass, as the per-key paths do. + */ +import { describe, test, expect, beforeEach } from 'bun:test' +import { SnapshotStore } from '@contember/bindx' +import { createTestStore } from '../shared/unitTestHelpers.js' + +describe('notifyAll() and unsubscribe during notification', () => { + let store: SnapshotStore + + beforeEach(() => { + store = createTestStore() + }) + + test('an entity subscriber unsubscribed mid-pass is not invoked', () => { + store.setEntityData('Article', 'a1', { id: 'a1', title: 'T' }, true) + + let siblingCalls = 0 + let unsubscribeSibling: (() => void) | null = null + // Registered FIRST, so it runs before the sibling it removes. + store.subscribeToEntity('Article', 'a1', () => { unsubscribeSibling?.() }) + unsubscribeSibling = store.subscribeToEntity('Article', 'a1', () => { siblingCalls++ }) + + store.clear() + + expect(siblingCalls).toBe(0) + }) + + test('a global subscriber unsubscribed mid-pass is not invoked', () => { + store.setEntityData('Article', 'a1', { id: 'a1', title: 'T' }, true) + + let siblingCalls = 0 + let unsubscribeSibling: (() => void) | null = null + store.subscribeToEntity('Article', 'a1', () => { unsubscribeSibling?.() }) + unsubscribeSibling = store.subscribe(() => { siblingCalls++ }) + + store.clear() + + expect(siblingCalls).toBe(0) + }) +}) diff --git a/tests/unit/store/rekeySubscriberMerge.test.ts b/tests/unit/store/rekeySubscriberMerge.test.ts new file mode 100644 index 00000000..2ed05465 --- /dev/null +++ b/tests/unit/store/rekeySubscriberMerge.test.ts @@ -0,0 +1,56 @@ +/** + * Rekeying onto a key that already has subscribers must merge them, not replace them. + */ +import { describe, test, expect, beforeEach } from 'bun:test' +import { SnapshotStore } from '@contember/bindx' +import { createTestStore, createMockSubscriber } from '../shared/unitTestHelpers.js' + +describe('rekey onto an already-subscribed key', () => { + let store: SnapshotStore + + beforeEach(() => { + store = createTestStore() + }) + + test('a subscriber already registered under the persisted key survives the rekey', () => { + store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'Server' }, true) + + const existing = createMockSubscriber() + store.subscribeToEntity('Article', 'article-1', existing.fn) + + const tempId = store.createEntity('Article', { title: 'Draft' }) + const draft = createMockSubscriber() + store.subscribeToEntity('Article', tempId, draft.fn) + + store.mapTempIdToPersistedId('Article', tempId, 'article-1') + existing.reset() + draft.reset() + + store.setFieldValue('Article', 'article-1', ['title'], 'Updated') + + expect(draft.callCount()).toBe(1) + expect(existing.callCount()).toBe(1) + }) + + test('a relation subscriber already registered under the persisted key survives the rekey', () => { + store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'Server' }, true) + store.getOrCreateHasMany('Article', 'article-1', 'tags', []) + + const existing = createMockSubscriber() + store.subscribeToRelation('Article', 'article-1', 'tags', existing.fn) + + const tempId = store.createEntity('Article', { title: 'Draft' }) + store.getOrCreateHasMany('Article', tempId, 'tags', []) + const draft = createMockSubscriber() + store.subscribeToRelation('Article', tempId, 'tags', draft.fn) + + store.mapTempIdToPersistedId('Article', tempId, 'article-1') + existing.reset() + draft.reset() + + store.addToHasMany('Article', 'article-1', 'tags', 'tag-1') + + expect(draft.callCount()).toBe(1) + expect(existing.callCount()).toBe(1) + }) +}) diff --git a/tests/unit/store/sharedAncestorBumpOnce.test.ts b/tests/unit/store/sharedAncestorBumpOnce.test.ts new file mode 100644 index 00000000..46918c98 --- /dev/null +++ b/tests/unit/store/sharedAncestorBumpOnce.test.ts @@ -0,0 +1,30 @@ +import { describe, test, expect } from 'bun:test' +import { createTestStore, createMockSubscriber } from '../shared/unitTestHelpers.js' + +/** + * Parent propagation is a transitive closure over live relation edges. An ancestor + * reachable through several edges (a child listed in two has-many fields of the same + * parent, or a hub entity every row points at) must be bumped and walked once per + * write, not once per edge. + */ +describe('shared ancestor propagation', () => { + test('a grandparent reached through two parents is bumped once per child write', () => { + const store = createTestStore() + store.setEntityData('Site', 'site-1', { id: 'site-1' }, true) + store.setEntityData('Page', 'page-1', { id: 'page-1' }, true) + store.setEntityData('Page', 'page-2', { id: 'page-2' }, true) + store.setEntityData('Block', 'block-1', { id: 'block-1', title: 'A' }, true) + store.getOrCreateHasMany('Site', 'site-1', 'pages', ['page-1', 'page-2']) + store.getOrCreateHasMany('Page', 'page-1', 'blocks', ['block-1']) + store.getOrCreateHasMany('Page', 'page-2', 'blocks', ['block-1']) + + const grandparent = createMockSubscriber() + store.subscribeToEntity('Site', 'site-1', grandparent.fn) + const versionBefore = store.getEntitySnapshot('Site', 'site-1')?.version + + store.setFieldValue('Block', 'block-1', ['title'], 'B') + + expect(grandparent.callCount()).toBe(1) + expect(store.getEntitySnapshot('Site', 'site-1')?.version).toBe((versionBefore ?? 0) + 1) + }) +}) From 560924d5b23e6b4e6d7715ba898171a5b1f3e577 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 15:45:25 +0200 Subject: [PATCH 37/55] fix(bindx-client): select connected ids and detect the has-many update shape structurally - A `{ connect: { id } }` op now contributes `field { id }` to the node selection. BatchPersister already content-matches create ops on their connected ids, but the response never carried them, so sibling creates that differ only by what they connect were paired positionally and could swap server ids. Selecting the id requires read access to the connected entity, the same requirement nested creates already have. - The has-many `{ update: { by, data } }` shape is recognised by both keys, so a has-one update touching a JSON column named `data` is no longer misread. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011YXekDQ4PbrgeNwZWESGfs --- .../src/graphql/mutationFragments.ts | 15 +- .../mutationSelectionConnect.test.ts | 155 ++++++++++++++++++ .../mutationSelectionUpdateShape.test.ts | 86 ++++++++++ 3 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 tests/unit/persistence/mutationSelectionConnect.test.ts create mode 100644 tests/unit/persistence/mutationSelectionUpdateShape.test.ts diff --git a/packages/bindx-client/src/graphql/mutationFragments.ts b/packages/bindx-client/src/graphql/mutationFragments.ts index 8481c957..7beff8a5 100644 --- a/packages/bindx-client/src/graphql/mutationFragments.ts +++ b/packages/bindx-client/src/graphql/mutationFragments.ts @@ -84,7 +84,12 @@ function isRecord(value: unknown): value is Record { } /** - * Unwraps a create/update operation to the data object it writes. + * Unwraps a create/update/connect operation to the data object whose shape the + * response must echo. + * + * A connect contributes `{ id }` only: the response needs the connected id so a + * create op that differs from its sibling solely by what it connects can still be + * content-matched against its row. */ function extractOperationData(op: unknown): Record | undefined { if (!isRecord(op)) return undefined @@ -94,10 +99,16 @@ function extractOperationData(op: unknown): Record | undefined const update = op['update'] if (isRecord(update)) { + // hasMany: `{ update: { by, data } }`; hasOne: `{ update: }`. Both keys are + // required to tell them apart — a plain JSON column may be named `data`. + const by = update['by'] const data = update['data'] - return isRecord(data) ? data : update + return isRecord(by) && isRecord(data) ? data : update } + const connect = op['connect'] + if (isRecord(connect)) return { id: connect['id'] } + return undefined } diff --git a/tests/unit/persistence/mutationSelectionConnect.test.ts b/tests/unit/persistence/mutationSelectionConnect.test.ts new file mode 100644 index 00000000..2b620aad --- /dev/null +++ b/tests/unit/persistence/mutationSelectionConnect.test.ts @@ -0,0 +1,155 @@ +/** + * Connected has-one ids are part of the node selection, so sibling creates that differ + * only by what they connect are paired with their own response rows. + */ +import { describe, test, expect, beforeEach, mock } from 'bun:test' +import { + SnapshotStore, + MutationCollector, + ContemberSchemaMutationAdapter, + ActionDispatcher, + BatchPersister, + type BackendAdapter, + type SchemaNames, +} from '@contember/bindx' +import { buildNodeSelectionFromMutationData } from '@contember/bindx-client' + +const schema: SchemaNames = { + entities: { + Page: { + name: 'Page', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + blocks: { type: 'many', entity: 'Block' }, + }, + }, + Block: { + name: 'Block', + scalars: ['id', 'order'], + fields: { + id: { type: 'column' }, + order: { type: 'column' }, + author: { type: 'one', entity: 'Author', nullable: true }, + }, + }, + Author: { + name: 'Author', + scalars: ['id', 'name'], + fields: { id: { type: 'column' }, name: { type: 'column' } }, + }, + }, + enums: {}, +} + +type NodeSelection = { name: string; children?: NodeSelection[] } + +const readSelection = (selectionSet: readonly unknown[]): NodeSelection[] => + selectionSet.map(item => { + const field = item as { name: string; selectionSet?: readonly unknown[] } + return { name: field.name, children: field.selectionSet ? readSelection(field.selectionSet) : undefined } + }) + +/** + * Echoes back exactly the selected fields, and returns the hasMany rows in + * reverse order — the Contember API gives no ordering guarantee for a + * mutation's `node` hasMany, which is why the persister content-matches. + */ +function createReorderingEchoAdapter(assigned: Map): BackendAdapter { + let counter = 0 + + const buildNode = (data: Record, selection: NodeSelection[]): Record => { + const selected = new Map(selection.map(field => [field.name, field])) + const node: Record = { id: `server-${++counter}` } + + for (const [key, value] of Object.entries(data)) { + const fieldSelection = selected.get(key) + if (!fieldSelection || value === null || value === undefined) continue + + if (Array.isArray(value)) { + const items: Record[] = [] + for (const op of value) { + if (typeof op !== 'object' || op === null) continue + const opObj = op as Record + if ('create' in opObj) { + const built = buildNode(opObj['create'] as Record, fieldSelection.children ?? []) + if (typeof opObj['alias'] === 'string') assigned.set(opObj['alias'], built['id'] as string) + items.push(built) + } else if ('connect' in opObj) { + items.push({ id: (opObj['connect'] as Record)['id'] }) + } + } + if (items.length > 0) node[key] = items.reverse() + } else if (typeof value === 'object') { + const opObj = value as Record + if ('create' in opObj) { + node[key] = buildNode(opObj['create'] as Record, fieldSelection.children ?? []) + } else if ('connect' in opObj) { + node[key] = { id: (opObj['connect'] as Record)['id'] } + } + } else { + node[key] = value + } + } + return node + } + + const respond = (data: Record) => + Promise.resolve({ ok: true, data: buildNode(data, readSelection(buildNodeSelectionFromMutationData(data))) }) + + return { + query: mock(() => Promise.resolve([])), + delete: mock(() => Promise.resolve({ ok: true })), + persist: mock((_e: string, _i: string, changes: Record) => respond(changes)), + create: mock((_e: string, data: Record) => respond(data)), + } +} + +describe('node selection must carry connected hasOne ids', () => { + test('a connected hasOne is part of the node selection', () => { + const selection = buildNodeSelectionFromMutationData({ + blocks: [ + { alias: 't1', create: { order: 1, author: { connect: { id: 'author-1' } } } }, + { alias: 't2', create: { order: 1, author: { connect: { id: 'author-2' } } } }, + ], + }) + const blocks = selection.find(f => (f as { name: string }).name === 'blocks') as { selectionSet?: readonly unknown[] } + expect((blocks.selectionSet ?? []).map(f => (f as { name: string }).name)).toContain('author') + }) + + test('sibling creates that differ only by their connected relation keep their own server ids', async () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + const schemaAdapter = new ContemberSchemaMutationAdapter(schema) + const mutationCollector = new MutationCollector(store, schemaAdapter) + const assigned = new Map() + const persister = new BatchPersister(createReorderingEchoAdapter(assigned), store, dispatcher, { + mutationCollector, + schema: schemaAdapter as never, + }) + + store.setEntityData('Page', 'page-1', { id: 'page-1', title: 'Page' }, true) + store.setExistsOnServer('Page', 'page-1', true) + store.getOrCreateHasMany('Page', 'page-1', 'blocks', []) + + // Two blocks with identical scalars; only the connected author differs. + const blockA = store.createEntity('Block', { order: 1 }) + store.getOrCreateRelation('Block', blockA, 'author', { + currentId: 'author-1', serverId: null, state: 'connected', serverState: 'disconnected', placeholderData: {}, + }) + store.addToHasMany('Page', 'page-1', 'blocks', blockA) + + const blockB = store.createEntity('Block', { order: 1 }) + store.getOrCreateRelation('Block', blockB, 'author', { + currentId: 'author-2', serverId: null, state: 'connected', serverState: 'disconnected', placeholderData: {}, + }) + store.addToHasMany('Page', 'page-1', 'blocks', blockB) + + const result = await persister.persistAll() + expect(result.success).toBe(true) + + expect(store.getPersistedId('Block', blockA)).toBe(assigned.get(blockA)!) + expect(store.getPersistedId('Block', blockB)).toBe(assigned.get(blockB)!) + }) +}) diff --git a/tests/unit/persistence/mutationSelectionUpdateShape.test.ts b/tests/unit/persistence/mutationSelectionUpdateShape.test.ts new file mode 100644 index 00000000..7554669c --- /dev/null +++ b/tests/unit/persistence/mutationSelectionUpdateShape.test.ts @@ -0,0 +1,86 @@ +/** + * A has-one update whose data carries a JSON column named `data` must not be mistaken + * for the has-many `{ update: { by, data } }` shape. + */ +import { describe, test, expect } from 'bun:test' +import { + SnapshotStore, + MutationCollector, + ContemberSchemaMutationAdapter, + type SchemaNames, +} from '@contember/bindx' +import { buildNodeSelectionFromMutationData } from '@contember/bindx-client' + +const schema: SchemaNames = { + entities: { + Page: { + name: 'Page', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + content: { type: 'one', entity: 'Content' }, + }, + }, + Content: { + name: 'Content', + // `data` is an ordinary JSON column here, not a mutation wrapper. + scalars: ['id', 'heading', 'data'], + fields: { + id: { type: 'column' }, + heading: { type: 'column' }, + data: { type: 'column' }, + }, + }, + }, + enums: {}, +} + +interface SelectionField { readonly name: string; readonly selectionSet?: readonly unknown[] } + +function fieldNames(selectionSet: readonly unknown[]): string[] { + return selectionSet.map(item => (item as SelectionField).name) +} + +function childOf(selectionSet: readonly unknown[], name: string): readonly unknown[] { + const field = selectionSet.find(item => (item as SelectionField).name === name) as SelectionField | undefined + if (!field?.selectionSet) throw new Error(`no nested selection for "${name}": ${fieldNames(selectionSet).join(', ')}`) + return field.selectionSet +} + +describe('node selection for a hasOne update touching a JSON column named `data`', () => { + test('selects the related entity\'s own columns, not the JSON payload keys', () => { + const store = new SnapshotStore() + const collector = new MutationCollector(store, new ContemberSchemaMutationAdapter(schema)) + + store.setEntityData('Page', 'p-1', { id: 'p-1', title: 'Page' }, true) + store.setEntityData('Content', 'c-1', { + id: 'c-1', + heading: 'Old heading', + data: { theme: 'light' }, + }, true) + store.setExistsOnServer('Content', 'c-1', true) + store.getOrCreateRelation('Page', 'p-1', 'content', { + currentId: 'c-1', + serverId: 'c-1', + state: 'connected', + serverState: 'connected', + placeholderData: {}, + }) + + store.setFieldValue('Content', 'c-1', ['heading'], 'New heading') + store.setFieldValue('Content', 'c-1', ['data'], { theme: 'dark' }) + + const mutation = collector.collectUpdateData('Page', 'p-1') + expect(mutation).toEqual({ + content: { update: { heading: 'New heading', data: { theme: 'dark' } } }, + }) + + const content = childOf(buildNodeSelectionFromMutationData(mutation!), 'content') + + // `theme` is a key of the JSON payload — Content has no such column, + // so asking for it makes the whole mutation invalid. + expect(fieldNames(content)).not.toContain('theme') + expect(fieldNames(content)).toContain('heading') + }) +}) From 8889fb2de54a2a80a8307fed6c48190c936e1b34 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 15:46:42 +0200 Subject: [PATCH 38/55] fix(bindx): stamp the live id into snapshots written under a rekeyed temp id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setEntityData/refreshServerData resolved the key through the temp→persisted redirect but passed the raw id down, so a late write with the temp id stored a snapshot whose id was the dead temp id and resurrected it in the id index. The id index now also bumps the mutation version whenever an id moves between keys, since the reachability memo resolves relation edges through it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011YXekDQ4PbrgeNwZWESGfs --- .../bindx/src/store/EntitySnapshotStore.ts | 5 +- packages/bindx/src/store/SnapshotStore.ts | 5 +- tests/unit/store/rekeyedIdWrite.test.ts | 49 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 tests/unit/store/rekeyedIdWrite.test.ts diff --git a/packages/bindx/src/store/EntitySnapshotStore.ts b/packages/bindx/src/store/EntitySnapshotStore.ts index 5d33557d..f60d45fc 100644 --- a/packages/bindx/src/store/EntitySnapshotStore.ts +++ b/packages/bindx/src/store/EntitySnapshotStore.ts @@ -123,8 +123,11 @@ export class EntitySnapshotStore implements Rekeyable { ) this.writeSnapshot(key, newSnapshot) + // The id index resolves relation edges for reachability, which memoizes on + // mutationVersion — so any change to the index must move it, not just a new key. + const indexChanged = this.idIndex.get(id) !== key this.idIndex.set(id, key) - if (!existing) this.mutationVersion++ + if (!existing || indexChanged) this.mutationVersion++ if (!isServerData) this.editableWriteVersion++ return newSnapshot } diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 4380ad49..9cf76895 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -268,7 +268,8 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { // Server loads are not user gestures; only journal local data sets (incl. the // initial write of a freshly created entity, which captures an absent pre-image). if (!isServerData) this.journal?.recordEntity(key) - const newSnapshot = this.entitySnapshots.setData(key, id, entityType, data, isServerData) + // The caller may still hold a rekeyed temp id; the snapshot must carry the live one. + const newSnapshot = this.entitySnapshots.setData(key, this.resolveId(entityType, id), entityType, data, isServerData) if (isServerData) { this.meta.setExistsOnServer(key, true) @@ -292,7 +293,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { skipNotify: boolean = false, ): EntitySnapshot { const key = this.getEntityKey(entityType, id) - const newSnapshot = this.entitySnapshots.refreshServerData(key, id, entityType, data) + const newSnapshot = this.entitySnapshots.refreshServerData(key, this.resolveId(entityType, id), entityType, data) this.meta.setExistsOnServer(key, true) if (!skipNotify) { this.notifyEntitySubscribers(key) diff --git a/tests/unit/store/rekeyedIdWrite.test.ts b/tests/unit/store/rekeyedIdWrite.test.ts new file mode 100644 index 00000000..d66636c2 --- /dev/null +++ b/tests/unit/store/rekeyedIdWrite.test.ts @@ -0,0 +1,49 @@ +/** + * Writes that still carry a rekeyed temp id land on the persisted key and must stamp + * the live id into the snapshot — never resurrect the dead temp id in the id index. + */ +import { describe, test, expect } from 'bun:test' +import { SnapshotStore } from '@contember/bindx' +import { EntitySnapshotStore } from '../../../packages/bindx/src/store/EntitySnapshotStore.js' + +/** Article a1 with a created Comment child that has just been persisted and rekeyed. */ +function seedRekeyed(): { store: SnapshotStore; temp: string } { + const store = new SnapshotStore() + store.setEntityData('Article', 'a1', { id: 'a1' }, true) + const temp = store.createEntity('Comment', { text: 'x' }) + store.addToHasMany('Article', 'a1', 'list', temp) + store.registerParentChild('Article', 'a1', 'Comment', temp) + store.setExistsOnServer('Comment', temp, true) + store.mapTempIdToPersistedId('Comment', temp, 'p1') + return { store, temp } +} + +describe('post-rekey writes that still carry the stale temp id', () => { + test('setEntityData keeps snapshot.id equal to the entity key it wrote', () => { + const { store, temp } = seedRekeyed() + store.setEntityData('Comment', temp, { text: 'srv' }, true) + expect(store.getEntitySnapshot('Comment', 'p1')?.id).toBe('p1') + }) + + test('refreshServerData keeps snapshot.id equal to the entity key it wrote', () => { + const { store, temp } = seedRekeyed() + store.refreshServerData('Comment', temp, { id: 'p1', text: 'srv' }) + expect(store.getEntitySnapshot('Comment', 'p1')?.id).toBe('p1') + }) +}) + +describe('EntitySnapshotStore id index', () => { + test('moving an id to another key bumps the mutation version', () => { + const snapshots = new EntitySnapshotStore() + snapshots.setData('Comment:a', 'a', 'Comment', { id: 'a' }, true) + snapshots.setData('Comment:b', 'b', 'Comment', { id: 'b' }, true) + const before = snapshots.getMutationVersion() + + // Re-pointing an existing id at an existing key changes what relation edges + // resolve to, so the reachability memo keyed on this counter must miss. + snapshots.setData('Comment:b', 'a', 'Comment', { id: 'a' }, true) + + expect(snapshots.keyForId('a')).toBe('Comment:b') + expect(snapshots.getMutationVersion()).toBeGreaterThan(before) + }) +}) From 90e2194e0050ba51ea0148fcabbcbd5f5bee3461 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 15:49:42 +0200 Subject: [PATCH 39/55] fix(bindx): keep relation ops dropped for vetoed items pending after commit When a nested create or a parent-side delete was suppressed for a vetoed entity, the parent's successful persist still ran a blanket commitAllRelations: the vetoed create's temp id was folded into serverIds (so it never got linked) and the vetoed delete was committed away (the item vanished client-side while it still existed on the server). MutationCollector now records the relation items it suppressed, and the commit step leaves exactly those planned additions/removals (and the has-one relation targeting them) pending, so they go out on the next persist. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011YXekDQ4PbrgeNwZWESGfs --- .../bindx/src/persistence/BatchPersister.ts | 20 ++- .../src/persistence/MutationCollector.ts | 45 +++++- packages/bindx/src/store/HasManyStore.ts | 36 ++++- packages/bindx/src/store/HasOneStore.ts | 14 +- packages/bindx/src/store/RelationStore.ts | 6 +- packages/bindx/src/store/SnapshotStore.ts | 12 +- .../vetoedCreateStaysPending.test.ts | 136 ++++++++++++++++++ .../vetoedDeleteStaysPending.test.ts | 111 ++++++++++++++ 8 files changed, 356 insertions(+), 24 deletions(-) create mode 100644 tests/unit/persistence/vetoedCreateStaysPending.test.ts create mode 100644 tests/unit/persistence/vetoedDeleteStaysPending.test.ts diff --git a/packages/bindx/src/persistence/BatchPersister.ts b/packages/bindx/src/persistence/BatchPersister.ts index c648f617..8f7d5fdd 100644 --- a/packages/bindx/src/persistence/BatchPersister.ts +++ b/packages/bindx/src/persistence/BatchPersister.ts @@ -322,6 +322,18 @@ export class BatchPersister { } } + /** + * Commits an entity's relations after a successful persist, except the planned ops + * the collector dropped for vetoed items — those stay pending so the next persist + * sends them, rather than being folded into the server baseline unsent. + */ + private commitRelations(entityType: string, entityId: string): void { + const suppressed = this.mutationCollector instanceof MutationCollector + ? this.mutationCollector.getSuppressedRelationItems() + : undefined + this.store.commitAllRelations(entityType, entityId, suppressed?.size ? suppressed : undefined) + } + /** * Whether any entity in the batch has an `entity:persisting` interceptor. */ @@ -891,7 +903,7 @@ export class BatchPersister { } else { // Full commit this.dispatcher.dispatch(commitEntity(entity.entityType, entity.entityId)) - this.store.commitAllRelations(entity.entityType, entity.entityId) + this.commitRelations(entity.entityType, entity.entityId) } // Map temp ID if create @@ -1090,7 +1102,7 @@ export class BatchPersister { // Commit the nested entity this.dispatcher.dispatch(commitEntity(entityType, nested.entityId)) - this.store.commitAllRelations(entityType, nested.entityId) + this.commitRelations(entityType, nested.entityId) this.store.setExistsOnServer(entityType, nested.entityId, true) // Map temp ID to server-assigned ID @@ -1131,7 +1143,7 @@ export class BatchPersister { // Commit it — the parent mutation succeeded, so this entity exists on the server this.dispatcher.dispatch(commitEntity(entity.entityType, entity.entityId)) - this.store.commitAllRelations(entity.entityType, entity.entityId) + this.commitRelations(entity.entityType, entity.entityId) this.store.setExistsOnServer(entity.entityType, entity.entityId, true) } @@ -1144,7 +1156,7 @@ export class BatchPersister { if (!snapshot) continue this.dispatcher.dispatch(commitEntity(entityType, tempId)) - this.store.commitAllRelations(entityType, tempId) + this.commitRelations(entityType, tempId) this.store.setExistsOnServer(entityType, tempId, true) } } diff --git a/packages/bindx/src/persistence/MutationCollector.ts b/packages/bindx/src/persistence/MutationCollector.ts index 15ed5970..c2971c3e 100644 --- a/packages/bindx/src/persistence/MutationCollector.ts +++ b/packages/bindx/src/persistence/MutationCollector.ts @@ -34,6 +34,12 @@ export class MutationCollector implements MutationDataCollector { private excludedEntityIds: ReadonlySet = new Set() /** Entities vetoed by an `entity:persisting` interceptor; nothing is emitted for them. */ private vetoedEntityIds: ReadonlySet = new Set() + /** + * Relation items whose op was dropped because the item is vetoed, keyed by relation + * key (`Type:id:field`). The commit step leaves exactly these pending, so the planned + * create/delete is sent once the veto is lifted instead of being committed unsent. + */ + private readonly _suppressedRelationItems = new Map>() private readonly _nestedEntityIds: Set = new Set() /** Maps nested entity temp IDs to their entity types for post-persist processing */ private readonly _nestedEntityTypes: Map = new Map() @@ -52,6 +58,7 @@ export class MutationCollector implements MutationDataCollector { this.excludedEntityIds = ids this._nestedEntityIds.clear() this._nestedEntityTypes.clear() + this._suppressedRelationItems.clear() } /** @@ -80,6 +87,24 @@ export class MutationCollector implements MutationDataCollector { return this._nestedEntityTypes } + /** + * Returns relation items (by relation key) whose planned op was not emitted because + * the item is vetoed. BatchPersister keeps these pending when it commits relations. + */ + getSuppressedRelationItems(): ReadonlyMap> { + return this._suppressedRelationItems + } + + private suppressRelationItem(entityType: string, entityId: string, fieldName: string, itemId: string): void { + const key = `${entityType}:${entityId}:${fieldName}` + const items = this._suppressedRelationItems.get(key) + if (items) { + items.add(itemId) + } else { + this._suppressedRelationItems.set(key, new Set([itemId])) + } + } + // ==================== Main Collection Methods ==================== /** @@ -365,7 +390,10 @@ export class MutationCollector implements MutationDataCollector { if (currentId && this.isExistingEntity(currentId)) { return { connect: { id: currentId } } } else if (currentId && isTempId(currentId)) { - if (this.vetoedEntityIds.has(currentId)) return null + if (this.vetoedEntityIds.has(currentId)) { + this.suppressRelationItem(entityType, entityId, fieldName, currentId) + return null + } // Temp entity — generate inline create with its collected data this._nestedEntityIds.add(currentId) const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName) @@ -402,7 +430,10 @@ export class MutationCollector implements MutationDataCollector { return null case 'deleted': - if (serverId !== null && this.vetoedEntityIds.has(serverId)) return null + if (serverId !== null && this.vetoedEntityIds.has(serverId)) { + this.suppressRelationItem(entityType, entityId, fieldName, serverId) + return null + } // Delete the related entity return { delete: true } @@ -480,7 +511,10 @@ export class MutationCollector implements MutationDataCollector { // Planned removals -> disconnect/delete for (const [removedId, removalType] of hasManyState.plannedRemovals) { if (removalType === 'delete') { - if (this.vetoedEntityIds.has(removedId)) continue + if (this.vetoedEntityIds.has(removedId)) { + this.suppressRelationItem(entityType, entityId, fieldName, removedId) + continue + } operations.push({ delete: { id: removedId }, alias: removedId }) } else { operations.push({ disconnect: { id: removedId }, alias: removedId }) @@ -490,7 +524,10 @@ export class MutationCollector implements MutationDataCollector { // Planned additions -> create (newly created) or connect (existing persisted) for (const [additionId, kind] of hasManyState.plannedAdditions) { if (kind === 'created') { - if (this.vetoedEntityIds.has(additionId)) continue + if (this.vetoedEntityIds.has(additionId)) { + this.suppressRelationItem(entityType, entityId, fieldName, additionId) + continue + } if (!targetType) continue this._nestedEntityIds.add(additionId) this._nestedEntityTypes.set(additionId, targetType) diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index 3e4cb19c..723a639c 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -525,19 +525,41 @@ export class HasManyStore { /** * Commits all has-many relations for an entity. + * + * `pendingItems` (by relation key) names planned additions/removals that were NOT + * sent — they stay planned instead of being folded into the server baseline. */ - commitAllRelations(keyPrefix: string): void { + commitAllRelations(keyPrefix: string, pendingItems?: ReadonlyMap>): void { for (const [key, state] of this.hasManyStates) { - if (key.startsWith(keyPrefix)) { - const newServerIds = new Set(state.serverIds) - for (const removedId of state.plannedRemovals.keys()) { + if (!key.startsWith(keyPrefix)) continue + + const pending = pendingItems?.get(key) + const newServerIds = new Set(state.serverIds) + const keptRemovals = new Map() + const keptAdditions = new Map() + + for (const [removedId, type] of state.plannedRemovals) { + if (pending?.has(removedId)) { + keptRemovals.set(removedId, type) + } else { newServerIds.delete(removedId) } - for (const connectedId of state.plannedAdditions.keys()) { - newServerIds.add(connectedId) + } + for (const [addedId, kind] of state.plannedAdditions) { + if (pending?.has(addedId)) { + keptAdditions.set(addedId, kind) + } else { + newServerIds.add(addedId) } - this.commitHasMany(key, Array.from(newServerIds)) } + + this.writeHasMany(key, { + serverIds: newServerIds, + orderedIds: null, + plannedRemovals: keptRemovals, + plannedAdditions: keptAdditions, + version: state.version + 1, + }) } } diff --git a/packages/bindx/src/store/HasOneStore.ts b/packages/bindx/src/store/HasOneStore.ts index 999a0537..1819b02a 100644 --- a/packages/bindx/src/store/HasOneStore.ts +++ b/packages/bindx/src/store/HasOneStore.ts @@ -235,12 +235,18 @@ export class HasOneStore { /** * Commits all has-one relations for an entity. + * + * A relation whose planned op targets an item in `pendingItems` (by relation key) + * was not sent and is left uncommitted, so the op is retried on the next persist. */ - commitAllRelations(keyPrefix: string): void { - for (const key of this.relationStates.keys()) { - if (key.startsWith(keyPrefix)) { - this.commitRelation(key) + commitAllRelations(keyPrefix: string, pendingItems?: ReadonlyMap>): void { + for (const [key, state] of this.relationStates) { + if (!key.startsWith(keyPrefix)) continue + const pending = pendingItems?.get(key) + if (pending && ((state.currentId !== null && pending.has(state.currentId)) || (state.serverId !== null && pending.has(state.serverId)))) { + continue } + this.commitRelation(key) } } diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index b441a924..6adc621b 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -216,9 +216,9 @@ export class RelationStore implements Rekeyable { /** * Commits all relations (hasOne and hasMany) for an entity. */ - commitAllRelations(keyPrefix: string): void { - this.hasOne.commitAllRelations(keyPrefix) - this.hasMany.commitAllRelations(keyPrefix) + commitAllRelations(keyPrefix: string, pendingItems?: ReadonlyMap>): void { + this.hasOne.commitAllRelations(keyPrefix, pendingItems) + this.hasMany.commitAllRelations(keyPrefix, pendingItems) } /** diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 9cf76895..15234be4 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -955,9 +955,17 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { this.notifyRelationSubscribers(key) } - commitAllRelations(entityType: string, entityId: string): void { + /** + * Commits every relation of an entity. Items listed in `pendingItems` (by relation + * key) were not sent in the persist and stay planned. + */ + commitAllRelations( + entityType: string, + entityId: string, + pendingItems?: ReadonlyMap>, + ): void { const keyPrefix = `${entityType}:${entityId}:` - this.relations.commitAllRelations(keyPrefix) + this.relations.commitAllRelations(keyPrefix, pendingItems) } resetAllRelations(entityType: string, entityId: string): void { diff --git a/tests/unit/persistence/vetoedCreateStaysPending.test.ts b/tests/unit/persistence/vetoedCreateStaysPending.test.ts new file mode 100644 index 00000000..d040a3fa --- /dev/null +++ b/tests/unit/persistence/vetoedCreateStaysPending.test.ts @@ -0,0 +1,136 @@ +/** + * A nested create dropped for a vetoed child must stay planned on the parent after the + * parent's successful persist, so a later persist still links the child. + */ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + ActionDispatcher, + BatchPersister, + ContemberSchemaMutationAdapter, + MutationCollector, + SnapshotStore, + type BackendAdapter, + type SchemaNames, +} from '@contember/bindx' + +const schema: SchemaNames = { + entities: { + Page: { + name: 'Page', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + blocks: { type: 'many', entity: 'Block' }, + cover: { type: 'one', entity: 'Block' }, + }, + }, + Block: { + name: 'Block', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + }, + }, + }, + enums: {}, +} + +interface PersistCall { + readonly entityType: string + readonly entityId: string + readonly changes: Record +} + +function createAdapter(calls: PersistCall[]): BackendAdapter { + return { + query: () => Promise.resolve([]), + persist: (entityType, entityId, changes) => { + calls.push({ entityType, entityId, changes }) + return Promise.resolve({ + ok: true, + data: { id: entityId, blocks: [{ id: 'block-server-1', title: 'Draft block' }] }, + }) + }, + create: (entityType, data) => { + calls.push({ entityType, entityId: '', changes: data }) + return Promise.resolve({ ok: true, data: { id: 'created-1', ...data } }) + }, + delete: () => Promise.resolve({ ok: true }), + } +} + +/** + * A vetoed nested create is dropped from its parent's mutation, but the parent is + * still committed on success — including the planned addition that was never sent. + * The link must survive as pending so the next persist still creates the child. + */ +describe('vetoed nested create keeps its parent link pending', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let calls: PersistCall[] + let persister: BatchPersister + let blockId: string + let removeVeto: () => void + + beforeEach(() => { + store = new SnapshotStore() + dispatcher = new ActionDispatcher(store) + calls = [] + persister = new BatchPersister(createAdapter(calls), store, dispatcher, { + mutationCollector: new MutationCollector(store, new ContemberSchemaMutationAdapter(schema)), + }) + + store.setEntityData('Page', 'page-1', { id: 'page-1', title: 'Original' }, true) + store.getOrCreateHasMany('Page', 'page-1', 'blocks', []) + store.setFieldValue('Page', 'page-1', ['title'], 'Updated title') + blockId = store.createEntity('Block', { title: 'Draft block' }) + store.addToHasMany('Page', 'page-1', 'blocks', blockId) + + removeVeto = dispatcher.getEventEmitter().interceptEntity( + 'entity:persisting', 'Block', blockId, () => ({ action: 'cancel' }), + ) + }) + + test('the temp id is not committed into the parent server list', async () => { + await persister.persistAll() + + const hasMany = store.getHasMany('Page', 'page-1', 'blocks') + expect(hasMany?.serverIds).not.toContain(blockId) + expect([...(hasMany?.plannedAdditions.keys() ?? [])]).toContain(blockId) + }) + + test('has-one: the vetoed create is not committed as the connected server target', async () => { + store.getOrCreateRelation('Page', 'page-1', 'cover', { + currentId: null, + serverId: null, + state: 'disconnected', + serverState: 'disconnected', + placeholderData: {}, + }) + const coverId = store.createEntity('Block', { title: 'New cover' }) + store.setRelation('Page', 'page-1', 'cover', { currentId: coverId, state: 'connected' }) + dispatcher.getEventEmitter().interceptEntity('entity:persisting', 'Block', coverId, () => ({ + action: 'cancel', + })) + + await persister.persistAll() + + const relation = store.getRelation('Page', 'page-1', 'cover') + expect(relation?.serverId).toBeNull() + }) + + test('a later persist still sends the create for the un-vetoed child', async () => { + await persister.persistAll() + removeVeto() + calls.length = 0 + + await persister.persistAll() + + // The child must reach the server *attached to its parent*: either nested in the + // page mutation, or as a create plus a connect on the page. + const pageCall = calls.find(call => call.entityType === 'Page') + expect(pageCall?.changes['blocks']).toBeDefined() + }) +}) diff --git a/tests/unit/persistence/vetoedDeleteStaysPending.test.ts b/tests/unit/persistence/vetoedDeleteStaysPending.test.ts new file mode 100644 index 00000000..2e150de4 --- /dev/null +++ b/tests/unit/persistence/vetoedDeleteStaysPending.test.ts @@ -0,0 +1,111 @@ +/** + * A parent-side delete dropped for a vetoed item must stay planned after the parent's + * successful persist, so a later persist still sends it. + */ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + ActionDispatcher, + BatchPersister, + ContemberSchemaMutationAdapter, + MutationCollector, + SnapshotStore, + type BackendAdapter, + type SchemaNames, +} from '@contember/bindx' + +const schema: SchemaNames = { + entities: { + Page: { + name: 'Page', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + blocks: { type: 'many', entity: 'Block' }, + }, + }, + Block: { + name: 'Block', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + }, + }, + }, + enums: {}, +} + +interface PersistCall { + readonly entityType: string + readonly entityId: string + readonly changes: Record +} + +function createAdapter(calls: PersistCall[]): BackendAdapter { + return { + query: () => Promise.resolve([]), + persist: (entityType, entityId, changes) => { + calls.push({ entityType, entityId, changes }) + return Promise.resolve({ ok: true, data: { id: entityId } }) + }, + create: (_entityType, data) => Promise.resolve({ ok: true, data: { id: 'created-1', ...data } }), + delete: () => Promise.resolve({ ok: true }), + } +} + +/** + * A vetoed parent-side delete is dropped from the mutation, yet the parent is still + * committed on success — the planned removal moves into serverIds, so the item is + * gone from the client list although the server still has it and no delete was sent. + */ +describe('vetoed parent-side delete is committed anyway', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let calls: PersistCall[] + let persister: BatchPersister + let liftVeto: () => void + + beforeEach(() => { + store = new SnapshotStore() + dispatcher = new ActionDispatcher(store) + calls = [] + persister = new BatchPersister(createAdapter(calls), store, dispatcher, { + mutationCollector: new MutationCollector(store, new ContemberSchemaMutationAdapter(schema)), + }) + + store.setEntityData('Page', 'page-1', { id: 'page-1', title: 'Page' }, true) + store.setEntityData('Block', 'block-1', { id: 'block-1', title: 'Block' }, true) + store.getOrCreateHasMany('Page', 'page-1', 'blocks', ['block-1']) + // The page has an unrelated change, so its mutation is sent and committed. + store.setFieldValue('Page', 'page-1', ['title'], 'Edited page') + store.setFieldValue('Block', 'block-1', ['title'], 'Edited block') + store.planHasManyRemoval('Page', 'page-1', 'blocks', 'block-1', 'delete') + + liftVeto = dispatcher.getEventEmitter().interceptEntity('entity:persisting', 'Block', 'block-1', () => ({ + action: 'cancel', + })) + }) + + test('the removal stays pending when its delete was not sent', async () => { + await persister.persistAll() + + const pageCall = calls.find(call => call.entityType === 'Page') + expect(pageCall?.changes).toEqual({ title: 'Edited page' }) + + const hasMany = store.getHasMany('Page', 'page-1', 'blocks') + expect(hasMany?.serverIds).toContain('block-1') + expect([...(hasMany?.plannedRemovals.keys() ?? [])]).toContain('block-1') + }) + + test('a later persist still sends the delete once the veto is gone', async () => { + await persister.persistAll() + calls.length = 0 + liftVeto() + + await persister.persistAll() + + const pageCall = calls.find(call => call.entityType === 'Page') + expect(pageCall?.changes['blocks']).toEqual([{ delete: { id: 'block-1' }, alias: 'block-1' }]) + }) +}) From e439c713cbe4a61c653e66311cabffc3464b7779 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 15:50:50 +0200 Subject: [PATCH 40/55] fix(bindx-react): key useEntityList item accessors by their persisted id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list state keeps the temp id a draft was added under, so after a persist the cached accessor kept reporting the dead temp id as `item.id`. Item ids now go through the temp→persisted map (as HasManyListHandle.resolveItemKey does): the handle is minted under the server id, the temp key is evicted, and $remove matches either form of the id. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011YXekDQ4PbrgeNwZWESGfs --- .../src/hooks/ItemAccessorCache.ts | 12 +- .../bindx-react/src/hooks/useEntityList.ts | 37 +++--- .../hooks/useEntityList/persistRekey.test.tsx | 109 ++++++++++++++++++ 3 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 tests/react/hooks/useEntityList/persistRekey.test.tsx diff --git a/packages/bindx-react/src/hooks/ItemAccessorCache.ts b/packages/bindx-react/src/hooks/ItemAccessorCache.ts index edbd38aa..1a3ffbd2 100644 --- a/packages/bindx-react/src/hooks/ItemAccessorCache.ts +++ b/packages/bindx-react/src/hooks/ItemAccessorCache.ts @@ -19,8 +19,15 @@ import { EntityHandle } from '@contember/bindx' export class ItemAccessorCache { private readonly entries = new Map>() + /** + * @param createHandle builds the handle for a canonical id + * @param resolveId maps an id to its canonical form — a temp id follows its + * temp→persisted rekey, so the handle (and the `id` it reports) is minted under the + * server id and the dead temp key is evicted. Mirrors HasManyListHandle.resolveItemKey. + */ constructor( private readonly createHandle: (id: string) => EntityHandle, + private readonly resolveId: (id: string) => string, ) {} /** Rebuilds the accessor array; ids no longer listed are evicted so the cache stays bounded. */ @@ -29,8 +36,9 @@ export class ItemAccessorCache { const liveIds = new Set() for (const item of items) { - accessors.push(this.resolve(item.id)) - liveIds.add(item.id) + const id = this.resolveId(item.id) + accessors.push(this.resolve(id)) + liveIds.add(id) } for (const id of this.entries.keys()) { diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index ecc73426..d1b96ae5 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -1,6 +1,6 @@ import { useRef, useEffect, useMemo, useCallback } from 'react' import type { EntityDef, EntityAccessor, SelectionInput, SelectionMeta, FieldError, SchemaRegistry, CommonEntity, EntityForRoles, RoleNames } from '@contember/bindx' -import { EntityHandle, isTempId, resolveSelectionMeta, buildQueryFromSelection, refreshServerData, createLoadError } from '@contember/bindx' +import { EntityHandle, isTempId, isPersistedId, resolveSelectionMeta, buildQueryFromSelection, refreshServerData, createLoadError } from '@contember/bindx' import { useBindxContext, useSchemaRegistry } from './BackendAdapterContext.js' import { useStoreSubscription } from './useStoreSubscription.js' import { ItemAccessorCache } from './ItemAccessorCache.js' @@ -239,17 +239,27 @@ export function useEntityList( // whenever a handle construction input changes — handles are built against `selectionMeta` and // validate field access against it. A new cache also invalidates `listCacheRef`, so a widened // selection never serves the previous result with its narrow accessors. + // Canonical id of a list item: a temp id follows its temp→persisted rekey. The list + // state keeps the id it was given, so every id comparison goes through this. + const resolveItemId = useCallback( + (id: string): string => (isPersistedId(id) ? id : store.getPersistedId(entityType, id) ?? id), + [store, entityType], + ) + const itemAccessorCache = useMemo( - () => new ItemAccessorCache((id) => EntityHandle.createRaw( - id, - entityType, - store, - dispatcher, - schemaRegistry as SchemaRegistry>, - undefined, - selectionMeta, - )), - [entityType, store, dispatcher, schemaRegistry, selectionMeta], + () => new ItemAccessorCache( + (id) => EntityHandle.createRaw( + id, + entityType, + store, + dispatcher, + schemaRegistry as SchemaRegistry>, + undefined, + selectionMeta, + ), + resolveItemId, + ), + [entityType, store, dispatcher, schemaRegistry, selectionMeta, resolveItemId], ) // --- Store subscription --- @@ -284,11 +294,12 @@ export function useEntityList( } else { store.scheduleForDeletion(entityType, key) } - listStateRef.current.items = listStateRef.current.items.filter(item => item.id !== key) + const removedId = resolveItemId(key) + listStateRef.current.items = listStateRef.current.items.filter(item => resolveItemId(item.id) !== removedId) versionRef.current++ store.notify() }, - [entityType, store], + [entityType, store, resolveItemId], ) const moveItem = useCallback( diff --git a/tests/react/hooks/useEntityList/persistRekey.test.tsx b/tests/react/hooks/useEntityList/persistRekey.test.tsx new file mode 100644 index 00000000..7d6ba3fb --- /dev/null +++ b/tests/react/hooks/useEntityList/persistRekey.test.tsx @@ -0,0 +1,109 @@ +/** + * After $add() + persist, list item accessors report the server id, not the dead temp id. + */ +import '../../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, act, cleanup } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + MockAdapter, + defineSchema, + entityDef, + isTempId, + scalar, + useEntityList, + usePersist, + useSnapshotStore, +} from '@contember/bindx-react' + +afterEach(() => { + cleanup() +}) + +interface Author { + id: string + name: string +} + +interface TestSchema { + Author: Author +} + +const schema = defineSchema({ + entities: { + Author: { + fields: { + id: scalar(), + name: scalar(), + }, + }, + }, +}) + +const authorDef = entityDef('Author') + +describe('useEntityList item accessor across temp -> persisted rekey', () => { + test('reports the persisted id after $add + persist', async () => { + const adapter = new MockAdapter({ + Author: { + 'author-1': { id: 'author-1', name: 'John Doe' }, + }, + }, { delay: 0 }) + + let addAuthor: (() => string) | null = null + let persistAll: (() => Promise) | null = null + let renderedIds: string[] = [] + let readPersistedId: ((tempId: string) => string | null) | null = null + + function List(): React.ReactElement { + const store = useSnapshotStore() + const persist = usePersist() + readPersistedId = (id) => store.getPersistedId('Author', id) + const authors = useEntityList(authorDef, {}, a => a.id().name()) + persistAll = () => persist.persistAll() + if (authors.$status !== 'ready') return
+ addAuthor = () => authors.$add({ name: 'Fresh' }) + renderedIds = authors.items.map(item => item.id) + return ( +
    + {authors.items.map(item => ( +
  • {String(item.name.value)}
  • + ))} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => expect(container.querySelectorAll('[data-testid="row"]').length).toBe(1)) + + let tempId = '' + act(() => { + tempId = addAuthor!() + }) + await waitFor(() => expect(container.querySelectorAll('[data-testid="row"]').length).toBe(2)) + expect(renderedIds[1]).toBe(tempId) + + await act(async () => { + await persistAll!() + }) + + // The store rekeyed the draft to a server id... + const persistedId = readPersistedId!(tempId) + expect(persistedId).not.toBeNull() + expect(persistedId).not.toBe(tempId) + + // ...and the accessor the list hands out must address the persisted entity. + await waitFor(() => { + expect(renderedIds[1]).toBe(persistedId!) + }) + // The id is user-facing: React keys, routing, `useEntity({ by: { id } })` on a detail view. + expect(isTempId(renderedIds[1]!)).toBe(false) + expect(container.querySelectorAll('[data-testid="row"]')[1]!.textContent).toBe('Fresh') + }) +}) From f62863529272daf3526f416988b5e3d2b6d4d2c7 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 15:52:04 +0200 Subject: [PATCH 41/55] fix(bindx-dataview): stop merging a relation cell's rendered selection into the relation scope The analyzer's result was merged wholesale into the related entity's scope, so a cell that also rendered a relation of the row entity (`` inside an author column) put `tags` on the Organization selection and broke the grid query. Every component already registers through the scope of the ref it received; running the analyzer for those side effects is all that is needed, and it is the only attribution that tells row refs from related refs apart. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011YXekDQ4PbrgeNwZWESGfs --- .../src/createRelationColumn.tsx | 33 +++---- .../relationColumnRowFieldLeak.test.tsx | 94 +++++++++++++++++++ 2 files changed, 108 insertions(+), 19 deletions(-) create mode 100644 tests/react/dataview/relationColumnRowFieldLeak.test.tsx diff --git a/packages/bindx-dataview/src/createRelationColumn.tsx b/packages/bindx-dataview/src/createRelationColumn.tsx index 340099b8..7d1b0615 100644 --- a/packages/bindx-dataview/src/createRelationColumn.tsx +++ b/packages/bindx-dataview/src/createRelationColumn.tsx @@ -13,7 +13,7 @@ import React from 'react' import type { FieldRef, FilterArtifact, FilterHandler, EntityAccessor, SelectionMeta } from '@contember/bindx' import { SelectionScope } from '@contember/bindx' -import { createCollectorProxy, collectSelection as collectJsxSelection, SCOPE_REF } from '@contember/bindx-react' +import { createCollectorProxy, collectSelection as collectJsxSelection } from '@contember/bindx-react' import type { ColumnTypeDef } from './columnTypes.js' import { accessField } from './columnTypes.js' @@ -66,23 +66,18 @@ export interface RelationColumnProps { children: (entity: EntityAccessor) => React.ReactNode } -/** Reads a collector proxy's own SelectionScope; runtime refs do not carry one. */ -function readSelectionScope(target: unknown): SelectionScope | null { - if (target === null || typeof target !== 'object' || !(SCOPE_REF in target)) { - return null - } - const scope = target[SCOPE_REF] - return scope instanceof SelectionScope ? scope : null -} - /** - * Merges the selection declared by the cell renderer's returned JSX into the - * given scope. Proxy touches alone only see the refs passed as props, so fields - * declared by nested components would otherwise never be fetched. + * Walks the JSX a cell renderer returned so nested components declare their + * selection. Proxy touches alone only see the refs passed as props; a nested + * `` or `createComponent()` registers its fields only when analyzed. + * + * The analyzer's return value is deliberately discarded: every component + * registers through the scope of the ref it received, which is the only way to + * attribute a field to the right entity — the rendered JSX may mix refs of the + * related entity with refs of the row entity. */ -function mergeRenderedSelection(rendered: React.ReactNode, scope: SelectionScope | null): void { - if (!scope) return - scope.mergeFromSelectionMeta(collectJsxSelection(rendered)) +function analyzeRenderedSelection(rendered: React.ReactNode): void { + collectJsxSelection(rendered) } // ============================================================================ @@ -116,7 +111,7 @@ export function createRelationColumn( if (relatedEntityName && renderer) { const scope = new SelectionScope() const proxy = createCollectorProxy(scope, relatedEntityName) - mergeRenderedSelection(renderer(proxy), scope) + analyzeRenderedSelection(renderer(proxy)) relatedSelection = scope.toSelectionMeta() } @@ -196,7 +191,7 @@ export interface RelationColumnComponent { export const hasOneCellConfig: RelationCellConfig = { collectSelection: (renderer, fieldRef) => { - mergeRenderedSelection(renderer(fieldRef), readSelectionScope(fieldRef)) + analyzeRenderedSelection(renderer(fieldRef)) }, renderCell: (accessor, fieldName, renderer) => { const related = getRelatedAccessor(accessor, fieldName) @@ -210,7 +205,7 @@ export const hasManyCellConfig: RelationCellConfig = { const ref = fieldRef as { map?: (fn: (item: unknown, index: number) => unknown) => unknown[] } const rendered: React.ReactNode[] = [] ref.map?.((item) => { rendered.push(renderer(item)); return null }) - mergeRenderedSelection(rendered, readSelectionScope(fieldRef)) + analyzeRenderedSelection(rendered) }, renderCell: (accessor, fieldName, renderer) => { const ref = accessField(accessor, fieldName) as { items?: EntityAccessor[] } | null diff --git a/tests/react/dataview/relationColumnRowFieldLeak.test.tsx b/tests/react/dataview/relationColumnRowFieldLeak.test.tsx new file mode 100644 index 00000000..5c7df1f9 --- /dev/null +++ b/tests/react/dataview/relationColumnRowFieldLeak.test.tsx @@ -0,0 +1,94 @@ +/** + * A relation column's cell renderer may render relations of the ROW entity too; those + * must stay on the row selection and never land in the related entity's selection. + */ +import '../../setup' +import { describe, test, expect } from 'bun:test' +import React from 'react' +import { + DataGridHasOneColumn, + DataGridHasManyColumn, + extractColumnLeaves, + type ColumnLeafProps, +} from '@contember/bindx-dataview' +import { + Field, + HasMany, + HasOne, + createCollectorProxy, + defineSchema, + scalar, + hasOne, + hasMany, +} from '@contember/bindx-react' +import { SelectionScope, SchemaRegistry, type EntityAccessor, type SelectionMeta } from '@contember/bindx' + +interface Member { id: string; fullName: string } +interface Organization { id: string; name: string; members: Member[] } +interface Tag { id: string; label: string } +interface Project { id: string; name: string; organization: Organization | null; tags: Tag[] } + +const testSchema = defineSchema<{ Project: Project; Organization: Organization; Tag: Tag; Member: Member }>({ + entities: { + Project: { fields: { id: scalar(), name: scalar(), organization: hasOne('Organization'), tags: hasMany('Tag') } }, + Organization: { fields: { id: scalar(), name: scalar(), members: hasMany('Member') } }, + Tag: { fields: { id: scalar(), label: scalar() } }, + Member: { fields: { id: scalar(), fullName: scalar() } }, + }, +}) +const schemaRegistry = new SchemaRegistry(testSchema) + +function buildColumn(build: (it: EntityAccessor) => React.ReactNode): { + rowSelection: SelectionMeta + leaf: ColumnLeafProps +} { + const scope = new SelectionScope() + const collector = createCollectorProxy(scope, 'Project', schemaRegistry) + const leaves = extractColumnLeaves(build(collector)) + expect(leaves).toHaveLength(1) + const leaf = leaves[0]! + leaf.collectSelection?.(collector) + return { rowSelection: scope.toSelectionMeta(), leaf } +} + +function fieldNames(selection: SelectionMeta | undefined): string[] { + return [...(selection?.fields.keys() ?? [])].sort() +} + +describe('relation column selection must stay on the related entity', () => { + test('hasOne column: a row-entity hasMany rendered in the cell must not land in the relation selection', () => { + const { rowSelection, leaf } = buildColumn(it => ( + + {org => ( + <> + {org.name.value} + {tag => } + + )} + + )) + + // `tags` belongs to Project, not to Organization. + expect(fieldNames(rowSelection.fields.get('organization')?.nested)).toEqual(['id', 'name']) + expect(fieldNames(leaf.relatedSelection)).not.toContain('tags') + // The row-level selection itself is correct. + expect(rowSelection.fields.has('tags')).toBe(true) + }) + + test('hasMany column: a row-entity hasOne rendered in the cell must not land in the relation selection', () => { + const { rowSelection, leaf } = buildColumn(it => ( + + {tag => ( + <> + + {org => } + + )} + + )) + + // `organization` belongs to Project, not to Tag. + expect(fieldNames(rowSelection.fields.get('tags')?.nested)).toEqual(['id', 'label']) + expect(fieldNames(leaf.relatedSelection)).not.toContain('organization') + }) +}) From 87d005fa7a9871d8a8e9a2e4073588b61b8cbeac Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 15:53:00 +0200 Subject: [PATCH 42/55] fix(bindx): connect, not re-create, a planned has-many addition whose item was persisted on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 'created' planned addition survived the item's temp→persisted rekey with its kind intact, so a child persisted separately (e.g. its parent's create was vetoed) was created a second time, inline, once the parent went out. The rekey now turns the addition into a 'connected' one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011YXekDQ4PbrgeNwZWESGfs --- packages/bindx/src/store/HasManyStore.ts | 8 +- .../persistedChildConnectsNotCreates.test.ts | 103 ++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 tests/unit/persistence/persistedChildConnectsNotCreates.test.ts diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index 723a639c..5ea4eaf0 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -648,11 +648,13 @@ export class HasManyStore { } let plannedAdditions = state.plannedAdditions - const additionKind = plannedAdditions.get(oldId) - if (additionKind !== undefined) { + if (plannedAdditions.has(oldId)) { plannedAdditions = new Map(plannedAdditions) plannedAdditions.delete(oldId) - plannedAdditions.set(newId, additionKind) + // The id only changes when the item was persisted, so a still-planned + // 'created' addition (the item went out on its own, not nested in this + // parent) must now connect the server row rather than create a second one. + plannedAdditions.set(newId, 'connected') changed = true } diff --git a/tests/unit/persistence/persistedChildConnectsNotCreates.test.ts b/tests/unit/persistence/persistedChildConnectsNotCreates.test.ts new file mode 100644 index 00000000..fe91b108 --- /dev/null +++ b/tests/unit/persistence/persistedChildConnectsNotCreates.test.ts @@ -0,0 +1,103 @@ +/** + * A child created on its own (its parent create was vetoed) must be connected, not + * created again, when the parent finally goes out. + */ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + ActionDispatcher, + BatchPersister, + ContemberSchemaMutationAdapter, + MutationCollector, + SnapshotStore, + type BackendAdapter, + type SchemaNames, +} from '@contember/bindx' + +const schema: SchemaNames = { + entities: { + Page: { + name: 'Page', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + blocks: { type: 'many', entity: 'Block' }, + }, + }, + Block: { + name: 'Block', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + }, + }, + }, + enums: {}, +} + +interface CreateCall { + readonly entityType: string + readonly data: Record +} + +function createAdapter(creates: CreateCall[]): BackendAdapter { + let counter = 0 + return { + query: () => Promise.resolve([]), + persist: (entityType, entityId) => Promise.resolve({ ok: true, data: { id: entityId } }), + create: (entityType, data) => { + creates.push({ entityType, data }) + counter++ + return Promise.resolve({ ok: true, data: { id: `${entityType.toLowerCase()}-server-${counter}`, ...data } }) + }, + delete: () => Promise.resolve({ ok: true }), + } +} + +/** + * Vetoing the parent create leaves its child create in the batch. The child is then + * sent standalone (it is no longer a nested create), so the next persist of the + * now-accepted parent must CONNECT that child, not create a second copy of it. + */ +describe('vetoed parent create with an accepted child', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let creates: CreateCall[] + let persister: BatchPersister + let pageId: string + let blockId: string + let removeVeto: () => void + + beforeEach(() => { + store = new SnapshotStore() + dispatcher = new ActionDispatcher(store) + creates = [] + persister = new BatchPersister(createAdapter(creates), store, dispatcher, { + mutationCollector: new MutationCollector(store, new ContemberSchemaMutationAdapter(schema)), + }) + + pageId = store.createEntity('Page', { title: 'New page' }) + blockId = store.createEntity('Block', { title: 'New block' }) + store.getOrCreateHasMany('Page', pageId, 'blocks', []) + store.addToHasMany('Page', pageId, 'blocks', blockId) + + removeVeto = dispatcher.getEventEmitter().interceptEntity( + 'entity:persisting', 'Page', pageId, () => ({ action: 'cancel' }), + ) + }) + + test('the child is created exactly once across both persists', async () => { + await persister.persistAll() + removeVeto() + await persister.persistAll() + + const blockCreates = creates.filter(call => call.entityType === 'Block') + const nestedBlockCreates = creates + .filter(call => call.entityType === 'Page') + .flatMap(call => (Array.isArray(call.data['blocks']) ? call.data['blocks'] : [])) + .filter(op => typeof op === 'object' && op !== null && 'create' in op) + + expect(blockCreates.length + nestedBlockCreates.length).toBe(1) + }) +}) From 6358dc15ca976bfa90221457128ff47654fe6ed6 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 15:54:30 +0200 Subject: [PATCH 43/55] fix(bindx): offer collector-materialized entities to entity:persisting A placeholder has-one is turned into a real entity inside collectUpdateData, after the hook phase has run, so its interceptors never fired although it did get entity:persisted. Nested entities the collector registers that were not in the batch are now offered to the interceptors; a veto drops them and rebuilds the mutations once. The extra await only happens when such an interceptor is registered, so a plain persist still reaches the adapter synchronously. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011YXekDQ4PbrgeNwZWESGfs --- .../bindx/src/persistence/BatchPersister.ts | 50 +++++++- .../materializedEntityPersistingHook.test.ts | 113 ++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 tests/unit/persistence/materializedEntityPersistingHook.test.ts diff --git a/packages/bindx/src/persistence/BatchPersister.ts b/packages/bindx/src/persistence/BatchPersister.ts index 8f7d5fdd..897ad023 100644 --- a/packages/bindx/src/persistence/BatchPersister.ts +++ b/packages/bindx/src/persistence/BatchPersister.ts @@ -279,7 +279,17 @@ export class BatchPersister { // mutated to the server view — pessimistic mode presents the server // baseline via getPresentationSnapshot instead — so there is nothing to // capture or restore. - const mutations = this.buildMutations(sortedEntities, vetoedEntityIds, scope) + let mutations = this.buildMutations(sortedEntities, vetoedEntityIds, scope) + + // Entities that only came into being during collection (placeholder relations + // materialized by the collector) were not dirty when the hook phase ran, yet + // they do get `entity:persisted` — offer them to the interceptors too. Awaited + // only when one is registered, so a plain persist still reaches the adapter + // synchronously. + const lateEntities = this.collectLateNestedEntities(sortedEntities, vetoedEntityIds) + if (lateEntities.length > 0 && this.hasPersistingInterceptors(lateEntities)) { + mutations = await this.rebuildAfterLateVetoes(sortedEntities, vetoedEntityIds, scope, lateEntities, mutations) + } if (mutations.length === 0) { // Nothing to persist @@ -322,6 +332,44 @@ export class BatchPersister { } } + /** + * Runs the `entity:persisting` interceptors for late nested entities. A veto drops + * them and rebuilds the mutations once; materialization is idempotent, so the + * second pass sees the same store. + */ + private async rebuildAfterLateVetoes( + sortedEntities: DirtyEntity[], + vetoedEntityIds: ReadonlySet, + scope: PersistScope, + lateEntities: readonly DirtyEntity[], + mutations: TransactionMutation[], + ): Promise { + const { cancelled } = await this.runPersistingInterceptors(lateEntities) + if (cancelled.length === 0) return mutations + + const vetoed = new Set(vetoedEntityIds) + for (const entity of cancelled) vetoed.add(entity.entityId) + return this.buildMutations(sortedEntities, vetoed, scope) + } + + /** + * Nested entities the collector registered that were not part of the hook phase. + */ + private collectLateNestedEntities( + sortedEntities: readonly DirtyEntity[], + vetoedEntityIds: ReadonlySet, + ): DirtyEntity[] { + if (!(this.mutationCollector instanceof MutationCollector)) return [] + + const known = new Set(sortedEntities.map(entity => entity.entityId)) + const late: DirtyEntity[] = [] + for (const [entityId, entityType] of this.mutationCollector.getNestedEntityTypes()) { + if (known.has(entityId) || vetoedEntityIds.has(entityId)) continue + late.push({ entityType, entityId, changeType: 'create', dirtyFields: [], dirtyRelations: [] }) + } + return late + } + /** * Commits an entity's relations after a successful persist, except the planned ops * the collector dropped for vetoed items — those stay pending so the next persist diff --git a/tests/unit/persistence/materializedEntityPersistingHook.test.ts b/tests/unit/persistence/materializedEntityPersistingHook.test.ts new file mode 100644 index 00000000..6dc4fb9a --- /dev/null +++ b/tests/unit/persistence/materializedEntityPersistingHook.test.ts @@ -0,0 +1,113 @@ +/** + * An entity the collector materializes during mutation building (a placeholder has-one) + * must be offered to `entity:persisting` like any other entity that ends up persisted. + */ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + ActionDispatcher, + BatchPersister, + ContemberSchemaMutationAdapter, + MutationCollector, + SnapshotStore, + type BackendAdapter, + type SchemaNames, +} from '@contember/bindx' + +const schema: SchemaNames = { + entities: { + Lecturer: { + name: 'Lecturer', + scalars: ['id', 'status'], + fields: { + id: { type: 'column' }, + status: { type: 'column' }, + user: { type: 'one', entity: 'User' }, + }, + }, + User: { + name: 'User', + scalars: ['id', 'firstName'], + fields: { + id: { type: 'column' }, + firstName: { type: 'column' }, + }, + }, + }, + enums: {}, +} + +interface PersistCall { + readonly entityType: string + readonly entityId: string + readonly changes: Record +} + +function createAdapter(calls: PersistCall[]): BackendAdapter { + return { + query: () => Promise.resolve([]), + persist: (entityType, entityId, changes) => { + calls.push({ entityType, entityId, changes }) + return Promise.resolve({ ok: true, data: { id: entityId, user: { id: 'user-1', firstName: 'Jan' } } }) + }, + create: (_entityType, data) => Promise.resolve({ ok: true, data: { id: 'created-1', ...data } }), + delete: () => Promise.resolve({ ok: true }), + } +} + +/** + * A placeholder-backed hasOne is materialized into a real entity while the mutation + * is being built — after the `entity:persisting` pipeline has already run. The entity + * is therefore written (and gets an `entity:persisted` event) without ever having + * been offered to an interceptor, so a global veto cannot stop it. + */ +describe('entity materialized during collection skips the persisting pipeline', () => { + let store: SnapshotStore + let dispatcher: ActionDispatcher + let calls: PersistCall[] + let persister: BatchPersister + + beforeEach(() => { + store = new SnapshotStore() + dispatcher = new ActionDispatcher(store) + calls = [] + persister = new BatchPersister(createAdapter(calls), store, dispatcher, { + mutationCollector: new MutationCollector(store, new ContemberSchemaMutationAdapter(schema)), + }) + + store.setEntityData('Lecturer', 'lect-1', { id: 'lect-1', status: 'draft' }, true) + store.setFieldValue('Lecturer', 'lect-1', ['status'], 'active') + store.getOrCreateRelation('Lecturer', 'lect-1', 'user', { + currentId: null, + serverId: null, + state: 'creating', + serverState: 'disconnected', + placeholderData: { firstName: 'Jan' }, + }) + }) + + test('every entity that reports persisted was offered to the interceptors', async () => { + const offered: string[] = [] + const persisted: string[] = [] + dispatcher.getEventEmitter().intercept('entity:persisting', event => { + offered.push(event.entityType) + return { action: 'continue' } + }) + dispatcher.getEventEmitter().on('entity:persisted', event => persisted.push(event.entityType)) + + await persister.persistAll() + + for (const entityType of persisted) { + expect(offered).toContain(entityType) + } + }) + + test('a global veto of the child type stops it from being written', async () => { + dispatcher.getEventEmitter().intercept('entity:persisting', event => ( + event.entityType === 'User' ? { action: 'cancel' } : { action: 'continue' } + )) + + await persister.persistAll() + + expect(calls[0]?.changes).toEqual({ status: 'active' }) + }) +}) From 84db8106b1e883051dd02d33335d7a50c62ab881 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 15:59:17 +0200 Subject: [PATCH 44/55] fix(bindx): enumerate entity accessors as id + selected fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handle proxy had no ownKeys/getOwnPropertyDescriptor traps, so for…in and Object.keys listed the handle's instance fields (store, dispatcher, schema, …) and any generic walker reading them went through the field-access get trap — React's dev prop-diff logger did exactly that whenever an accessor was passed as a prop, threw UnfetchedFieldError during commit and wedged the render loop. Enumeration now yields `id` plus the selection's field names (nothing beyond `id` without a selection); the handle's internals stay reachable only through the `$` aliases. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011YXekDQ4PbrgeNwZWESGfs --- packages/bindx/src/handles/EntityHandle.ts | 19 ++++- packages/bindx/src/handles/HasOneHandle.ts | 8 +- .../bindx/src/handles/PlaceholderHandle.ts | 5 ++ packages/bindx/src/handles/proxyFactory.ts | 27 +++++++ tests/react/jsx/accessorAsProp.test.tsx | 66 ++++++++++++++++ tests/unit/handles/proxyEnumeration.test.ts | 79 +++++++++++++++++++ 6 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 tests/react/jsx/accessorAsProp.test.tsx create mode 100644 tests/unit/handles/proxyEnumeration.test.ts diff --git a/packages/bindx/src/handles/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index 07ebc267..0289927e 100644 --- a/packages/bindx/src/handles/EntityHandle.ts +++ b/packages/bindx/src/handles/EntityHandle.ts @@ -110,7 +110,24 @@ export class EntityHandle extends Enti } static wrapProxy(handle: EntityHandle): EntityAccessor { - return createHandleProxy, EntityAccessor>(handle, (target) => target.fields) + return createHandleProxy, EntityAccessor>( + handle, + (target) => target.fields, + (target) => target.selectedFieldNames, + ) + } + + /** + * Field names the handle's selection exposes, for enumeration. Without a selection + * nothing is validated and nothing is listed. + */ + get selectedFieldNames(): readonly string[] { + if (!this.selection) return [] + const names = new Set() + for (const meta of this.selection.fields.values()) { + names.add(meta.fieldName) + } + return [...names] } get [FIELD_REF_META](): FieldRefMeta { diff --git a/packages/bindx/src/handles/HasOneHandle.ts b/packages/bindx/src/handles/HasOneHandle.ts index ac6d77a8..b390489a 100644 --- a/packages/bindx/src/handles/HasOneHandle.ts +++ b/packages/bindx/src/handles/HasOneHandle.ts @@ -81,7 +81,7 @@ export class HasOneHandle brands?: Set, selection?: SelectionMeta, ): HasOneAccessor { - return createHandleProxy, HasOneAccessor>(new HasOneHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection), (target) => target.entityRaw.fields) + return HasOneHandle.wrapProxy(new HasOneHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection)) } static createRaw( @@ -99,7 +99,11 @@ export class HasOneHandle } static wrapProxy(handle: HasOneHandle): HasOneAccessor { - return createHandleProxy, HasOneAccessor>(handle, (target) => target.entityRaw.fields) + return createHandleProxy, HasOneAccessor>( + handle, + (target) => target.entityRaw.fields, + (target) => target.entityRaw.selectedFieldNames, + ) } /** diff --git a/packages/bindx/src/handles/PlaceholderHandle.ts b/packages/bindx/src/handles/PlaceholderHandle.ts index 79b17c38..4c5f4b7f 100644 --- a/packages/bindx/src/handles/PlaceholderHandle.ts +++ b/packages/bindx/src/handles/PlaceholderHandle.ts @@ -89,6 +89,11 @@ export class PlaceholderHandle, EntityAccessor>(handle, (target) => target.fields) } + /** A placeholder carries no selection, so enumeration lists nothing beyond `id`. */ + get selectedFieldNames(): readonly string[] { + return [] + } + /** * Gets the placeholder ID. */ diff --git a/packages/bindx/src/handles/proxyFactory.ts b/packages/bindx/src/handles/proxyFactory.ts index bd9f6f25..8564c031 100644 --- a/packages/bindx/src/handles/proxyFactory.ts +++ b/packages/bindx/src/handles/proxyFactory.ts @@ -24,6 +24,8 @@ const HANDLE_PASSTHROUGH_PROPERTIES = new Set([ FIELD_REF_META, ]) +const NO_FIELD_NAMES = (): readonly string[] => [] + /** * Creates a proxy around a handle that supports direct field access. * @@ -33,14 +35,23 @@ const HANDLE_PASSTHROUGH_PROPERTIES = new Set([ * 3. `$xxx` → strip `$`, access handle property * 4. Everything else → field access (returns FieldHandle/HasOneHandle/HasManyListHandle) * + * Enumeration (`for…in`, `Object.keys`) sees `id` plus the selected field names, never + * the handle's own instance fields: a generic walker (React's dev prop-diff logger, + * a debugger) reading those through the `get` trap would hit field access and throw + * `UnfetchedFieldError` for `store`, `dispatcher`, ... + * * @param handle - The handle instance to wrap * @param getFields - Function to get the fields object from the handle + * @param getFieldNames - Function to list the selected field names (enumeration only) * @returns Proxied handle with direct field access support */ export function createHandleProxy( handle: T, getFields: (target: T) => object, + getFieldNames: (target: T) => readonly string[] = NO_FIELD_NAMES, ): TResult { + const ownKeys = (target: T): string[] => ['id', ...getFieldNames(target).filter(name => name !== 'id')] + // The proxy adds $ alias support and field access, making the handle satisfy the public type TResult at runtime return new Proxy(handle, { get(target, prop, _receiver) { @@ -76,6 +87,22 @@ export function createHandleProxy( } return Reflect.has(target, prop) }, + + ownKeys(target) { + return ownKeys(target) + }, + + getOwnPropertyDescriptor(target, prop) { + if (typeof prop !== 'string' || !ownKeys(target).includes(prop)) { + return undefined + } + const value = prop === 'id' + ? Reflect.get(target, prop, target) + : (getFields(target) as Record)[prop] + // Configurable: the target's own properties are configurable too, so the + // proxy invariants allow a descriptor that differs from the target's. + return { value, enumerable: true, configurable: true, writable: false } + }, }) as unknown as TResult } diff --git a/tests/react/jsx/accessorAsProp.test.tsx b/tests/react/jsx/accessorAsProp.test.tsx new file mode 100644 index 00000000..73863a71 --- /dev/null +++ b/tests/react/jsx/accessorAsProp.test.tsx @@ -0,0 +1,66 @@ +/** + * An entity accessor passed as a prop must survive generic enumeration. React's dev + * build logs a prop diff for every changed prop by reading each own key of the value; + * without an ownKeys trap that walk reached the handle's instance fields through the + * field-access `get` trap and threw UnfetchedFieldError mid-commit. + */ +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, act, cleanup } from '@testing-library/react' +import React from 'react' +import { BindxProvider, Field, MockAdapter, useEntity, type EntityRef } from '@contember/bindx-react' +import { createMockData, schema, testSchema, type Author } from '../../shared' + +afterEach(() => { + cleanup() +}) + +// React 19's development build logs a prop diff for every fiber whose props changed +// (react-dom-client.development.js `logComponentRender` -> `addObjectDiffToProperties` -> +// `addObjectToProperties`). That helper does `for (const key in props[name])` and then READS +// every own key off the value. An EntityAccessor is a Proxy with no `ownKeys` trap, so `for..in` +// yields the EntityHandle's own instance fields (`store`, `dispatcher`, `schema`, ...) and the +// `get` trap routes each of them into field access, which throws UnfetchedFieldError. +// +// The gate is `typeof console.timeStamp === 'function' && typeof performance.measure === 'function'` +// — true in every browser — so this fires in any dev build as soon as an entity accessor is +// passed as a prop to a component whose props change. +// +// Passing an entity accessor down as a prop is the documented pattern (createComponent() +// entity props, DataGrid rows, ``). + +describe('entity accessor passed as a prop', () => { + test('does not explode when React logs the prop diff', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + let show: (() => void) | null = null + + function Badge({ author }: { author?: EntityRef> }): React.ReactElement { + if (!author) return none + return + } + + function Host(): React.ReactElement { + const [visible, setVisible] = React.useState(false) + show = () => setVisible(true) + const author = useEntity(schema.Author, { by: { id: 'author-1' } }, a => a.id().name()) + if (author.$isLoading) return
+ if (author.$isError || author.$isNotFound) return
+ return + } + + const { container } = render( + + + , + ) + + await waitFor(() => expect(container.querySelector('[data-testid="badge-empty"]')).not.toBeNull()) + + // The prop goes undefined -> accessor, so React logs the diff of `author`. + act(() => { + show!() + }) + + expect(container.querySelector('[data-testid="badge"]')!.textContent).toBe('John Doe') + }) +}) diff --git a/tests/unit/handles/proxyEnumeration.test.ts b/tests/unit/handles/proxyEnumeration.test.ts new file mode 100644 index 00000000..47f9b07b --- /dev/null +++ b/tests/unit/handles/proxyEnumeration.test.ts @@ -0,0 +1,79 @@ +import { describe, test, expect } from 'bun:test' +import { + ActionDispatcher, + EntityHandle, + SchemaRegistry, + SnapshotStore, + type SchemaDefinition, + type SelectionMeta, +} from '@contember/bindx' + +interface TestArticle { + id: string + title: string +} + +interface TestSchema { + Article: TestArticle + [key: string]: object +} + +const schemaDefinition: SchemaDefinition = { + entities: { + Article: { + fields: { + id: { type: 'scalar' }, + title: { type: 'scalar' }, + }, + }, + }, +} + +const selection: SelectionMeta = { + fields: new Map([ + ['id', { fieldName: 'id', alias: 'id', path: ['id'], isArray: false, isRelation: false }], + ['title', { fieldName: 'title', alias: 'title', path: ['title'], isArray: false, isRelation: false }], + ]), +} + +function createHandle(selected?: SelectionMeta): EntityHandle { + const store = new SnapshotStore() + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Hello' }, true) + return EntityHandle.createRaw( + 'a-1', + 'Article', + store, + new ActionDispatcher(store), + new SchemaRegistry(schemaDefinition), + undefined, + selected, + ) +} + +/** + * Enumerating an entity accessor must list `id` and the selected fields only — never + * the handle's instance fields, which a generic walker would then read as entity + * fields and trip selection validation. + */ +describe('entity accessor enumeration', () => { + test('lists id and the selected fields', () => { + const accessor = EntityHandle.wrapProxy(createHandle(selection)) + expect(Object.keys(accessor)).toEqual(['id', 'title']) + const seen: string[] = [] + for (const key in accessor) seen.push(key) + expect(seen).toEqual(['id', 'title']) + }) + + test('reading every enumerated key does not throw', () => { + const accessor = EntityHandle.wrapProxy(createHandle(selection)) + for (const [key, value] of Object.entries(accessor)) { + expect(value, key).toBeDefined() + } + expect(Object.getOwnPropertyDescriptor(accessor, 'store')).toBeUndefined() + }) + + test('without a selection only id is listed', () => { + const accessor = EntityHandle.wrapProxy(createHandle()) + expect(Object.keys(accessor)).toEqual(['id']) + }) +}) From 0eb302c7efe990c598e9667df542e1793b4dd550 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 20:13:04 +0200 Subject: [PATCH 45/55] fix(bindx): reconcile persisted relation baselines exactly --- packages/bindx/src/store/HasManyStore.ts | 85 ++++++ packages/bindx/src/store/HasOneStore.ts | 87 ++++++ packages/bindx/src/store/RelationStore.ts | 33 ++- packages/bindx/src/store/SnapshotStore.ts | 39 ++- ...stedRelationBaselineReconciliation.test.ts | 255 ++++++++++++++++++ 5 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 tests/unit/store/persistedRelationBaselineReconciliation.test.ts diff --git a/packages/bindx/src/store/HasManyStore.ts b/packages/bindx/src/store/HasManyStore.ts index 5ea4eaf0..90580292 100644 --- a/packages/bindx/src/store/HasManyStore.ts +++ b/packages/bindx/src/store/HasManyStore.ts @@ -1,6 +1,8 @@ import { parentKeyFromOwnerPrefix, parentKeyFromRelationKey } from './relationKey.js' import { RelationEdgeIndex } from './RelationEdgeIndex.js' +type ReconciliationResult = 'applied' | 'conflict' + function setsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) return false for (const item of a) { @@ -29,6 +31,21 @@ export type HasManyRemovalType = 'disconnect' | 'delete' */ export type HasManyAdditionKind = 'created' | 'connected' +export interface SentHasManyAddition { + itemId: string + kind: HasManyAdditionKind +} + +export interface SentHasManyRemoval { + itemId: string + type: HasManyRemovalType +} + +export interface SentHasManyDelta { + additions: readonly SentHasManyAddition[] + removals: readonly SentHasManyRemoval[] +} + /** * Has-many list state stored in SnapshotStore */ @@ -294,6 +311,19 @@ export class HasManyStore { }) } + /** + * Advances the server baseline by the confirmed sent delta and rebases local + * edits made after the request started onto that new baseline. + */ + reconcileSentDelta(key: string, delta: SentHasManyDelta): ReconciliationResult { + const existing = this.hasManyStates.get(key) + if (!existing) return 'conflict' + + const next = reconcileHasManyState(existing, delta) + this.writeHasMany(key, next.state) + return next.result + } + /** * Resets has-many state to server state (clears planned operations). */ @@ -723,3 +753,58 @@ function liveHasManyChildIds(state: StoredHasManyState | undefined): Set } return live } + +interface HasManyReconciliation { + state: StoredHasManyState + result: ReconciliationResult +} + +function reconcileHasManyState( + existing: StoredHasManyState, + delta: SentHasManyDelta, +): HasManyReconciliation { + const currentLive = liveHasManyChildIds(existing) + const serverIds = new Set(existing.serverIds) + const plannedAdditions = new Map(existing.plannedAdditions) + const plannedRemovals = new Map(existing.plannedRemovals) + let result: ReconciliationResult = 'applied' + + for (const addition of delta.additions) { + serverIds.add(addition.itemId) + plannedAdditions.delete(addition.itemId) + plannedRemovals.delete(addition.itemId) + if (!currentLive.has(addition.itemId)) { + plannedRemovals.set( + addition.itemId, + addition.kind === 'created' ? 'delete' : 'disconnect', + ) + } + } + + for (const removal of delta.removals) { + serverIds.delete(removal.itemId) + const currentRemoval = plannedRemovals.get(removal.itemId) + if (currentRemoval === removal.type) plannedRemovals.delete(removal.itemId) + + if (!currentLive.has(removal.itemId)) { + if (removal.type === 'delete') plannedRemovals.delete(removal.itemId) + continue + } + if (removal.type === 'delete') { + result = 'conflict' + } else if (!plannedAdditions.has(removal.itemId)) { + plannedAdditions.set(removal.itemId, 'connected') + } + } + + return { + state: { + serverIds, + orderedIds: existing.orderedIds ? [...existing.orderedIds] : null, + plannedRemovals, + plannedAdditions, + version: existing.version + 1, + }, + result, + } +} diff --git a/packages/bindx/src/store/HasOneStore.ts b/packages/bindx/src/store/HasOneStore.ts index 1819b02a..f9cb94cb 100644 --- a/packages/bindx/src/store/HasOneStore.ts +++ b/packages/bindx/src/store/HasOneStore.ts @@ -15,6 +15,14 @@ export interface StoredRelationState { version: number } +type ReconciliationResult = 'applied' | 'conflict' + +export type SentHasOneTransition = + | { operation: 'connect'; targetId: string } + | { operation: 'create'; targetId: string } + | { operation: 'disconnect'; targetId: string } + | { operation: 'delete'; targetId: string } + function cloneRelationState(state: StoredRelationState): StoredRelationState { return { ...state, @@ -169,6 +177,22 @@ export class HasOneStore { }) } + /** + * Advances the server baseline by one confirmed sent transition while keeping + * edits made after the request started as pending local state. + */ + reconcileSentTransition( + key: string, + transition: SentHasOneTransition, + ): ReconciliationResult { + const existing = this.relationStates.get(key) + if (!existing) return 'conflict' + + const next = reconcileHasOneState(existing, transition) + this.writeRelation(key, next.state) + return next.result + } + /** * Resets relation to server state. */ @@ -365,3 +389,66 @@ function liveHasOneChildId(state: StoredRelationState | undefined): string | nul if (!state) return null return state.currentId !== null && state.state !== 'deleted' ? state.currentId : null } + +interface HasOneReconciliation { + state: StoredRelationState + result: ReconciliationResult +} + +function reconcileHasOneState( + existing: StoredRelationState, + transition: SentHasOneTransition, +): HasOneReconciliation { + if (transition.operation === 'connect' || transition.operation === 'create') { + return reconcileHasOneAddition(existing, transition) + } + return reconcileHasOneRemoval(existing, transition) +} + +function reconcileHasOneAddition( + existing: StoredRelationState, + transition: Extract, +): HasOneReconciliation { + const wasRemoved = existing.currentId !== transition.targetId || existing.state !== 'connected' + const removedCreatedTarget = transition.operation === 'create' + && existing.currentId === null + && existing.state === 'disconnected' + + return { + state: { + ...existing, + currentId: removedCreatedTarget ? transition.targetId : existing.currentId, + serverId: transition.targetId, + state: removedCreatedTarget ? 'deleted' : existing.state, + serverState: 'connected', + placeholderData: wasRemoved ? existing.placeholderData : {}, + version: existing.version + 1, + }, + result: 'applied', + } +} + +function reconcileHasOneRemoval( + existing: StoredRelationState, + transition: Extract, +): HasOneReconciliation { + const isCompletedDelete = transition.operation === 'delete' + && existing.currentId === transition.targetId + && existing.state === 'deleted' + const isDeleteReversal = transition.operation === 'delete' + && existing.currentId === transition.targetId + && existing.state !== 'deleted' + + return { + state: { + ...existing, + currentId: isCompletedDelete ? null : existing.currentId, + serverId: null, + state: isCompletedDelete ? 'disconnected' : existing.state, + serverState: 'disconnected', + placeholderData: isCompletedDelete ? {} : existing.placeholderData, + version: existing.version + 1, + }, + result: isDeleteReversal ? 'conflict' : 'applied', + } +} diff --git a/packages/bindx/src/store/RelationStore.ts b/packages/bindx/src/store/RelationStore.ts index 6adc621b..9864d7b2 100644 --- a/packages/bindx/src/store/RelationStore.ts +++ b/packages/bindx/src/store/RelationStore.ts @@ -1,19 +1,34 @@ import type { EntitySnapshot } from './snapshots.js' import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js' -import { HasOneStore, type StoredRelationState } from './HasOneStore.js' +import { + HasOneStore, + type SentHasOneTransition, + type StoredRelationState, +} from './HasOneStore.js' import { HasManyStore, computeDefaultOrderedIds, type HasManyAdditionKind, type HasManyRemovalType, + type SentHasManyDelta, type StoredHasManyState, } from './HasManyStore.js' // Re-exported so existing imports from './RelationStore.js' keep resolving. export type { StoredRelationState } from './HasOneStore.js' -export type { HasManyAdditionKind, HasManyRemovalType, StoredHasManyState } from './HasManyStore.js' +export type { SentHasOneTransition } from './HasOneStore.js' +export type { + HasManyAdditionKind, + HasManyRemovalType, + SentHasManyAddition, + SentHasManyDelta, + SentHasManyRemoval, + StoredHasManyState, +} from './HasManyStore.js' export { computeDefaultOrderedIds } from './HasManyStore.js' +export type RelationReconciliationResult = 'applied' | 'conflict' + /** * Manages has-one and has-many relation state. * @@ -75,6 +90,13 @@ export class RelationStore implements Rekeyable { this.hasOne.commitRelation(key) } + reconcileSentRelation( + key: string, + transition: SentHasOneTransition, + ): RelationReconciliationResult { + return this.hasOne.reconcileSentTransition(key, transition) + } + resetRelation(key: string): void { this.hasOne.resetRelation(key) } @@ -105,6 +127,13 @@ export class RelationStore implements Rekeyable { this.hasMany.commitHasMany(key, newServerIds) } + reconcileSentHasMany( + key: string, + delta: SentHasManyDelta, + ): RelationReconciliationResult { + return this.hasMany.reconcileSentDelta(key, delta) + } + resetHasMany(key: string): void { this.hasMany.resetHasMany(key) } diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 15234be4..ae2e9714 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -6,6 +6,9 @@ import { ErrorStore } from './ErrorStore.js' import { RelationStore, type HasManyRemovalType, + type RelationReconciliationResult, + type SentHasManyDelta, + type SentHasOneTransition, type StoredHasManyState, type StoredRelationState, } from './RelationStore.js' @@ -28,7 +31,16 @@ import type { EditableWriteCounters, } from '../undo/UndoJournal.js' -export type { HasManyRemovalType, StoredHasManyState, StoredRelationState } from './RelationStore.js' +export type { + HasManyRemovalType, + RelationReconciliationResult, + SentHasManyAddition, + SentHasManyDelta, + SentHasManyRemoval, + SentHasOneTransition, + StoredHasManyState, + StoredRelationState, +} from './RelationStore.js' export type { EntityMeta } from './EntityMetaStore.js' export { isTempId, isPlaceholderId, isPersistedId, generatePlaceholderId } from './entityId.js' @@ -597,6 +609,19 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { this.notifyRelationSubscribers(key) } + reconcileSentHasMany( + parentType: string, + parentId: string, + fieldName: string, + delta: SentHasManyDelta, + alias?: string, + ): RelationReconciliationResult { + const key = this.getRelationKey(parentType, parentId, alias ?? fieldName) + const result = this.relations.reconcileSentHasMany(key, delta) + this.notifyRelationSubscribers(key) + return result + } + resetHasMany( parentType: string, parentId: string, @@ -948,6 +973,18 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { this.notifyRelationSubscribers(key) } + reconcileSentRelation( + parentType: string, + parentId: string, + fieldName: string, + transition: SentHasOneTransition, + ): RelationReconciliationResult { + const key = this.getRelationKey(parentType, parentId, fieldName) + const result = this.relations.reconcileSentRelation(key, transition) + this.notifyRelationSubscribers(key) + return result + } + resetRelation(parentType: string, parentId: string, fieldName: string): void { const key = this.getRelationKey(parentType, parentId, fieldName) this.journal?.recordRelation(key) diff --git a/tests/unit/store/persistedRelationBaselineReconciliation.test.ts b/tests/unit/store/persistedRelationBaselineReconciliation.test.ts new file mode 100644 index 00000000..c5f5b375 --- /dev/null +++ b/tests/unit/store/persistedRelationBaselineReconciliation.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, test } from 'bun:test' +import { SnapshotStore } from '@contember/bindx' + +const parentType = 'Article' +const parentId = 'article-1' + +function seedHasOne(store: SnapshotStore, targetId: string): void { + store.getOrCreateRelation(parentType, parentId, 'author', { + currentId: targetId, + serverId: targetId, + state: 'connected', + serverState: 'connected', + placeholderData: {}, + }) +} + +describe('persisted relation baseline reconciliation', () => { + test('has-one sent connect advances server B while current C stays dirty', () => { + const store = new SnapshotStore() + store.getOrCreateRelation(parentType, parentId, 'author', { + currentId: null, + serverId: null, + state: 'disconnected', + serverState: 'disconnected', + placeholderData: {}, + }) + store.setRelation(parentType, parentId, 'author', { currentId: 'B', state: 'connected' }) + store.setRelation(parentType, parentId, 'author', { currentId: 'C', state: 'connected' }) + + const result = store.reconcileSentRelation(parentType, parentId, 'author', { + operation: 'connect', + targetId: 'B', + }) + const state = store.getRelation(parentType, parentId, 'author') + + expect(result).toBe('applied') + expect(state?.serverId).toBe('B') + expect(state?.currentId).toBe('C') + expect(state?.state).toBe('connected') + expect(store.getDirtyRelations(parentType, parentId)).toContain('author') + }) + + test('has-one disconnect reversal becomes a pending connect', () => { + const store = new SnapshotStore() + seedHasOne(store, 'B') + store.setRelation(parentType, parentId, 'author', { currentId: null, state: 'disconnected' }) + store.setRelation(parentType, parentId, 'author', { currentId: 'B', state: 'connected' }) + + const result = store.reconcileSentRelation(parentType, parentId, 'author', { + operation: 'disconnect', + targetId: 'B', + }) + const state = store.getRelation(parentType, parentId, 'author') + + expect(result).toBe('applied') + expect(state?.serverId).toBeNull() + expect(state?.currentId).toBe('B') + expect(state?.state).toBe('connected') + }) + + test('has-one delete reversal to the deleted target is a conflict', () => { + const store = new SnapshotStore() + seedHasOne(store, 'B') + store.setRelation(parentType, parentId, 'author', { state: 'deleted' }) + store.setRelation(parentType, parentId, 'author', { currentId: 'B', state: 'connected' }) + + const result = store.reconcileSentRelation(parentType, parentId, 'author', { + operation: 'delete', + targetId: 'B', + }) + const state = store.getRelation(parentType, parentId, 'author') + + expect(result).toBe('conflict') + expect(state?.serverId).toBeNull() + expect(state?.currentId).toBe('B') + expect(state?.state).toBe('connected') + }) + + test('has-many sent connect keeps a later disconnect B and connect C pending', () => { + const store = new SnapshotStore() + store.getOrCreateHasMany(parentType, parentId, 'tags', []) + store.planHasManyConnection(parentType, parentId, 'tags', 'B') + store.removeFromHasMany(parentType, parentId, 'tags', 'B', 'disconnect') + store.planHasManyConnection(parentType, parentId, 'tags', 'C') + + const result = store.reconcileSentHasMany(parentType, parentId, 'tags', { + additions: [{ itemId: 'B', kind: 'connected' }], + removals: [], + }) + const state = store.getHasMany(parentType, parentId, 'tags') + + expect(result).toBe('applied') + expect(state?.serverIds).toEqual(new Set(['B'])) + expect(state?.plannedRemovals).toEqual(new Map([['B', 'disconnect']])) + expect(state?.plannedAdditions).toEqual(new Map([['C', 'connected']])) + }) + + test('sent created item removed while awaiting becomes a pending delete', () => { + const store = new SnapshotStore() + store.getOrCreateHasMany(parentType, parentId, 'tags', []) + store.addToHasMany(parentType, parentId, 'tags', 'B') + store.removeFromHasMany(parentType, parentId, 'tags', 'B', 'disconnect') + + const result = store.reconcileSentHasMany(parentType, parentId, 'tags', { + additions: [{ itemId: 'B', kind: 'created' }], + removals: [], + }) + const state = store.getHasMany(parentType, parentId, 'tags') + + expect(result).toBe('applied') + expect(state?.serverIds).toEqual(new Set(['B'])) + expect(state?.plannedRemovals).toEqual(new Map([['B', 'delete']])) + expect(store.getHasManyOrderedIds(parentType, parentId, 'tags')).toEqual([]) + }) + + test('reversing a sent disconnect keeps a pending connection', () => { + const store = new SnapshotStore() + store.getOrCreateHasMany(parentType, parentId, 'tags', ['B']) + store.removeFromHasMany(parentType, parentId, 'tags', 'B', 'disconnect') + store.planHasManyConnection(parentType, parentId, 'tags', 'B') + + const result = store.reconcileSentHasMany(parentType, parentId, 'tags', { + additions: [], + removals: [{ itemId: 'B', type: 'disconnect' }], + }) + const state = store.getHasMany(parentType, parentId, 'tags') + + expect(result).toBe('applied') + expect(state?.serverIds).toEqual(new Set()) + expect(state?.plannedAdditions).toEqual(new Map([['B', 'connected']])) + }) + + test('reversing a sent delete to the deleted item is a conflict', () => { + const store = new SnapshotStore() + store.getOrCreateHasMany(parentType, parentId, 'tags', ['B']) + store.removeFromHasMany(parentType, parentId, 'tags', 'B', 'delete') + store.planHasManyConnection(parentType, parentId, 'tags', 'B') + + const result = store.reconcileSentHasMany(parentType, parentId, 'tags', { + additions: [], + removals: [{ itemId: 'B', type: 'delete' }], + }) + const state = store.getHasMany(parentType, parentId, 'tags') + + expect(result).toBe('conflict') + expect(state?.serverIds).toEqual(new Set()) + expect(state?.plannedAdditions).toEqual(new Map([['B', 'connected']])) + }) + + test('disconnect after reversing a sent delete is complete when the delete succeeds', () => { + const store = new SnapshotStore() + store.getOrCreateHasMany(parentType, parentId, 'tags', ['B']) + store.removeFromHasMany(parentType, parentId, 'tags', 'B', 'delete') + store.planHasManyConnection(parentType, parentId, 'tags', 'B') + store.removeFromHasMany(parentType, parentId, 'tags', 'B', 'disconnect') + + const result = store.reconcileSentHasMany(parentType, parentId, 'tags', { + additions: [], + removals: [{ itemId: 'B', type: 'delete' }], + }) + const state = store.getHasMany(parentType, parentId, 'tags') + + expect(result).toBe('applied') + expect(state?.serverIds).toEqual(new Set()) + expect(state?.plannedAdditions).toEqual(new Map()) + expect(state?.plannedRemovals).toEqual(new Map()) + }) + + test('unrelated pending operations and explicit order survive', () => { + const store = new SnapshotStore() + store.getOrCreateHasMany(parentType, parentId, 'tags', ['A', 'D']) + store.planHasManyConnection(parentType, parentId, 'tags', 'B') + store.planHasManyConnection(parentType, parentId, 'tags', 'C') + store.removeFromHasMany(parentType, parentId, 'tags', 'D', 'delete') + store.moveInHasMany(parentType, parentId, 'tags', 0, 2) + const orderBefore = store.getHasManyOrderedIds(parentType, parentId, 'tags') + + store.reconcileSentHasMany(parentType, parentId, 'tags', { + additions: [{ itemId: 'B', kind: 'connected' }], + removals: [], + }) + const state = store.getHasMany(parentType, parentId, 'tags') + + expect(state?.serverIds).toEqual(new Set(['A', 'D', 'B'])) + expect(state?.plannedAdditions).toEqual(new Map([['C', 'connected']])) + expect(state?.plannedRemovals).toEqual(new Map([['D', 'delete']])) + expect(store.getHasManyOrderedIds(parentType, parentId, 'tags')).toEqual(orderBefore) + }) + + test('reconciliation changes mutation version but not editable counters', () => { + const store = new SnapshotStore() + store.getOrCreateRelation(parentType, parentId, 'author', { + currentId: null, + serverId: null, + state: 'disconnected', + serverState: 'disconnected', + placeholderData: {}, + }) + store.setRelation(parentType, parentId, 'author', { currentId: 'B', state: 'connected' }) + store.getOrCreateHasMany(parentType, parentId, 'tags', []) + store.planHasManyConnection(parentType, parentId, 'tags', 'B') + const editableBefore = store.getEditableWriteCounters() + const beforeHasOne = store.getDirtyVersion() + + store.reconcileSentRelation(parentType, parentId, 'author', { + operation: 'connect', + targetId: 'B', + }) + const afterHasOne = store.getDirtyVersion() + + store.reconcileSentHasMany(parentType, parentId, 'tags', { + additions: [{ itemId: 'B', kind: 'connected' }], + removals: [], + }) + + expect(store.getEditableWriteCounters()).toEqual(editableBefore) + expect(afterHasOne).toBeGreaterThan(beforeHasOne) + expect(store.getDirtyVersion()).toBeGreaterThan(afterHasOne) + }) + + test('each SnapshotStore reconciliation call emits one relation notification', () => { + const store = new SnapshotStore() + store.getOrCreateRelation(parentType, parentId, 'author', { + currentId: null, + serverId: null, + state: 'disconnected', + serverState: 'disconnected', + placeholderData: {}, + }) + store.setRelation(parentType, parentId, 'author', { currentId: 'B', state: 'connected' }) + store.getOrCreateHasMany(parentType, parentId, 'tags', []) + store.planHasManyConnection(parentType, parentId, 'tags', 'B') + let hasOneNotifications = 0 + let hasManyNotifications = 0 + store.subscribeToRelation(parentType, parentId, 'author', () => { + hasOneNotifications++ + }) + store.subscribeToRelation(parentType, parentId, 'tags', () => { + hasManyNotifications++ + }) + + store.reconcileSentRelation(parentType, parentId, 'author', { + operation: 'connect', + targetId: 'B', + }) + + store.reconcileSentHasMany(parentType, parentId, 'tags', { + additions: [{ itemId: 'B', kind: 'connected' }], + removals: [], + }) + + expect(hasOneNotifications).toBe(1) + expect(hasManyNotifications).toBe(1) + }) +}) From 22859d4396d297595b75df01320669ac0b9fef8e Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 20:17:20 +0200 Subject: [PATCH 46/55] fix(bindx): keep undo blocked across concurrent persists --- packages/bindx/src/undo/UndoManager.ts | 37 +++++++---- tests/undo.test.ts | 91 ++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 14 deletions(-) diff --git a/packages/bindx/src/undo/UndoManager.ts b/packages/bindx/src/undo/UndoManager.ts index 6629d7fd..0d0498d8 100644 --- a/packages/bindx/src/undo/UndoManager.ts +++ b/packages/bindx/src/undo/UndoManager.ts @@ -35,7 +35,7 @@ export class UndoManager { private debounceTimer: ReturnType | null = null private manualGroupId: string | null = null - private isBlocked = false + private blockDepth = 0 private subscribers = new Set() private cachedState: UndoState | null = null @@ -73,7 +73,7 @@ export class UndoManager { // ==================== Recording (journal commit sink) ==================== private onEntry(entry: JournalEntry): void { - if (this.isBlocked || entry.cells.length === 0) return + if (this.blockDepth > 0 || entry.cells.length === 0) return // A fresh user action invalidates the redo stack. if (this.redoStack.length > 0) { @@ -109,7 +109,7 @@ export class UndoManager { } } - private flushPending(): void { + private flushPending(shouldNotify = true): void { if (this.debounceTimer) { clearTimeout(this.debounceTimer) this.debounceTimer = null @@ -117,16 +117,18 @@ export class UndoManager { const pending = this.pending this.pending = null if (pending && pending.size > 0) { - this.pushEntry({ cells: [...pending.values()] }) + this.pushEntry({ cells: [...pending.values()] }, shouldNotify) } } - private pushEntry(entry: JournalEntry): void { + private pushEntry(entry: JournalEntry, shouldNotify = true): void { this.undoStack.push(entry) while (this.undoStack.length > this.maxHistorySize) { this.undoStack.shift() } - this.notifySubscribers() + if (shouldNotify) { + this.notifySubscribers() + } } private resetDebounceTimer(): void { @@ -162,7 +164,7 @@ export class UndoManager { undo(): void { this.flushPending() - if (this.isBlocked || this.undoStack.length === 0) return + if (this.blockDepth > 0 || this.undoStack.length === 0) return const entry = this.undoStack.pop()! // Capture the current state of the same cells as the inverse (redo) entry, @@ -175,7 +177,7 @@ export class UndoManager { } redo(): void { - if (this.isBlocked || this.redoStack.length === 0) return + if (this.blockDepth > 0 || this.redoStack.length === 0) return const entry = this.redoStack.pop()! const inverse = this.journal.captureCurrent(entry.cells) @@ -188,14 +190,21 @@ export class UndoManager { // ==================== Blocking (during persist) ==================== block(): void { - this.flushPending() - this.isBlocked = true - this.notifySubscribers() + if (this.blockDepth === 0) { + this.blockDepth++ + this.flushPending(false) + this.notifySubscribers() + return + } + this.blockDepth++ } unblock(): void { - this.isBlocked = false - this.notifySubscribers() + if (this.blockDepth === 0) return + this.blockDepth-- + if (this.blockDepth === 0) { + this.notifySubscribers() + } } // ==================== Persist rekey ==================== @@ -262,7 +271,7 @@ export class UndoManager { this.cachedState = { canUndo: this.undoStack.length > 0 || hasPending, canRedo: this.redoStack.length > 0, - isBlocked: this.isBlocked, + isBlocked: this.blockDepth > 0, undoCount: this.undoStack.length + (hasPending ? 1 : 0), redoCount: this.redoStack.length, } diff --git a/tests/undo.test.ts b/tests/undo.test.ts index 97424a66..500da699 100644 --- a/tests/undo.test.ts +++ b/tests/undo.test.ts @@ -205,6 +205,97 @@ describe('UndoManager', () => { expect(undoManager.getState().canUndo).toBe(false) }) + + test('should remain blocked until every overlapping block is released', () => { + store.setEntityData('Article', '1', { id: '1', title: 'Original' }, true) + dispatcher.dispatch(setField('Article', '1', ['title'], 'Before block')) + + undoManager.block() + undoManager.block() + dispatcher.dispatch(setField('Article', '1', ['title'], 'During first block')) + + undoManager.unblock() + expect(undoManager.getState().isBlocked).toBe(true) + dispatcher.dispatch(setField('Article', '1', ['title'], 'During second block')) + undoManager.undo() + expect(store.getEntitySnapshot<{title: string}>('Article', '1')?.data.title).toBe('During second block') + expect(undoManager.getState().undoCount).toBe(1) + + undoManager.unblock() + expect(undoManager.getState().isBlocked).toBe(false) + dispatcher.dispatch(setField('Article', '1', ['title'], 'After block')) + expect(undoManager.getState().undoCount).toBe(2) + + undoManager.undo() + expect(store.getEntitySnapshot<{title: string}>('Article', '1')?.data.title).toBe('During second block') + }) + + test('should keep an active block when history is cleared and ignore excess unblock calls', () => { + store.setEntityData('Article', '1', { id: '1', title: 'Original' }, true) + dispatcher.dispatch(setField('Article', '1', ['title'], 'Before block')) + + undoManager.block() + undoManager.clear() + expect(undoManager.getState().isBlocked).toBe(true) + + dispatcher.dispatch(setField('Article', '1', ['title'], 'During block')) + undoManager.unblock() + undoManager.unblock() + expect(undoManager.getState().isBlocked).toBe(false) + expect(undoManager.getState().canUndo).toBe(false) + + dispatcher.dispatch(setField('Article', '1', ['title'], 'After block')) + expect(undoManager.getState().canUndo).toBe(true) + }) + + test('should notify subscribers only when the visible blocked state changes', () => { + let notifyCount = 0 + const unsubscribe = undoManager.subscribe(() => { + notifyCount++ + }) + + undoManager.block() + expect(notifyCount).toBe(1) + undoManager.block() + expect(notifyCount).toBe(1) + undoManager.unblock() + expect(notifyCount).toBe(1) + undoManager.unblock() + expect(notifyCount).toBe(2) + undoManager.unblock() + expect(notifyCount).toBe(2) + + unsubscribe() + }) + + test('should enter the blocked state before flushing pending history', () => { + const debouncedStore = new SnapshotStore() + const debouncedDispatcher = new ActionDispatcher(debouncedStore) + const debouncedUndo = new UndoManager(debouncedStore, { debounceMs: 1000 }) + debouncedDispatcher.addMiddleware(debouncedUndo.createMiddleware()) + debouncedStore.setEntityData('Article', '1', { id: '1', title: 'Original' }, true) + debouncedDispatcher.dispatch(setField('Article', '1', ['title'], 'Pending')) + + const observedBlockedStates: boolean[] = [] + let shouldWriteReentrantly = true + const unsubscribe = debouncedUndo.subscribe(() => { + observedBlockedStates.push(debouncedUndo.getState().isBlocked) + if (shouldWriteReentrantly) { + shouldWriteReentrantly = false + debouncedDispatcher.dispatch(setField('Article', '1', ['title'], 'Reentrant')) + } + }) + + debouncedUndo.block() + + expect(observedBlockedStates).toEqual([true]) + expect(debouncedUndo.getState().undoCount).toBe(1) + debouncedUndo.unblock() + debouncedUndo.undo() + expect(debouncedStore.getEntitySnapshot<{title: string}>('Article', '1')?.data.title).toBe('Original') + + unsubscribe() + }) }) describe('History management', () => { From bca06f24d5c7f34f23b9399d30c80c936f56030b Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 20:46:35 +0200 Subject: [PATCH 47/55] fix(bindx-react): stabilize multi-ref subscriptions --- packages/bindx-react/src/hooks/useFields.ts | 97 ++++++---- .../bindx-react/src/jsx/componentFactory.ts | 23 +-- .../bindx-react/src/jsx/components/If.tsx | 4 +- .../bindx-react/src/jsx/components/Switch.tsx | 4 +- .../react/hooks/useFields/useFields.test.tsx | 169 ++++++++++++++++++ tests/unit/types/useFields.test.ts | 42 +++++ 6 files changed, 291 insertions(+), 48 deletions(-) create mode 100644 tests/react/hooks/useFields/useFields.test.tsx create mode 100644 tests/unit/types/useFields.test.ts diff --git a/packages/bindx-react/src/hooks/useFields.ts b/packages/bindx-react/src/hooks/useFields.ts index ee9e6fd9..ac9af173 100644 --- a/packages/bindx-react/src/hooks/useFields.ts +++ b/packages/bindx-react/src/hooks/useFields.ts @@ -1,4 +1,4 @@ -import { useCallback, useSyncExternalStore } from 'react' +import { useCallback, useRef, useSyncExternalStore } from 'react' import { FIELD_REF_META, type FieldAccessor, @@ -20,59 +20,74 @@ function hasFieldRefMeta(value: unknown): value is { readonly [FIELD_REF_META]: /** Deduplicates the entities behind `refs`; entries without field metadata are ignored. */ function collectTargets(refs: ReadonlyArray): SubscriptionTarget[] { const targets: SubscriptionTarget[] = [] - const seen = new Set() + const seen = new Map>() for (const ref of refs) { if (!hasFieldRefMeta(ref)) continue const meta = ref[FIELD_REF_META] if (!meta) continue - const key = `${meta.entityType}:${meta.entityId}` - if (seen.has(key)) continue - seen.add(key) + let ids = seen.get(meta.entityType) + if (!ids) { + ids = new Set() + seen.set(meta.entityType, ids) + } + if (ids.has(meta.entityId)) continue + ids.add(meta.entityId) targets.push({ entityType: meta.entityType, entityId: meta.entityId }) } + targets.sort((left, right) => { + if (left.entityType < right.entityType) return -1 + if (left.entityType > right.entityType) return 1 + if (left.entityId < right.entityId) return -1 + if (left.entityId > right.entityId) return 1 + return 0 + }) return targets } -/** - * Subscribes to every entity behind `refs` with a single hook, so the hook count stays - * constant no matter how many refs are passed. Use it wherever the number of refs is - * driven by data or by children (`` cases, condition DSL fields) — calling - * {@link useField} in a loop breaks the rules of hooks the moment the count changes. - * - * Nulls and values without field metadata are ignored, which makes it safe to pass - * loosely typed collections such as the fields of a `Condition`. - * - * @example - * ```tsx - * // Values + subscription for a variable number of fields - * const accessors = useFields([article.title, article.publishedAt]) - * - * // Subscription only (condition DSL refs are not necessarily FieldRefs) - * useFields(collectConditionFields(condition)) - * ``` - */ -export function useFields(refs: ReadonlyArray | null>): ReadonlyArray | null> -export function useFields(refs: ReadonlyArray): void -export function useFields(refs: ReadonlyArray): unknown { +type FieldAccessorFor = TRef extends FieldRef + ? FieldAccessor + : TRef extends null + ? null + : never + +type FieldRefFor = TRef extends FieldRef + ? FieldRef + : TRef extends null + ? null + : never + +type FieldAccessorTuple = { + readonly [TIndex in keyof TRefs]: FieldAccessorFor +} + +type FieldRefTuple = { + readonly [TIndex in keyof TRefs]: FieldRefFor +} + +/** Internal subscription path for metadata-bearing refs of any kind. */ +export function useRefSubscription(refs: readonly unknown[]): readonly unknown[] { const store = useSnapshotStore() const targets = collectTargets(refs) const subscriptionKey = JSON.stringify(targets) - const hasTargets = targets.length > 0 + const stableTargetsRef = useRef({ subscriptionKey, targets }) + if (stableTargetsRef.current.subscriptionKey !== subscriptionKey) { + stableTargetsRef.current = { subscriptionKey, targets } + } + const stableTargets = stableTargetsRef.current.targets + const hasTargets = stableTargets.length > 0 - // `subscriptionKey` fully determines `targets`, so keeping the capture from the render that - // last changed the key is equivalent — and it avoids resubscribing on every render. const subscribe = useCallback( (callback: () => void): (() => void) => { - const unsubscribes = targets.map(target => + const unsubscribes = stableTargets.map(target => store.subscribeToEntity(target.entityType, target.entityId, callback), ) return () => { for (const unsubscribe of unsubscribes) unsubscribe() } }, - [store, subscriptionKey], + [store, stableTargets], ) const getSnapshot = useCallback( @@ -81,7 +96,23 @@ export function useFields(refs: ReadonlyArray): unknown { ) useSyncExternalStore(subscribe, getSnapshot, getSnapshot) - - // Ref proxies already expose accessor properties at runtime — the overloads widen the type. return refs } + +/** + * Subscribes to every entity behind `refs` with a single hook, so the hook count stays + * constant no matter how many refs are passed. Use it wherever the number of refs is + * driven by data or by children (`` cases, condition DSL fields) — calling + * {@link useField} in a loop breaks the rules of hooks the moment the count changes. + * + * @example + * ```tsx + * const accessors = useFields([article.title, article.publishedAt]) + * ``` + */ +export function useFields( + refs: TRefs & FieldRefTuple, +): FieldAccessorTuple +export function useFields(refs: ReadonlyArray): unknown { + return useRefSubscription(refs) +} diff --git a/packages/bindx-react/src/jsx/componentFactory.ts b/packages/bindx-react/src/jsx/componentFactory.ts index 8327a90a..1c290fb4 100644 --- a/packages/bindx-react/src/jsx/componentFactory.ts +++ b/packages/bindx-react/src/jsx/componentFactory.ts @@ -15,7 +15,6 @@ import type { SelectionMeta, SelectionBuilder, AnyBrand, - EntityRef, SchemaDefinition, } from '@contember/bindx' import { @@ -33,7 +32,7 @@ import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF } from './types.js' import { createCollectorProxy } from './proxy.js' import { collectSelection } from './analyzer.js' import { type Condition, evaluateCondition } from './conditions.js' -import { useAccessor } from '../hooks/useAccessor.js' +import { useRefSubscription } from '../hooks/useFields.js' // ============================================================================ // Symbols @@ -70,17 +69,19 @@ export interface EntityConfig { // ============================================================================ /** - * Converts entity ref props to accessors via useAccessor, subscribing each one. - * Called with a fixed list of prop names — hook count is stable across renders. + * Converts entity ref props to accessors while subscribing with one fixed hook. */ +function readProperty(target: object, name: string): unknown { + return Reflect.get(target, name) +} + function useRenderProps(props: TProps, entityPropNames: string[]): TProps { - const record = props as Record - const accessors: Record = {} - for (const name of entityPropNames) { - // eslint-disable-next-line react-hooks/rules-of-hooks -- stable iteration count (entityPropNames is fixed at build time) - accessors[name] = useAccessor(record[name] as EntityRef) - } - return { ...props, ...accessors } as TProps + const refs = entityPropNames.map(name => readProperty(props, name)) + const accessors = useRefSubscription(refs) + const accessorProps = Object.fromEntries( + entityPropNames.map((name, index) => [name, accessors[index]]), + ) + return Object.assign({}, props, accessorProps) } // ============================================================================ diff --git a/packages/bindx-react/src/jsx/components/If.tsx b/packages/bindx-react/src/jsx/components/If.tsx index 12008de7..759b8b1f 100644 --- a/packages/bindx-react/src/jsx/components/If.tsx +++ b/packages/bindx-react/src/jsx/components/If.tsx @@ -3,7 +3,7 @@ import type { IfProps, SelectionFieldMeta, SelectionMeta, SelectionProvider, Fie import { FIELD_REF_META, BINDX_COMPONENT } from '../types.js' import { mergeSelections, createEmptySelection } from '../SelectionMeta.js' import { useField } from '../../hooks/useField.js' -import { useFields } from '../../hooks/useFields.js' +import { useRefSubscription } from '../../hooks/useFields.js' import { type Condition, isCondition, @@ -60,7 +60,7 @@ function IfImpl({ condition, then: thenBranch, else: elseBranch }: IfProps): Rea const fieldAccessor = useField(fieldRef) // A Condition is evaluated against live data, so it needs a subscription of its own — // a memoized is not re-rendered by its parent. - useFields(isCondition(condition) ? collectConditionFields(condition) : NO_CONDITION_FIELDS) + useRefSubscription(isCondition(condition) ? collectConditionFields(condition) : NO_CONDITION_FIELDS) let conditionValue: boolean diff --git a/packages/bindx-react/src/jsx/components/Switch.tsx b/packages/bindx-react/src/jsx/components/Switch.tsx index eb75d37d..3b727205 100644 --- a/packages/bindx-react/src/jsx/components/Switch.tsx +++ b/packages/bindx-react/src/jsx/components/Switch.tsx @@ -13,7 +13,7 @@ import type { SelectionProvider, } from '../types.js' import { FIELD_REF_META, BINDX_COMPONENT } from '../types.js' -import { useFields } from '../../hooks/useFields.js' +import { useFields, useRefSubscription } from '../../hooks/useFields.js' import { type Condition, isCondition, @@ -156,7 +156,7 @@ function SwitchImpl({ children }: SwitchProps): ReactElement | null { // Two fixed hooks cover every case, so the hook count never depends on how many // children are rendered (a conditional used to crash React). const accessors = useFields(entries.cases.map(entry => resolveTriggerField(entry.props))) - useFields(collectConditionRefs(entries.cases)) + useRefSubscription(collectConditionRefs(entries.cases)) for (let i = 0; i < entries.cases.length; i++) { const { props } = entries.cases[i]! diff --git a/tests/react/hooks/useFields/useFields.test.tsx b/tests/react/hooks/useFields/useFields.test.tsx new file mode 100644 index 00000000..a9608f7d --- /dev/null +++ b/tests/react/hooks/useFields/useFields.test.tsx @@ -0,0 +1,169 @@ +import '../../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, render } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + MockAdapter, + SnapshotStore, + FIELD_REF_META, +} from '@contember/bindx-react' +import { useRefSubscription } from '../../../../packages/bindx-react/src/hooks/useFields.js' +import { createMockData, testSchema } from '../../../shared' + +afterEach(() => { + cleanup() +}) + +interface SubscriptionKey { + readonly entityType: string + readonly entityId: string +} + +class CountingStore extends SnapshotStore { + readonly subscriptionLog: SubscriptionKey[] = [] + readonly activeSubscriptions = new Map() + + override subscribeToEntity(entityType: string, id: string, callback: () => void): () => void { + const key = JSON.stringify([entityType, id]) + this.subscriptionLog.push({ entityType, entityId: id }) + this.activeSubscriptions.set(key, (this.activeSubscriptions.get(key) ?? 0) + 1) + const unsubscribe = super.subscribeToEntity(entityType, id, callback) + return () => { + unsubscribe() + const remaining = (this.activeSubscriptions.get(key) ?? 1) - 1 + if (remaining === 0) { + this.activeSubscriptions.delete(key) + } else { + this.activeSubscriptions.set(key, remaining) + } + } + } + + isActive(entityType: string, entityId: string): boolean { + return this.activeSubscriptions.has(JSON.stringify([entityType, entityId])) + } +} + +function metadataRef(entityType: string, entityId: string): object { + return { + [FIELD_REF_META]: { + entityType, + entityId, + path: ['value'], + fieldName: 'value', + isArray: false, + isRelation: false, + }, + } +} + +interface ProbeProps { + readonly refs: readonly unknown[] + readonly onRender: (values: readonly unknown[]) => void +} + +function Probe({ refs, onRender }: ProbeProps): null { + const values = useRefSubscription(refs) + onRender(values) + return null +} + +function renderProbe(store: SnapshotStore, props: ProbeProps): ReturnType { + return render( + + + , + ) +} + +describe('useRefSubscription', () => { + test('tracks a variable target set and preserves input alignment', () => { + const store = new CountingStore() + const author = metadataRef('Author', 'author-1') + const article = metadataRef('Article', 'article-1') + const ignored = { value: 'not a ref' } + const latestValues: Array = [] + const onRender = (values: readonly unknown[]): void => { + latestValues.push(values) + } + const view = renderProbe(store, { refs: [], onRender }) + + expect(store.activeSubscriptions.size).toBe(0) + + view.rerender( + + + , + ) + expect(store.isActive('Author', 'author-1')).toBe(true) + expect(store.isActive('Article', 'article-1')).toBe(true) + expect(latestValues.at(-1)).toEqual([author, null, ignored, article]) + + view.rerender( + + + , + ) + expect(store.isActive('Author', 'author-1')).toBe(false) + expect(store.isActive('Article', 'article-1')).toBe(true) + expect(latestValues.at(-1)).toEqual([article]) + }) + + test('stale targets stop triggering and new targets trigger', () => { + const store = new CountingStore() + const author = metadataRef('Author', 'author-1') + const article = metadataRef('Article', 'article-1') + let renders = 0 + const onRender = (): void => { + renders++ + } + const view = renderProbe(store, { refs: [author], onRender }) + + view.rerender( + + + , + ) + const afterTargetChange = renders + + act(() => { + store.setEntityData('Author', 'author-1', { id: 'author-1', name: 'Old' }, true) + }) + expect(renders).toBe(afterTargetChange) + + act(() => { + store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'New' }, true) + }) + expect(renders).toBe(afterTargetChange + 1) + }) + + test('deduplicates multiple refs to the same entity', () => { + const store = new CountingStore() + const title = metadataRef('Article', 'article-1') + const published = metadataRef('Article', 'article-1') + + renderProbe(store, { refs: [title, null, published, title], onRender: () => undefined }) + + expect(store.subscriptionLog).toEqual([ + { entityType: 'Article', entityId: 'article-1' }, + ]) + expect(store.activeSubscriptions.size).toBe(1) + }) +}) diff --git a/tests/unit/types/useFields.test.ts b/tests/unit/types/useFields.test.ts new file mode 100644 index 00000000..30bd2222 --- /dev/null +++ b/tests/unit/types/useFields.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test' +import type { FieldAccessor, FieldRef } from '@contember/bindx' +import { useFields } from '@contember/bindx-react' + +type IsEqual = + (() => T extends TLeft ? 1 : 2) extends + (() => T extends TRight ? 1 : 2) + ? true + : false + +function assertTrue(): void {} + +function useMappedTuple( + refs: readonly [FieldRef, FieldRef, null], +) { + return useFields(refs) +} + +function useMappedArray(refs: readonly FieldRef[]): readonly FieldAccessor[] { + return useFields(refs) +} + +function useMappedNullableArray( + refs: readonly (FieldRef | null)[], +): readonly (FieldAccessor | null)[] { + return useFields(refs) +} + +type MappedTuple = ReturnType +type ExpectedTuple = readonly [FieldAccessor, FieldAccessor, null] + +describe('useFields types', () => { + test('maps heterogeneous tuples by position', () => { + assertTrue>() + expect(typeof useMappedTuple).toBe('function') + }) + + test('keeps homogeneous readonly arrays assignable', () => { + expect(typeof useMappedArray).toBe('function') + expect(typeof useMappedNullableArray).toBe('function') + }) +}) From 47aeac7aefb7c0d35ce31a10453ac3b1327dd158 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 20:46:51 +0200 Subject: [PATCH 48/55] fix(bindx-dataview): bind relation cells to live rows --- .../src/createRelationColumn.tsx | 23 ++++- .../bindx-dataview/src/useDataGridSetup.ts | 36 +++++++- .../relationColumnRuntimeRow.test.tsx | 83 +++++++++++++++++++ .../dataview/relationColumnSelection.test.tsx | 23 +++++ 4 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 tests/react/dataview/relationColumnRuntimeRow.test.tsx diff --git a/packages/bindx-dataview/src/createRelationColumn.tsx b/packages/bindx-dataview/src/createRelationColumn.tsx index 7d1b0615..b4082054 100644 --- a/packages/bindx-dataview/src/createRelationColumn.tsx +++ b/packages/bindx-dataview/src/createRelationColumn.tsx @@ -13,7 +13,7 @@ import React from 'react' import type { FieldRef, FilterArtifact, FilterHandler, EntityAccessor, SelectionMeta } from '@contember/bindx' import { SelectionScope } from '@contember/bindx' -import { createCollectorProxy, collectSelection as collectJsxSelection } from '@contember/bindx-react' +import { createCollectorProxy, collectSelection as collectJsxSelection, SCOPE_REF } from '@contember/bindx-react' import type { ColumnTypeDef } from './columnTypes.js' import { accessField } from './columnTypes.js' @@ -80,6 +80,19 @@ function analyzeRenderedSelection(rendered: React.ReactNode): void { collectJsxSelection(rendered) } +function getSelectionScope(ref: unknown): SelectionScope | null { + if (typeof ref !== 'object' || ref === null || !(SCOPE_REF in ref)) return null + const scope = ref[SCOPE_REF] + return scope instanceof SelectionScope ? scope : null +} + +function replaceSelectionFields(target: SelectionMeta, source: SelectionMeta): void { + target.fields.clear() + for (const [name, field] of source.fields) { + target.fields.set(name, field) + } +} + // ============================================================================ // Factory // ============================================================================ @@ -107,12 +120,12 @@ export function createRelationColumn( const relatedEntityName = extractRelatedEntityName(fieldRef) ?? '' // Collect selection metadata for the related entity (for filter fetching) - let relatedSelection: SelectionMeta = { fields: new Map() } + const relatedSelection: SelectionMeta = { fields: new Map() } if (relatedEntityName && renderer) { const scope = new SelectionScope() const proxy = createCollectorProxy(scope, relatedEntityName) analyzeRenderedSelection(renderer(proxy)) - relatedSelection = scope.toSelectionMeta() + replaceSelectionFields(relatedSelection, scope.toSelectionMeta()) } const renderFilterItem = renderer @@ -155,6 +168,10 @@ export function createRelationColumn( collectSelection: () => { if (renderer && fieldRef) { cellConfig.collectSelection(renderer, fieldRef) + const authoritativeScope = getSelectionScope(fieldRef) + if (authoritativeScope) { + replaceSelectionFields(relatedSelection, authoritativeScope.toSelectionMeta()) + } } }, renderCell: (accessor: EntityAccessor) => { diff --git a/packages/bindx-dataview/src/useDataGridSetup.ts b/packages/bindx-dataview/src/useDataGridSetup.ts index a81ca6b2..4e45e62c 100644 --- a/packages/bindx-dataview/src/useDataGridSetup.ts +++ b/packages/bindx-dataview/src/useDataGridSetup.ts @@ -107,6 +107,40 @@ export function useDataGridSetup({ const analysis = analyzeChildren(jsx, MARKER_TYPES) const cols = analysis.getAll(ColumnLeaf) + const runtimeColumnsByAccessor = new WeakMap, readonly ColumnLeafProps[]>() + + const getRuntimeColumns = (accessor: EntityAccessor): readonly ColumnLeafProps[] => { + const cached = runtimeColumnsByAccessor.get(accessor) + if (cached) return cached + + const runtimeJsx = children(accessor) + const runtimeColumns = analyzeChildren(runtimeJsx, MARKER_TYPES).getAll(ColumnLeaf) + if (runtimeColumns.length !== cols.length) { + throw new Error(`DataGrid children produced ${runtimeColumns.length} columns for row "${accessor.id}", but collection produced ${cols.length}. Data-dependent column structure is not supported.`) + } + + for (let index = 0; index < cols.length; index++) { + const collected = cols[index] + const runtime = runtimeColumns[index] + if (collected?.fieldName !== runtime?.fieldName || collected?.columnType !== runtime?.columnType) { + throw new Error(`DataGrid children changed column order at position ${index} for row "${accessor.id}". Data-dependent column structure is not supported.`) + } + } + + runtimeColumnsByAccessor.set(accessor, runtimeColumns) + return runtimeColumns + } + + const runtimeBoundColumns = cols.map((column, index): ColumnLeafProps => ({ + ...column, + renderCell: accessor => { + const runtimeColumn = getRuntimeColumns(accessor)[index] + if (!runtimeColumn) { + throw new Error(`DataGrid runtime column ${index} is missing for row "${accessor.id}".`) + } + return runtimeColumn.renderCell(accessor) + }, + })) const toolbarMarker = analysis.getFirst(DataGridToolbarContent) const layoutMarkers = analysis.getAll(DataGridLayout) @@ -144,7 +178,7 @@ export function useDataGridSetup({ return { childrenJsx: jsx, - columns: cols, + columns: runtimeBoundColumns, selection: sel, queryKey: key, toolbarContent: toolbarMarker?.children, diff --git a/tests/react/dataview/relationColumnRuntimeRow.test.tsx b/tests/react/dataview/relationColumnRuntimeRow.test.tsx new file mode 100644 index 00000000..b4817096 --- /dev/null +++ b/tests/react/dataview/relationColumnRuntimeRow.test.tsx @@ -0,0 +1,83 @@ +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + MockAdapter, +} from '@contember/bindx-react' +import { + DataGrid, + DataGridHasManyColumn, + DataGridHasOneColumn, +} from '@contember/bindx-dataview' +import { schema, testSchema } from '../../shared/index.js' +import { TestTable, getByTestId, queryByTestId } from './helpers.js' + +afterEach(() => { + cleanup() +}) + +describe('relation column runtime row binding', () => { + test('binds has-one and has-many renderers to each live row and caches its leaves', async () => { + const adapter = new MockAdapter({ + Article: { + 'article-1': { + id: 'article-1', + title: 'First', + content: '', + author: { id: 'author-1', name: 'Alice', email: 'alice@example.com' }, + tags: [{ id: 'tag-1', name: 'Red', color: '#f00' }], + }, + 'article-2': { + id: 'article-2', + title: 'Second', + content: '', + author: { id: 'author-2', name: 'Bob', email: 'bob@example.com' }, + tags: [{ id: 'tag-2', name: 'Blue', color: '#00f' }], + }, + }, + Author: { + 'author-1': { id: 'author-1', name: 'Alice', email: 'alice@example.com' }, + 'author-2': { id: 'author-2', name: 'Bob', email: 'bob@example.com' }, + }, + Tag: { + 'tag-1': { id: 'tag-1', name: 'Red', color: '#f00' }, + 'tag-2': { id: 'tag-2', name: 'Blue', color: '#00f' }, + }, + Location: {}, + }, { delay: 0 }) + let childrenCalls = 0 + + const { container } = render( + + + {it => { + childrenCalls++ + return ( + <> + + {author => `${author.name.value}/${it.title.value}`} + + + {tag => `${tag.name.value}/${it.title.value}`} + + + + ) + }} + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-loading')).toBeNull() + }) + + expect(getByTestId(container, 'datagrid-row-0-col-author').textContent).toBe('Alice/First') + expect(getByTestId(container, 'datagrid-row-1-col-author').textContent).toBe('Bob/Second') + expect(getByTestId(container, 'datagrid-row-0-col-tags').textContent).toBe('Red/First') + expect(getByTestId(container, 'datagrid-row-1-col-tags').textContent).toBe('Blue/Second') + expect(childrenCalls).toBe(3) + }) +}) diff --git a/tests/react/dataview/relationColumnSelection.test.tsx b/tests/react/dataview/relationColumnSelection.test.tsx index 0b18e166..43f647c4 100644 --- a/tests/react/dataview/relationColumnSelection.test.tsx +++ b/tests/react/dataview/relationColumnSelection.test.tsx @@ -40,12 +40,14 @@ interface Country { interface Member { id: string fullName: string + value: string } interface Organization { id: string name: string country: Country | null + member: Member | null members: Member[] } @@ -85,6 +87,7 @@ const testSchema = defineSchema({ id: scalar(), name: scalar(), country: hasOne('Country'), + member: hasOne('Member'), members: hasMany('Member'), }, }, @@ -99,6 +102,7 @@ const testSchema = defineSchema({ fields: { id: scalar(), fullName: scalar(), + value: scalar(), }, }, Country: { @@ -263,6 +267,25 @@ describe('relation column selection — hasMany', () => { // ============================================================================ describe('relation column relatedSelection', () => { + test('schema-bound collection replaces a colliding schema-less scalar with the nested relation', () => { + const scope = new SelectionScope() + const collector = createCollectorProxy(scope, 'Project', schemaRegistry) + const leaves = extractColumnLeaves( + + {organization => String(organization.member.value)} + , + ) + const leaf = leaves[0] + if (!leaf?.relatedSelection) throw new Error('expected relatedSelection to be built') + const relatedSelection = leaf.relatedSelection + + expect(relatedSelection.fields.get('member')?.nested).toBeUndefined() + leaf.collectSelection?.(collector) + + expect(leaf.relatedSelection).toBe(relatedSelection) + expect(fieldNames(nested(relatedSelection, 'member'))).toEqual(['id', 'value']) + }) + test('nested in the renderer reaches relatedSelection', () => { const leaf = buildColumnLeaf(it => ( From 73ce285c0aed4bd8e13cf6966df4c7ef88213a5f Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 21:00:10 +0200 Subject: [PATCH 49/55] fix(bindx): reconcile immutable persistence executions --- .../bindx/src/persistence/BatchPersister.ts | 1098 ++++++++++------- .../src/persistence/MutationCollector.ts | 226 +++- .../bindx/src/persistence/PersistExecution.ts | 217 ++++ tests/unit/persistence/errors.test.ts | 14 +- .../nestedCreateNormalisedScalar.test.ts | 11 +- .../unit/persistence/persistPlanning.test.ts | 390 ++++++ .../persistence/persistReconciliation.test.ts | 332 +++++ tests/unit/persistence/pessimistic.test.ts | 11 +- 8 files changed, 1785 insertions(+), 514 deletions(-) create mode 100644 packages/bindx/src/persistence/PersistExecution.ts create mode 100644 tests/unit/persistence/persistPlanning.test.ts create mode 100644 tests/unit/persistence/persistReconciliation.test.ts diff --git a/packages/bindx/src/persistence/BatchPersister.ts b/packages/bindx/src/persistence/BatchPersister.ts index 897ad023..d2542abd 100644 --- a/packages/bindx/src/persistence/BatchPersister.ts +++ b/packages/bindx/src/persistence/BatchPersister.ts @@ -13,18 +13,21 @@ import type { PersistScope, TransactionMutation, TransactionMutationResult, - TransactionResult, UpdateMode, } from './types.js' -import { setPersisting, commitEntity, resetEntity, addFieldError, addEntityError, addRelationError, clearAllServerErrors } from '../core/actions.js' +import { setPersisting, resetEntity, addFieldError, addEntityError, addRelationError, clearAllServerErrors } from '../core/actions.js' import { type ContemberMutationResult } from '../errors/pathMapper.js' import { resolveAllErrors } from '../errors/errorPathResolver.js' import { createServerError } from '../errors/types.js' import { MutationCollector } from './MutationCollector.js' import type { EntityPersistedEvent, EntityPersistFailedEvent, EntityPersistingEvent } from '../events/types.js' -import type { EntitySnapshot } from '../store/snapshots.js' -import type { StoredHasManyState, StoredRelationState } from '../store/SnapshotStore.js' import { deepEqual } from '../utils/deepEqual.js' +import { + createPersistExecution, + entityIdentityKey, + type ExecutionEntity, + type PersistExecution, +} from './PersistExecution.js' /** * Options for BatchPersister @@ -56,6 +59,7 @@ export interface BatchPersisterOptions { /** One inline create operation inside a hasMany mutation, keyed by its alias (a temp ID). */ interface NodeCreateOp { readonly alias: string + readonly entityType: string readonly createData: Record } @@ -65,6 +69,22 @@ interface NodeCreatePair { readonly nodeItem: Record } +interface ExecutedMutationResult extends TransactionMutationResult { + readonly nodeData?: Record +} + +type ExecutedPersist = + | { + readonly mode: 'atomic' + readonly ok: boolean + readonly results: readonly ExecutedMutationResult[] + } + | { + readonly mode: 'sequential' + readonly ok: boolean + readonly results: readonly ExecutedMutationResult[] + } + /** * BatchPersister orchestrates multi-entity persistence with: * - Deduplication (same entity referenced multiple times → single mutation) @@ -78,6 +98,8 @@ export class BatchPersister { private readonly undoManager?: UndoManager private readonly schema?: SchemaRegistry private readonly defaultUpdateMode: UpdateMode + private readonly nestedOutcomes = new WeakMap() + private readonly eventOutcomes = new WeakMap() constructor( private readonly adapter: BackendAdapter, @@ -202,54 +224,19 @@ export class BatchPersister { // Sort by dependencies (creates first) const sortedEntities = this.sortByDependencies(entitiesToPersist) - - // Claim the batch before the first await, so a concurrent persist of the same - // entity is still skipped while the before-persist hooks run. - this.changeRegistry.markInFlight(sortedEntities) - - // Before-persist hooks run before any mutation is built, so store writes they - // make (normalisation, orphan cleanup) go out in this very save. With no hook - // registered the pipeline is skipped, keeping a plain persist free of the extra - // microtask it would otherwise cost. - let accepted: DirtyEntity[] = sortedEntities - let cancelled: readonly DirtyEntity[] = [] - - if (this.hasPersistingInterceptors(sortedEntities)) { - const outcome = await this.runPersistingInterceptors(sortedEntities) - accepted = outcome.accepted - cancelled = outcome.cancelled - - // A vetoed entity never entered the persisting state — just release its claim. - if (cancelled.length > 0) { - this.changeRegistry.clearInFlight(cancelled) - } - } - - if (accepted.length === 0) { - return this.mergeCancelled( - { success: true, results: [], successCount: 0, failedCount: 0, skippedCount: 0 }, - cancelled, - ) - } - - let attempted: PersistenceResult + const collector = this.mutationCollector instanceof MutationCollector + ? this.mutationCollector.forkSession() + : this.mutationCollector try { - attempted = await this.executePersist( - accepted, - new Set(cancelled.map(entity => entity.entityId)), - scope, - options, - updateMode, - ) + const result = await this.executePersist(sortedEntities, scope, options, updateMode, collector) + const callbackOutcomes = [...result.results, ...(this.nestedOutcomes.get(result) ?? [])] + this.emitPersistOutcome(this.eventOutcomes.get(result) ?? callbackOutcomes) + for (const entry of callbackOutcomes) options?.onEntityPersisted?.(entry) + return result } catch (error) { - this.emitPersistFailed(accepted, toError(error)) + this.emitPersistFailed(sortedEntities, toError(error)) throw error } - - // Emitted after the persisting flags are cleared, so listeners observe settled state. - this.emitPersistOutcome(attempted.results) - - return this.mergeCancelled(attempted, cancelled) } /** @@ -258,128 +245,102 @@ export class BatchPersister { */ private async executePersist( sortedEntities: DirtyEntity[], - vetoedEntityIds: ReadonlySet, scope: PersistScope, options: BatchPersistOptions | undefined, updateMode: UpdateMode, + collector: MutationDataCollector | undefined, ): Promise { - // Set persisting state for all entities. In pessimistic mode the flag also - // marks the entity for server-baseline presentation while in-flight. - for (const entity of sortedEntities) { - this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, true, updateMode === 'pessimistic')) - this.dispatcher.dispatch(clearAllServerErrors(entity.entityType, entity.entityId)) - } - - // Block undo during persist + const claimed = new Map() + const accepted = new Map() + const vetoed = new Map() + const offered = new Set() + const initialKeys = new Set(sortedEntities.map(entity => entityIdentityKey(entity.entityType, entity.entityId))) + this.claimEntities(sortedEntities, claimed, updateMode) this.undoManager?.block() let result: PersistenceResult | undefined try { - // Build mutations from the (dirty) canonical state. The store is never - // mutated to the server view — pessimistic mode presents the server - // baseline via getPresentationSnapshot instead — so there is nothing to - // capture or restore. - let mutations = this.buildMutations(sortedEntities, vetoedEntityIds, scope) - - // Entities that only came into being during collection (placeholder relations - // materialized by the collector) were not dirty when the hook phase ran, yet - // they do get `entity:persisted` — offer them to the interceptors too. Awaited - // only when one is registered, so a plain persist still reaches the adapter - // synchronously. - const lateEntities = this.collectLateNestedEntities(sortedEntities, vetoedEntityIds) - if (lateEntities.length > 0 && this.hasPersistingInterceptors(lateEntities)) { - mutations = await this.rebuildAfterLateVetoes(sortedEntities, vetoedEntityIds, scope, lateEntities, mutations) - } + let execution: PersistExecution + while (true) { + const pending = [...claimed.values()].filter(entity => !offered.has(entityIdentityKey(entity.entityType, entity.entityId))) + for (const entity of pending) { + const key = entityIdentityKey(entity.entityType, entity.entityId) + offered.add(key) + if (options?.onEntityPersisting) { + await options.onEntityPersisting(entity.entityType, entity.entityId) + } + const outcome = this.hasPersistingInterceptors([entity]) + ? await this.runPersistingInterceptors([entity]) + : { accepted: [entity], cancelled: [] } + if (outcome.cancelled.length > 0) { + vetoed.set(key, entity) + this.releaseEntities([entity]) + } else { + accepted.set(key, entity) + } + } - if (mutations.length === 0) { - // Nothing to persist - result = { - success: true, - results: [], - successCount: 0, - failedCount: 0, - skippedCount: 0, + const topLevel = sortedEntities.filter(entity => accepted.has(entityIdentityKey(entity.entityType, entity.entityId))) + const vetoedKeys = new Set(vetoed.keys()) + const mutations = this.buildMutations(topLevel, vetoedKeys, scope, collector) + const nested = collector instanceof MutationCollector ? collector.getNestedEntities() : [] + const discovered: DirtyEntity[] = [] + for (const entity of nested) { + const key = entityIdentityKey(entity.entityType, entity.entityId) + if (claimed.has(key) || vetoed.has(key)) continue + const dirty: DirtyEntity = { + entityType: entity.entityType, + entityId: entity.entityId, + changeType: 'create', + dirtyFields: this.store.getDirtyFields(entity.entityType, entity.entityId), + dirtyRelations: this.store.getDirtyRelations(entity.entityType, entity.entityId), + } + discovered.push(dirty) } - return result + if (discovered.length > 0) { + this.claimEntities(discovered, claimed, updateMode) + continue + } + + const executionEntities = [...accepted.values()].filter(entity => initialKeys.has(entityIdentityKey(entity.entityType, entity.entityId))) + execution = createPersistExecution( + this.store, + mutations, + executionEntities, + nested, + collector instanceof MutationCollector ? collector.getCollectedRelationFields() : [], + collector instanceof MutationCollector ? collector.getCollectedNestedCreates() : [], + collector instanceof MutationCollector ? collector.getCollectedNestedUpdates() : [], + collector instanceof MutationCollector ? collector.getCollectedHasOneChanges() : [], + collector instanceof MutationCollector ? collector.getCollectedHasManyChanges() : [], + [...vetoed.values()], + collector instanceof MutationCollector ? collector.getSuppressedRelationItems() : new Map(), + scope, + ) + break } - // Execute transaction - const transactionResult = await this.executeTransaction(mutations, options?.signal) + this.assertCustomCollectorIsSafe(execution, collector) + if (execution.mutations.length === 0) { + result = this.mergeCancelled(emptyPersistenceResult(), execution.vetoed) + return result + } - // Assigned to `result` so the finally block can gate the sweep on full success. - result = this.processTransactionResult(sortedEntities, transactionResult, scope, options) + const transactionResult = await this.executeTransaction(execution, options?.signal) + result = this.processExecutionResult(execution, transactionResult, options) + result = this.mergeCancelled(result, execution.vetoed) return result } finally { - // Clear in-flight status - this.changeRegistry.clearInFlight(sortedEntities) - - // Clear persisting state - for (const entity of sortedEntities) { - this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, false)) - } - - // Unblock undo + this.releaseEntities([...claimed.values()]) this.undoManager?.unblock() - - // Reclaim memory: drop snapshots of any created entities orphaned during - // editing. Only after a FULLY successful persist — on a failed or partial - // persist the user's creates and edits are left intact and dirty for a retry, - // and sweeping then could reclaim a create the user still intends to save. if (result?.success) { this.store.sweepUnreachableCreated() } } - } - - /** - * Runs the `entity:persisting` interceptors for late nested entities. A veto drops - * them and rebuilds the mutations once; materialization is idempotent, so the - * second pass sees the same store. - */ - private async rebuildAfterLateVetoes( - sortedEntities: DirtyEntity[], - vetoedEntityIds: ReadonlySet, - scope: PersistScope, - lateEntities: readonly DirtyEntity[], - mutations: TransactionMutation[], - ): Promise { - const { cancelled } = await this.runPersistingInterceptors(lateEntities) - if (cancelled.length === 0) return mutations - - const vetoed = new Set(vetoedEntityIds) - for (const entity of cancelled) vetoed.add(entity.entityId) - return this.buildMutations(sortedEntities, vetoed, scope) - } - - /** - * Nested entities the collector registered that were not part of the hook phase. - */ - private collectLateNestedEntities( - sortedEntities: readonly DirtyEntity[], - vetoedEntityIds: ReadonlySet, - ): DirtyEntity[] { - if (!(this.mutationCollector instanceof MutationCollector)) return [] - - const known = new Set(sortedEntities.map(entity => entity.entityId)) - const late: DirtyEntity[] = [] - for (const [entityId, entityType] of this.mutationCollector.getNestedEntityTypes()) { - if (known.has(entityId) || vetoedEntityIds.has(entityId)) continue - late.push({ entityType, entityId, changeType: 'create', dirtyFields: [], dirtyRelations: [] }) - } - return late - } - /** - * Commits an entity's relations after a successful persist, except the planned ops - * the collector dropped for vetoed items — those stay pending so the next persist - * sends them, rather than being folded into the server baseline unsent. - */ - private commitRelations(entityType: string, entityId: string): void { - const suppressed = this.mutationCollector instanceof MutationCollector - ? this.mutationCollector.getSuppressedRelationItems() - : undefined - this.store.commitAllRelations(entityType, entityId, suppressed?.size ? suppressed : undefined) + // Unreachable, but keeps the return type explicit when control-flow analysis changes. + return emptyPersistenceResult() } /** @@ -428,10 +389,9 @@ export class BatchPersister { */ private emitPersistOutcome(results: readonly EntityPersistResult[]): void { const emitter = this.dispatcher.getEventEmitter() - const emittedEntityKeys = new Set() for (const entry of results) { - emittedEntityKeys.add(`${entry.entityType}:${entry.entityId}`) + if (entry.skipped) continue if (entry.success) { emitter.emit({ type: 'entity:persisted', @@ -454,32 +414,6 @@ export class BatchPersister { } } - if (!(this.mutationCollector instanceof MutationCollector)) return - - const failure = results.find(entry => !entry.success)?.error?.message ?? 'Persist failed' - for (const [entityId, entityType] of this.mutationCollector.getNestedEntityTypes()) { - if (emittedEntityKeys.has(`${entityType}:${entityId}`)) continue - - if (this.store.existsOnServer(entityType, entityId)) { - emitter.emit({ - type: 'entity:persisted', - timestamp: Date.now(), - entityType, - entityId, - isNew: true, - persistedId: this.store.getPersistedId(entityType, entityId) ?? entityId, - } satisfies EntityPersistedEvent) - } else { - emitter.emit({ - type: 'entity:persistFailed', - timestamp: Date.now(), - entityType, - entityId, - isNew: true, - error: new Error(failure), - } satisfies EntityPersistFailedEvent) - } - } } /** @@ -641,21 +575,22 @@ export class BatchPersister { */ private buildMutations( entities: DirtyEntity[], - vetoedEntityIds: ReadonlySet, + vetoedEntityKeys: ReadonlySet, scope: PersistScope, + collector: MutationDataCollector | undefined, ): TransactionMutation[] { // Exclude only non-create entities from nesting — // new entities should be nested inside their parent's mutation // to maintain correct relation connections without transaction support. // Vetoed entities are kept apart: an excluded entity still has its parent-side // delete emitted, a vetoed one must not be written at all. - if (this.mutationCollector instanceof MutationCollector) { + if (collector instanceof MutationCollector) { const excludedIds = new Set() for (const entity of entities) { - if (entity.changeType !== 'create') excludedIds.add(entity.entityId) + if (entity.changeType !== 'create') excludedIds.add(entityIdentityKey(entity.entityType, entity.entityId)) } - this.mutationCollector.setExcludedEntities(excludedIds) - this.mutationCollector.setVetoedEntities(vetoedEntityIds) + collector.setExcludedEntityKeys(excludedIds) + collector.setVetoedEntityKeys(vetoedEntityKeys) } const mutations: TransactionMutation[] = [] @@ -677,13 +612,13 @@ export class BatchPersister { // Field-specific collection data = this.collectFieldsData(entity.entityType, entity.entityId, scope.fields) } else if (entity.changeType === 'create') { - const mc = this.mutationCollector + const mc = collector data = mc?.collectCreateData ? mc.collectCreateData(entity.entityType, entity.entityId) : this.collectCreateDataWithRelationCheck(entity) } else { - data = this.mutationCollector - ? this.mutationCollector.collectUpdateData(entity.entityType, entity.entityId) + data = collector + ? collector.collectUpdateData(entity.entityType, entity.entityId) : this.collectUpdateDataWithRelationCheck(entity) } @@ -699,10 +634,10 @@ export class BatchPersister { // Remove standalone create mutations for entities that were included // as nested inline creates inside another entity's mutation. - if (this.mutationCollector instanceof MutationCollector) { - const nestedIds = this.mutationCollector.getNestedEntityIds() - if (nestedIds.size > 0) { - return mutations.filter(m => !(m.operation === 'create' && nestedIds.has(m.entityId))) + if (collector instanceof MutationCollector) { + const nestedKeys = new Set(collector.getNestedEntities().map(entity => entityIdentityKey(entity.entityType, entity.entityId))) + if (nestedKeys.size > 0) { + return mutations.filter(m => !(m.operation === 'create' && nestedKeys.has(entityIdentityKey(m.entityType, m.entityId)))) } } @@ -814,16 +749,18 @@ export class BatchPersister { * Executes mutations as a transaction. */ private async executeTransaction( - mutations: TransactionMutation[], + execution: PersistExecution, signal?: AbortSignal, - ): Promise { + ): Promise { + const mutations = execution.mutations // Check if adapter supports transactions if ('persistTransaction' in this.adapter && typeof this.adapter.persistTransaction === 'function') { try { - return await this.adapter.persistTransaction(mutations) + const result = await this.adapter.persistTransaction(mutations) + return { mode: 'atomic', ok: result.ok, results: result.results } } catch (error) { - // Adapter threw an exception - mark all as failed return { + mode: 'atomic', ok: false, results: mutations.map(m => ({ entityType: m.entityType, @@ -835,8 +772,26 @@ export class BatchPersister { } } - // Fallback: execute sequentially (not truly transactional) - const results: TransactionResult['results'][number][] = [] + const missingCreate = mutations.some(mutation => mutation.operation === 'create') && !this.adapter.create + const missingDelete = mutations.some(mutation => mutation.operation === 'delete') && !this.adapter.delete + if (missingCreate || missingDelete) { + const message = [ + missingCreate ? 'Adapter does not implement create' : '', + missingDelete ? 'Adapter does not implement delete' : '', + ].filter(Boolean).join('; ') + return { + mode: 'sequential', + ok: false, + results: mutations.map(mutation => ({ + entityType: mutation.entityType, + entityId: mutation.entityId, + ok: false, + errorMessage: message, + })), + } + } + + const results: ExecutedMutationResult[] = [] let allOk = true for (const mutation of mutations) { @@ -867,16 +822,13 @@ export class BatchPersister { } else if (mutation.operation === 'create') { if (this.adapter.create && mutation.data) { const result = await this.adapter.create(mutation.entityType, mutation.data) - const persistedId = result.data?.['id'] as string | undefined - const nestedResults = result.ok && result.data && mutation.data - ? this.extractNestedResultsFromNode(mutation.data, result.data, mutation.entityType, mutation.entityId) - : undefined + const persistedId = getStringProperty(result.data, 'id') results.push({ entityType: mutation.entityType, entityId: mutation.entityId, ok: result.ok, persistedId, - nestedResults, + nodeData: result.data, errorMessage: result.errorMessage, mutationResult: result.mutationResult, }) @@ -890,14 +842,11 @@ export class BatchPersister { mutation.entityId, mutation.data, ) - const nestedResults = result.ok && result.data && mutation.data - ? this.extractNestedResultsFromNode(mutation.data, result.data, mutation.entityType, mutation.entityId) - : undefined results.push({ entityType: mutation.entityType, entityId: mutation.entityId, ok: result.ok, - nestedResults, + nodeData: result.data, errorMessage: result.errorMessage, mutationResult: result.mutationResult, }) @@ -915,129 +864,360 @@ export class BatchPersister { } } - return { ok: allOk, results } + return { mode: 'sequential', ok: allOk, results } } /** * Processes transaction result and commits/rolls back as needed. * For pessimistic mode, restores captured state on success. */ - private processTransactionResult( - entities: DirtyEntity[], - transactionResult: TransactionResult, - scope: PersistScope, + private processExecutionResult( + execution: PersistExecution, + transactionResult: ExecutedPersist, options?: BatchPersistOptions, ): PersistenceResult { const results: EntityPersistResult[] = [] - let successCount = 0 - let failedCount = 0 + const nestedResults: EntityPersistResult[] = [] const rollbackOnError = options?.rollbackOnError ?? false + const atomicFailure = transactionResult.mode === 'atomic' && ( + !transactionResult.ok || transactionResult.results.some(entry => !entry.ok) + ) - if (transactionResult.ok) { - // All succeeded - commit all - for (let i = 0; i < transactionResult.results.length; i++) { - const mutationResult = transactionResult.results[i]! - const entity = entities.find( - e => e.entityType === mutationResult.entityType && e.entityId === mutationResult.entityId, - ) + for (const mutation of execution.mutations) { + const mutationResult = transactionResult.results.find(entry => ( + entry.entityType === mutation.entityType && entry.entityId === mutation.entityId + )) + const entity = execution.entities.find(entry => ( + entry.entityType === mutation.entityType && entry.entityId === mutation.entityId + )) + if (!entity) continue + + const adapterSucceeded = mutationResult?.ok === true && !atomicFailure + if (!adapterSucceeded) { + const message = atomicFailure + ? mutationResult?.errorMessage ?? 'Atomic transaction failed' + : mutationResult?.errorMessage ?? 'Mutation result missing' + this.mapServerErrors(entity.entityType, entity.entityId, mutationResult?.mutationResult, message) + if (rollbackOnError) this.rollbackExecutionEntity(entity) + results.push(toFailedResult(entity, message, mutationResult?.mutationResult)) + for (const nested of this.expectedNestedEntities(execution, mutation)) { + nestedResults.push(toFailedResult(nested, message)) + } + for (const nested of this.expectedNestedUpdates(execution, mutation)) { + nestedResults.push(toFailedResult(nested, message)) + } + continue + } + if (entity.operation === 'create' && isTempId(entity.entityId) && !mutationResult?.persistedId) { + const message = `Create of ${entity.entityType}:${entity.entityId} succeeded without a server ID` + this.mapServerErrors(entity.entityType, entity.entityId, undefined, message) + results.push(toFailedResult(entity, message)) + continue + } - if (entity) { - // The store was never mutated to the server view, so a successful - // persist just commits the dirty state as the new server baseline — - // the same path optimistic mode always took. - if (scope.type === 'fields' && scope.entityType === entity.entityType && scope.entityId === entity.entityId) { - // Partial commit for field scope - this.store.commitFields(entity.entityType, entity.entityId, [...scope.fields]) - } else { - // Full commit - this.dispatcher.dispatch(commitEntity(entity.entityType, entity.entityId)) - this.commitRelations(entity.entityType, entity.entityId) - } + const resolvedNested = this.resolveNestedResults(execution, mutation, mutationResult) + const nestedUpdates = this.expectedNestedUpdates(execution, mutation) + const involved = [entity, ...resolvedNested.entities, ...nestedUpdates] + const conflicts = this.reconcileConfirmedEntities(execution, involved, new Set(resolvedNested.persistedIds.keys())) + this.mapConfirmedIds(entity, mutationResult.persistedId, resolvedNested.persistedIds) + + for (const nested of resolvedNested.entities) { + const key = entityIdentityKey(nested.entityType, nested.entityId) + const conflict = conflicts.get(key)?.join('; ') + nestedResults.push(conflict + ? toFailedResult(nested, conflict) + : toSuccessResult(nested, resolvedNested.persistedIds.get(key))) + } + for (const nested of nestedUpdates) { + const conflict = conflicts.get(entityIdentityKey(nested.entityType, nested.entityId))?.join('; ') + nestedResults.push(conflict ? toFailedResult(nested, conflict) : toSuccessResult(nested)) + } + const unresolvedMessage = resolvedNested.unresolved.length > 0 + ? `Missing or ambiguous server ID for nested create ${resolvedNested.unresolved.map(item => `${item.entityType}:${item.entityId}`).join(', ')}` + : undefined + for (const nested of resolvedNested.unresolved) { + nestedResults.push(toFailedResult(nested, unresolvedMessage ?? 'Nested create could not be resolved')) + } + const conflictMessage = [...conflicts.values()].flat().join('; ') + const failure = [unresolvedMessage, conflictMessage || undefined].filter((message): message is string => message !== undefined).join('; ') + if (failure) { + this.mapServerErrors(entity.entityType, entity.entityId, undefined, failure) + results.push(toFailedResult(entity, failure)) + } else { + results.push(toSuccessResult(entity, this.persistedIdFor(entity, mutationResult.persistedId))) + } + } - // Map temp ID if create - if (entity.changeType === 'create' && mutationResult.persistedId) { - this.store.mapTempIdToPersistedId( - entity.entityType, - entity.entityId, - mutationResult.persistedId, - ) - } + for (const nested of execution.entities.filter(entity => entity.nested)) { + const key = entityIdentityKey(nested.entityType, nested.entityId) + if (nestedResults.some(entry => entityIdentityKey(entry.entityType, entry.entityId) === key)) continue + if (atomicFailure) nestedResults.push(toFailedResult(nested, 'Atomic transaction failed')) + } - // Process nested results (inline-created entities within this mutation) - if (mutationResult.nestedResults) { - this.commitNestedResults(mutationResult.nestedResults) - } + const successCount = results.filter(entry => entry.success).length + const failedCount = results.length - successCount + const result: PersistenceResult = { + success: failedCount === 0 && transactionResult.ok, + results, + successCount, + failedCount, + skippedCount: 0, + } + this.nestedOutcomes.set(result, nestedResults) + if (atomicFailure) { + this.eventOutcomes.set(result, [ + ...execution.mutations.map(mutation => { + const entity = execution.entities.find(candidate => ( + candidate.entityType === mutation.entityType && candidate.entityId === mutation.entityId + )) + return entity ? toFailedResult(entity, 'Atomic transaction failed') : undefined + }).filter((entry): entry is EntityPersistResult => entry !== undefined), + ...nestedResults, + ]) + } + return result + } - results.push({ - entityType: entity.entityType, - entityId: entity.entityId, - operation: entity.changeType, - success: true, - persistedId: mutationResult.persistedId, - }) - successCount++ - } - } + private claimEntities( + entities: readonly DirtyEntity[], + claimed: Map, + updateMode: UpdateMode, + ): void { + const fresh = entities.filter(entity => !claimed.has(entityIdentityKey(entity.entityType, entity.entityId))) + if (fresh.length === 0) return + this.changeRegistry.markInFlight(fresh) + for (const entity of fresh) { + claimed.set(entityIdentityKey(entity.entityType, entity.entityId), entity) + this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, true, updateMode === 'pessimistic')) + this.dispatcher.dispatch(clearAllServerErrors(entity.entityType, entity.entityId)) + } + } - // Commit nested entities that don't have explicit results from the adapter. - // These entities were nested inside a parent mutation's data and exist on the server, - // but the adapter may not provide individual results for them. - this.commitUnresolvedNestedEntities(entities) - } else { - // Transaction failed - map errors and optionally rollback - // For pessimistic mode, entities are already at server state, no rollback needed - for (const mutationResult of transactionResult.results) { - const entity = entities.find( - e => e.entityType === mutationResult.entityType && e.entityId === mutationResult.entityId, + private releaseEntities(entities: readonly DirtyEntity[]): void { + if (entities.length === 0) return + this.changeRegistry.clearInFlight(entities) + for (const entity of entities) { + this.dispatcher.dispatch(setPersisting(entity.entityType, entity.entityId, false)) + } + } + + private assertCustomCollectorIsSafe( + execution: PersistExecution, + collector: MutationDataCollector | undefined, + ): void { + if (!collector || collector instanceof MutationCollector) return + for (const entity of execution.entities) { + const relations = this.store.getDirtyRelations(entity.entityType, entity.entityId) + if (relations.length > 0) { + throw new Error( + `Custom mutation collectors support scalar data only; ${entity.entityType}:${entity.entityId} has relation changes`, ) + } + } + if (execution.mutations.some(mutation => containsNestedMutation(mutation.data))) { + throw new Error('Custom mutation collectors cannot emit nested or relation mutation operations') + } + } - if (entity) { - if (!mutationResult.ok) { - // Map errors to fields - this.mapServerErrors( - entity.entityType, - entity.entityId, - mutationResult.mutationResult, - mutationResult.errorMessage, - ) + private resolveNestedResults( + execution: PersistExecution, + mutation: TransactionMutation, + result: ExecutedMutationResult, + ): { + readonly entities: readonly ExecutionEntity[] + readonly unresolved: readonly ExecutionEntity[] + readonly persistedIds: ReadonlyMap + } { + const expected = this.expectedNestedEntities(execution, mutation) + if (expected.length === 0) return { entities: [], unresolved: [], persistedIds: new Map() } + + let supplied = result.nestedResults ?? [] + if (supplied.length === 0 && result.nodeData) { + supplied = this.extractNestedResultsFromNode( + execution, + result.nodeData, + mutation.entityType, + mutation.entityId, + ) + } + const flattened = flattenMutationResults(supplied) + const persistedIds = new Map() + const unresolved: ExecutionEntity[] = [] + const typesById = new Map>() + for (const entity of expected) { + const types = typesById.get(entity.entityId) + if (types) types.add(entity.entityType) + else typesById.set(entity.entityId, new Set([entity.entityType])) + } + for (const entity of expected) { + if ((typesById.get(entity.entityId)?.size ?? 0) > 1) { + unresolved.push(entity) + continue + } + const exactMatch = flattened.find(entry => ( + entry.entityType === entity.entityType && entry.entityId === entity.entityId && entry.ok + )) + const match = exactMatch ?? flattened.find(entry => ( + entry.entityType === 'Unknown' + && entry.entityId === entity.entityId + && entry.ok + && typesById.get(entity.entityId)?.size === 1 + )) + const persistedId = match?.persistedId ?? (!isTempId(entity.entityId) ? entity.entityId : undefined) + if (!persistedId) unresolved.push(entity) + else persistedIds.set(entityIdentityKey(entity.entityType, entity.entityId), persistedId) + } + return { entities: expected.filter(entity => !unresolved.includes(entity)), unresolved, persistedIds } + } - // The store was never mutated to the server view, so on failure the - // entity's edits and creates are already intact and dirty — they - // simply survive for a retry (P2), no restore needed. Rollback to - // server state happens only when rollbackOnError is set. - if (rollbackOnError) { - this.rollbackEntity(entity) - } - } + private expectedNestedEntities( + execution: PersistExecution, + mutation: TransactionMutation, + ): ExecutionEntity[] { + return this.expectedNestedGraph(execution, mutation).creates + } - results.push({ - entityType: entity.entityType, - entityId: entity.entityId, - operation: entity.changeType, - success: mutationResult.ok, - error: mutationResult.ok ? undefined : { - message: mutationResult.errorMessage ?? 'Unknown error', - mutationResult: mutationResult.mutationResult, - }, - persistedId: mutationResult.persistedId, - }) + private expectedNestedUpdates(execution: PersistExecution, mutation: TransactionMutation): ExecutionEntity[] { + return this.expectedNestedGraph(execution, mutation).updates + } - if (mutationResult.ok) { - successCount++ - } else { - failedCount++ - } + private expectedNestedGraph( + execution: PersistExecution, + mutation: TransactionMutation, + ): { readonly creates: ExecutionEntity[]; readonly updates: ExecutionEntity[] } { + const entities = new Map(execution.entities.map(entity => [entityIdentityKey(entity.entityType, entity.entityId), entity])) + const ownerQueue = [entityIdentityKey(mutation.entityType, mutation.entityId)] + const owners = new Set(ownerQueue) + const creates = new Map() + const updates = new Map() + for (let index = 0; index < ownerQueue.length; index++) { + const owner = ownerQueue[index]! + for (const descriptor of execution.nestedCreates) { + if (entityIdentityKey(descriptor.parentEntityType, descriptor.parentEntityId) !== owner) continue + const key = entityIdentityKey(descriptor.entityType, descriptor.entityId) + const entity = entities.get(key) + if (!entity) continue + creates.set(key, entity) + if (!owners.has(key)) { + owners.add(key) + ownerQueue.push(key) + } + } + for (const update of execution.nestedUpdates) { + if (entityIdentityKey(update.parentEntityType, update.parentEntityId) !== owner) continue + const key = entityIdentityKey(update.entityType, update.entityId) + const entity = entities.get(key) + if (!entity) continue + updates.set(key, entity) + if (!owners.has(key)) { + owners.add(key) + ownerQueue.push(key) } } } + return { creates: [...creates.values()], updates: [...updates.values()] } + } - return { - success: transactionResult.ok, - results, - successCount, - failedCount, - skippedCount: 0, + private reconcileConfirmedEntities( + execution: PersistExecution, + entities: readonly ExecutionEntity[], + confirmedNestedCreates: ReadonlySet, + ): ReadonlyMap { + const keys = new Set(entities.map(entity => entityIdentityKey(entity.entityType, entity.entityId))) + const conflicts = new Map() + const addConflict = (key: string, message: string): void => { + const messages = conflicts.get(key) + if (messages) messages.push(message) + else conflicts.set(key, [message]) + } + for (const entity of entities) { + const key = entityIdentityKey(entity.entityType, entity.entityId) + if (entity.operation === 'delete') { + if (!this.store.isScheduledForDeletion(entity.entityType, entity.entityId)) { + this.store.setExistsOnServer(entity.entityType, entity.entityId, false) + addConflict(key, `Delete of ${entity.entityType}:${entity.entityId} completed after the local deletion was reversed`) + continue + } + this.store.removeEntity(entity.entityType, entity.entityId) + continue + } + this.store.refreshServerData(entity.entityType, entity.entityId, entity.scalarData) } + + for (const change of execution.hasOneChanges) { + const ownerKey = entityIdentityKey(change.entityType, change.entityId) + if (!keys.has(ownerKey)) continue + if (change.transition.operation === 'create') { + const descriptor = execution.nestedCreates.find(item => ( + item.parentEntityType === change.entityType + && item.parentEntityId === change.entityId + && item.fieldName === change.fieldName + && item.entityId === change.transition.targetId + )) + if (!descriptor || !confirmedNestedCreates.has(entityIdentityKey(descriptor.entityType, descriptor.entityId))) continue + } + const outcome = this.store.reconcileSentRelation( + change.entityType, + change.entityId, + change.fieldName, + change.transition, + ) + if (outcome === 'conflict') addConflict(ownerKey, relationConflictMessage(change.entityType, change.entityId, change.fieldName)) + } + for (const change of execution.hasManyChanges) { + const ownerKey = entityIdentityKey(change.entityType, change.entityId) + if (!keys.has(ownerKey)) continue + const additions = change.additions.filter(addition => { + if (addition.kind !== 'created') return true + const descriptor = execution.nestedCreates.find(item => ( + item.parentEntityType === change.entityType + && item.parentEntityId === change.entityId + && item.fieldName === change.fieldName + && item.entityId === addition.itemId + )) + return descriptor !== undefined && confirmedNestedCreates.has(entityIdentityKey(descriptor.entityType, descriptor.entityId)) + }) + if (additions.length === 0 && change.removals.length === 0) continue + const outcome = this.store.reconcileSentHasMany( + change.entityType, + change.entityId, + change.fieldName, + { additions, removals: change.removals }, + ) + if (outcome === 'conflict') addConflict(ownerKey, relationConflictMessage(change.entityType, change.entityId, change.fieldName)) + } + return conflicts + } + + private mapConfirmedIds( + entity: ExecutionEntity, + persistedId: string | undefined, + nestedIds: ReadonlyMap, + ): void { + for (const [key, id] of nestedIds) { + const nested = splitExecutionKey(key) + if (isTempId(nested.entityId)) { + this.store.mapTempIdToPersistedId(nested.entityType, nested.entityId, id) + } + } + if (entity.operation === 'create' && isTempId(entity.entityId) && persistedId) { + this.store.mapTempIdToPersistedId(entity.entityType, entity.entityId, persistedId) + } + } + + private persistedIdFor(entity: ExecutionEntity, persistedId: string | undefined): string | undefined { + if (entity.operation !== 'create') return undefined + return persistedId ?? (!isTempId(entity.entityId) ? entity.entityId : undefined) + } + + private rollbackExecutionEntity(entity: ExecutionEntity): void { + this.rollbackEntity({ + entityType: entity.entityType, + entityId: entity.entityId, + changeType: entity.operation, + dirtyFields: [], + dirtyRelations: [], + }) } /** @@ -1129,186 +1309,89 @@ export class BatchPersister { } } - /** - * Recursively commits nested entity results and maps their server IDs. - * Called when the adapter provides nestedResults in the transaction response. - * Uses the MutationCollector's nestedEntityTypes map to resolve entity types, - * since the adapter may not know the correct entity type for nested entities. - */ - private commitNestedResults(nestedResults: readonly TransactionMutationResult[]): void { - const nestedTypes = this.mutationCollector instanceof MutationCollector - ? this.mutationCollector.getNestedEntityTypes() - : null - - for (const nested of nestedResults) { - if (!nested.ok) continue - - // Resolve entity type from collector's tracking (adapter may report 'Unknown') - const entityType = nestedTypes?.get(nested.entityId) ?? nested.entityType - const snapshot = this.store.getEntitySnapshot(entityType, nested.entityId) - if (!snapshot) continue - - // Commit the nested entity - this.dispatcher.dispatch(commitEntity(entityType, nested.entityId)) - this.commitRelations(entityType, nested.entityId) - this.store.setExistsOnServer(entityType, nested.entityId, true) - - // Map temp ID to server-assigned ID - if (nested.persistedId) { - this.store.mapTempIdToPersistedId( - entityType, - nested.entityId, - nested.persistedId, - ) - } - - // Recurse for deeper nesting - if (nested.nestedResults) { - this.commitNestedResults(nested.nestedResults) - } - } - } - - /** - * Commits nested entities that were part of the transaction but don't have - * explicit results from the adapter. These entities were created inline - * inside a parent mutation and exist on the server after a successful persist. - */ - private commitUnresolvedNestedEntities(entities: DirtyEntity[]): void { - if (!(this.mutationCollector instanceof MutationCollector)) return - - const nestedTypes = this.mutationCollector.getNestedEntityTypes() - if (nestedTypes.size === 0) return - - // Find nested entities that are in the entities list but weren't committed - // by the main result processing (they had no matching result from the adapter) - for (const entity of entities) { - if (entity.changeType !== 'create') continue - if (!nestedTypes.has(entity.entityId)) continue - - // Check if this entity was already committed (has server data) - if (this.store.existsOnServer(entity.entityType, entity.entityId)) continue - - // Commit it — the parent mutation succeeded, so this entity exists on the server - this.dispatcher.dispatch(commitEntity(entity.entityType, entity.entityId)) - this.commitRelations(entity.entityType, entity.entityId) - this.store.setExistsOnServer(entity.entityType, entity.entityId, true) - } - - // Also commit materialized entities that may not be in the entities list - // (they were created during mutation building by materializeEmbeddedHasMany/HasOne) - for (const [tempId, entityType] of nestedTypes) { - if (this.store.existsOnServer(entityType, tempId)) continue - - const snapshot = this.store.getEntitySnapshot(entityType, tempId) - if (!snapshot) continue - - this.dispatcher.dispatch(commitEntity(entityType, tempId)) - this.commitRelations(entityType, tempId) - this.store.setExistsOnServer(entityType, tempId, true) - } - } - - /** - * Extracts nested entity results from a mutation's node response data. - * Walks the mutation data structure to find inline create operations, - * then matches them against the node response to extract server-assigned IDs. - * - * For hasOne creates: looks up the temp ID from the store's relation state - * for the parent entity, then gets the server ID from the response node field. - * For hasMany creates: filters new IDs from the response array by excluding - * known pre-existing IDs, matching to temp IDs (aliases) by position. - */ + /** Resolves nested create responses from the immutable planning descriptors. */ private extractNestedResultsFromNode( - mutationData: Record, + execution: PersistExecution, nodeData: Record, parentEntityType: string, parentEntityId: string, ): TransactionMutationResult[] { const results: TransactionMutationResult[] = [] - const nestedTypes = this.mutationCollector instanceof MutationCollector - ? this.mutationCollector.getNestedEntityTypes() - : null - const makeResult = ( - tempId: string, - createData: Record, + op: NodeCreateOp, nodeItem: Record, ): TransactionMutationResult => { const childResults = this.extractNestedResultsFromNode( - createData, nodeItem, - nestedTypes?.get(tempId) ?? 'Unknown', tempId, + execution, + nodeItem, + op.entityType, + op.alias, ) return { - entityType: nestedTypes?.get(tempId) ?? 'Unknown', - entityId: tempId, + entityType: op.entityType, + entityId: op.alias, ok: true, - persistedId: nodeItem['id'] as string, + persistedId: getStringProperty(nodeItem, 'id'), nestedResults: childResults.length > 0 ? childResults : undefined, } } - - for (const [fieldName, fieldValue] of Object.entries(mutationData)) { - if (fieldValue === null || fieldValue === undefined) continue - - if (Array.isArray(fieldValue)) { - const nodeItems = nodeData[fieldName] - if (!Array.isArray(nodeItems)) continue - - // Separate create ops from known IDs (connect/update) - const knownIds = new Set() - const createOps: NodeCreateOp[] = [] - - for (const op of fieldValue) { - if (typeof op !== 'object' || op === null) continue - const opObj = op as Record - - if ('connect' in opObj) { - const connectBy = opObj['connect'] as Record - if (connectBy['id']) knownIds.add(connectBy['id'] as string) - } else if ('update' in opObj) { - const by = (opObj['update'] as Record)['by'] as Record | undefined - if (by?.['id']) knownIds.add(by['id'] as string) - } else if ('create' in opObj && opObj['alias']) { - createOps.push({ - alias: opObj['alias'] as string, - createData: opObj['create'] as Record, - }) - } - } - - // Include pre-existing server IDs - const hasManyState = this.store.getHasMany(parentEntityType, parentEntityId, fieldName) - if (hasManyState) { - for (const id of hasManyState.serverIds) knownIds.add(id) - } - - // Filter response to new items only, then content-match to create ops. - // Contember API does not guarantee hasMany ordering in node response, - // so we match by scalar field values instead of position. - const unmatchedItems = (nodeItems as Record[]) - .filter(it => typeof it === 'object' && it !== null && !knownIds.has(it['id'] as string)) - - for (const { op, nodeItem } of this.pairCreateOpsWithNodes(createOps, unmatchedItems)) { - results.push(makeResult(op.alias, op.createData, nodeItem)) - } - } else if (typeof fieldValue === 'object') { - const opObj = fieldValue as Record - const nodeField = nodeData[fieldName] - - if ('create' in opObj && typeof nodeField === 'object' && nodeField !== null) { - const serverId = (nodeField as Record)['id'] as string | undefined - if (!serverId) continue - - const relationState = this.store.getRelation(parentEntityType, parentEntityId, fieldName) - if (!relationState?.currentId) continue - - results.push(makeResult( - relationState.currentId, - opObj['create'] as Record, - nodeField as Record, + const descriptors = execution.nestedCreates.filter(descriptor => ( + descriptor.parentEntityType === parentEntityType && descriptor.parentEntityId === parentEntityId + )) + for (const descriptor of descriptors.filter(item => item.relationType === 'hasOne')) { + const nodeItem = nodeData[descriptor.fieldName] + if (!isRecord(nodeItem)) continue + results.push(makeResult({ + alias: descriptor.entityId, + entityType: descriptor.entityType, + createData: { ...descriptor.createData }, + }, nodeItem)) + } + const hasManyFields = new Set( + descriptors.filter(item => item.relationType === 'hasMany').map(item => item.fieldName), + ) + for (const fieldName of hasManyFields) { + const fieldDescriptors = descriptors.filter(item => item.relationType === 'hasMany' && item.fieldName === fieldName) + const knownIds = new Set(fieldDescriptors.flatMap(item => item.knownServerIds)) + const nodeItems = recordArray(nodeData[fieldName]).filter(item => { + const id = getStringProperty(item, 'id') + return id === undefined || !knownIds.has(id) + }) + const createOps = fieldDescriptors.map(descriptor => ({ + alias: descriptor.entityId, + entityType: descriptor.entityType, + createData: { ...descriptor.createData }, + })) + for (const { op, nodeItem } of this.pairCreateOpsWithNodes(createOps, nodeItems)) { + results.push(makeResult(op, nodeItem)) + } + } + const updates = execution.nestedUpdates.filter(update => ( + update.parentEntityType === parentEntityType && update.parentEntityId === parentEntityId + )) + for (const update of updates) { + if (update.relationType === 'hasOne') { + const nodeItem = nodeData[update.fieldName] + if (isRecord(nodeItem)) { + results.push(...this.extractNestedResultsFromNode( + execution, + nodeItem, + update.entityType, + update.entityId, )) } + continue + } + const nodeItem = recordArray(nodeData[update.fieldName]).find(item => ( + getStringProperty(item, 'id') === update.entityId + )) + if (nodeItem) { + results.push(...this.extractNestedResultsFromNode( + execution, + nodeItem, + update.entityType, + update.entityId, + )) } } @@ -1318,10 +1401,8 @@ export class BatchPersister { /** * Pairs inline create operations with the response rows they produced. * - * An unambiguous match is taken first: an op with several candidate rows waits until - * the other ops have consumed theirs, so a payload that is a subset of its sibling's - * no longer steals that sibling's row. What is left falls back to first-fit, which - * keeps indistinguishable siblings (identical payloads) mapped. + * An unambiguous match is taken first. Indistinguishable siblings remain unresolved + * instead of being paired by response position. * * A single op and row left over are paired by elimination. A server that echoes a * value back normalised (a date, a decimal) matches nothing byte-for-byte, and @@ -1352,12 +1433,6 @@ export class BatchPersister { continue } - const ambiguous = candidatesPerOp.findIndex(candidates => candidates.length > 1) - if (ambiguous >= 0) { - takePair(ambiguous, candidatesPerOp[ambiguous]![0]!) - continue - } - break } @@ -1411,3 +1486,82 @@ export class BatchPersister { function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } + +function emptyPersistenceResult(): PersistenceResult { + return { + success: true, + results: [], + successCount: 0, + failedCount: 0, + skippedCount: 0, + } +} + +function toSuccessResult(entity: ExecutionEntity, persistedId?: string): EntityPersistResult { + return { + entityType: entity.entityType, + entityId: entity.entityId, + operation: entity.operation, + success: true, + persistedId, + } +} + +function toFailedResult( + entity: ExecutionEntity, + message: string, + mutationResult?: ContemberMutationResult, +): EntityPersistResult { + return { + entityType: entity.entityType, + entityId: entity.entityId, + operation: entity.operation, + success: false, + error: { message, mutationResult }, + } +} + +function relationConflictMessage(entityType: string, entityId: string, fieldName: string): string { + return `Persisted relation ${entityType}:${entityId}.${fieldName} conflicts with a newer local change` +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getStringProperty(value: unknown, property: string): string | undefined { + if (!isRecord(value)) return undefined + const propertyValue = value[property] + return typeof propertyValue === 'string' ? propertyValue : undefined +} + +function recordArray(value: unknown): Record[] { + if (!Array.isArray(value)) return [] + return value.filter(isRecord) +} + +function containsNestedMutation(value: unknown): boolean { + if (Array.isArray(value)) return value.some(containsNestedMutation) + if (!isRecord(value)) return false + if ('create' in value || 'connect' in value || 'disconnect' in value || 'delete' in value || 'update' in value) { + return true + } + return Object.values(value).some(containsNestedMutation) +} + +function flattenMutationResults(results: readonly TransactionMutationResult[]): TransactionMutationResult[] { + const flattened: TransactionMutationResult[] = [] + for (const result of results) { + flattened.push(result) + if (result.nestedResults) flattened.push(...flattenMutationResults(result.nestedResults)) + } + return flattened +} + +function splitExecutionKey(key: string): { entityType: string; entityId: string } { + const separator = key.indexOf(':') + return { + entityType: separator < 0 ? '' : key.slice(0, separator), + entityId: separator < 0 ? key : key.slice(separator + 1), + } +} diff --git a/packages/bindx/src/persistence/MutationCollector.ts b/packages/bindx/src/persistence/MutationCollector.ts index c2971c3e..0263a10c 100644 --- a/packages/bindx/src/persistence/MutationCollector.ts +++ b/packages/bindx/src/persistence/MutationCollector.ts @@ -18,6 +18,59 @@ export interface EntityMutationResult { data?: Record } +export interface CollectedNestedEntity { + readonly entityType: string + readonly entityId: string +} + +export interface CollectedHasOneChange { + readonly entityType: string + readonly entityId: string + readonly fieldName: string + readonly transition: + | { readonly operation: 'connect'; readonly targetId: string } + | { readonly operation: 'create'; readonly targetId: string } + | { readonly operation: 'disconnect'; readonly targetId: string } + | { readonly operation: 'delete'; readonly targetId: string } +} + +export interface CollectedHasManyChange { + readonly entityType: string + readonly entityId: string + readonly fieldName: string + readonly additions: readonly { readonly itemId: string; readonly kind: 'created' | 'connected' }[] + readonly removals: readonly { readonly itemId: string; readonly type: 'delete' | 'disconnect' }[] +} + +export interface CollectedRelationField { + readonly entityType: string + readonly entityId: string + readonly fieldName: string + readonly relationType: 'hasOne' | 'hasMany' + readonly targetEntityType: string +} + +export interface CollectedNestedCreate { + readonly parentEntityType: string + readonly parentEntityId: string + readonly fieldName: string + readonly relationType: 'hasOne' | 'hasMany' + readonly entityType: string + readonly entityId: string + readonly createData: Readonly> + readonly knownServerIds: readonly string[] +} + +export interface CollectedNestedUpdate { + readonly parentEntityType: string + readonly parentEntityId: string + readonly fieldName: string + readonly relationType: 'hasOne' | 'hasMany' + readonly entityType: string + readonly entityId: string + readonly data: Readonly> +} + /** * MutationCollector builds Contember-compatible mutation input * by collecting changes from SnapshotStore including: @@ -43,12 +96,27 @@ export class MutationCollector implements MutationDataCollector { private readonly _nestedEntityIds: Set = new Set() /** Maps nested entity temp IDs to their entity types for post-persist processing */ private readonly _nestedEntityTypes: Map = new Map() + private readonly _nestedEntities = new Map() + private readonly _hasOneChanges: CollectedHasOneChange[] = [] + private readonly _hasManyChanges: CollectedHasManyChange[] = [] + private readonly _relationFields = new Map() + private readonly _nestedCreates: CollectedNestedCreate[] = [] + private readonly _nestedUpdates: CollectedNestedUpdate[] = [] constructor( private readonly store: SnapshotStore, private readonly schemaProvider: MutationSchemaProvider, ) {} + /** Creates an isolated collection session for one persist operation. */ + forkSession(): MutationCollector { + return new MutationCollector(this.store, this.schemaProvider) + } + + private entityKey(entityType: string, entityId: string): string { + return `${entityType}:${entityId}` + } + /** * Sets entity IDs that get their own top-level mutation, so their nested * update is skipped to avoid duplicate changes. Relation operations that only @@ -59,6 +127,16 @@ export class MutationCollector implements MutationDataCollector { this._nestedEntityIds.clear() this._nestedEntityTypes.clear() this._suppressedRelationItems.clear() + this._nestedEntities.clear() + this._hasOneChanges.length = 0 + this._hasManyChanges.length = 0 + this._relationFields.clear() + this._nestedCreates.length = 0 + this._nestedUpdates.length = 0 + } + + setExcludedEntityKeys(keys: ReadonlySet): void { + this.setExcludedEntities(keys) } /** @@ -70,6 +148,10 @@ export class MutationCollector implements MutationDataCollector { this.vetoedEntityIds = ids } + setVetoedEntityKeys(keys: ReadonlySet): void { + this.vetoedEntityIds = keys + } + /** * Returns IDs of entities that were included as nested inline creates * inside another entity's mutation data. These entities don't need @@ -87,6 +169,56 @@ export class MutationCollector implements MutationDataCollector { return this._nestedEntityTypes } + getNestedEntities(): readonly CollectedNestedEntity[] { + return [...this._nestedEntities.values()] + } + + getCollectedHasOneChanges(): readonly CollectedHasOneChange[] { + return this._hasOneChanges + } + + getCollectedHasManyChanges(): readonly CollectedHasManyChange[] { + return this._hasManyChanges + } + + getCollectedRelationFields(): readonly CollectedRelationField[] { + return [...this._relationFields.values()] + } + + getCollectedNestedCreates(): readonly CollectedNestedCreate[] { + return this._nestedCreates + } + + getCollectedNestedUpdates(): readonly CollectedNestedUpdate[] { + return this._nestedUpdates + } + + private recordRelationField( + entityType: string, + entityId: string, + fieldName: string, + relationType: 'hasOne' | 'hasMany', + targetEntityType: string | undefined, + ): void { + if (!targetEntityType) return + const key = `${this.entityKey(entityType, entityId)}:${fieldName}` + this._relationFields.set(key, { entityType, entityId, fieldName, relationType, targetEntityType }) + } + + private registerNestedEntity(entityType: string, entityId: string): void { + this._nestedEntityIds.add(entityId) + this._nestedEntityTypes.set(entityId, entityType) + this._nestedEntities.set(this.entityKey(entityType, entityId), { entityType, entityId }) + } + + private isExcluded(entityType: string, entityId: string): boolean { + return this.excludedEntityIds.has(this.entityKey(entityType, entityId)) || this.excludedEntityIds.has(entityId) + } + + private isVetoed(entityType: string, entityId: string): boolean { + return this.vetoedEntityIds.has(this.entityKey(entityType, entityId)) || this.vetoedEntityIds.has(entityId) + } + /** * Returns relation items (by relation key) whose planned op was not emitted because * the item is vetoed. BatchPersister keeps these pending when it commits relations. @@ -377,45 +509,67 @@ export class MutationCollector implements MutationDataCollector { fieldName: string, ): Record | null { const relationState = this.store.getRelation(entityType, entityId, fieldName) + const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName) + this.recordRelationField(entityType, entityId, fieldName, 'hasOne', targetType) if (!relationState) { return null } const { state, serverState, currentId, serverId, placeholderData } = relationState + const record = (transition: CollectedHasOneChange['transition'], data: Record): Record => { + this._hasOneChanges.push({ entityType, entityId, fieldName, transition }) + return data + } switch (state) { case 'connected': if (currentId !== serverId) { // Check if current entity exists on server if (currentId && this.isExistingEntity(currentId)) { - return { connect: { id: currentId } } + return record({ operation: 'connect', targetId: currentId }, { connect: { id: currentId } }) } else if (currentId && isTempId(currentId)) { - if (this.vetoedEntityIds.has(currentId)) { + if (targetType && this.isVetoed(targetType, currentId)) { this.suppressRelationItem(entityType, entityId, fieldName, currentId) return null } // Temp entity — generate inline create with its collected data - this._nestedEntityIds.add(currentId) - const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName) if (targetType) { - this._nestedEntityTypes.set(currentId, targetType) + this.registerNestedEntity(targetType, currentId) const createData = this.collectCreateData(targetType, currentId) - return { create: createData ?? {} } + this._nestedCreates.push({ + parentEntityType: entityType, + parentEntityId: entityId, + fieldName, + relationType: 'hasOne', + entityType: targetType, + entityId: currentId, + createData: createData ?? {}, + knownServerIds: [], + }) + return record({ operation: 'create', targetId: currentId }, { create: createData ?? {} }) } - return { create: {} } + return record({ operation: 'create', targetId: currentId }, { create: {} }) } else if (currentId) { - return { connect: { id: currentId } } + return record({ operation: 'connect', targetId: currentId }, { connect: { id: currentId } }) } } else if (currentId && serverId && currentId === serverId) { // Skip if entity has its own top-level mutation or was vetoed - if (this.excludedEntityIds.has(currentId) || this.vetoedEntityIds.has(currentId)) { + if (targetType && (this.isExcluded(targetType, currentId) || this.isVetoed(targetType, currentId))) { return null } // Same entity - check if we need to update it - const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName) if (targetType) { const nestedChanges = this.collectUpdateData(targetType, currentId) if (nestedChanges) { + this._nestedUpdates.push({ + parentEntityType: entityType, + parentEntityId: entityId, + fieldName, + relationType: 'hasOne', + entityType: targetType, + entityId: currentId, + data: nestedChanges, + }) return { update: nestedChanges } } } @@ -425,17 +579,19 @@ export class MutationCollector implements MutationDataCollector { case 'disconnected': // Only emit disconnect if server had a connection if (serverState === 'connected' && serverId !== null) { - return { disconnect: true } + return record({ operation: 'disconnect', targetId: serverId }, { disconnect: true }) } return null case 'deleted': - if (serverId !== null && this.vetoedEntityIds.has(serverId)) { - this.suppressRelationItem(entityType, entityId, fieldName, serverId) - return null + if (serverId !== null) { + if (targetType && this.isVetoed(targetType, serverId)) { + this.suppressRelationItem(entityType, entityId, fieldName, serverId) + return null + } } // Delete the related entity - return { delete: true } + return serverId === null ? null : record({ operation: 'delete', targetId: serverId }, { delete: true }) case 'creating': // Empty placeholderData is a legitimate no-op (user opened a create @@ -503,38 +659,54 @@ export class MutationCollector implements MutationDataCollector { fieldName: string, ): Array> | null { const hasManyState = this.store.getHasMany(entityType, entityId, fieldName) + const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName) + this.recordRelationField(entityType, entityId, fieldName, 'hasMany', targetType) if (!hasManyState) return null const operations: Array> = [] - const targetType = this.schemaProvider.getRelationTarget(entityType, fieldName) + const additions: Array<{ itemId: string; kind: 'created' | 'connected' }> = [] + const removals: Array<{ itemId: string; type: 'delete' | 'disconnect' }> = [] // Planned removals -> disconnect/delete for (const [removedId, removalType] of hasManyState.plannedRemovals) { if (removalType === 'delete') { - if (this.vetoedEntityIds.has(removedId)) { + if (targetType && this.isVetoed(targetType, removedId)) { this.suppressRelationItem(entityType, entityId, fieldName, removedId) continue } operations.push({ delete: { id: removedId }, alias: removedId }) + removals.push({ itemId: removedId, type: 'delete' }) } else { operations.push({ disconnect: { id: removedId }, alias: removedId }) + removals.push({ itemId: removedId, type: 'disconnect' }) } } // Planned additions -> create (newly created) or connect (existing persisted) for (const [additionId, kind] of hasManyState.plannedAdditions) { if (kind === 'created') { - if (this.vetoedEntityIds.has(additionId)) { + if (targetType && this.isVetoed(targetType, additionId)) { this.suppressRelationItem(entityType, entityId, fieldName, additionId) continue } if (!targetType) continue - this._nestedEntityIds.add(additionId) - this._nestedEntityTypes.set(additionId, targetType) + this.registerNestedEntity(targetType, additionId) const createData = this.collectCreateData(targetType, additionId) + this._nestedCreates.push({ + parentEntityType: entityType, + parentEntityId: entityId, + fieldName, + relationType: 'hasMany', + entityType: targetType, + entityId: additionId, + createData: createData ?? {}, + knownServerIds: [...hasManyState.serverIds], + }) operations.push({ create: createData ?? {}, alias: additionId }) + additions.push({ itemId: additionId, kind: 'created' }) } else { operations.push({ connect: { id: additionId }, alias: additionId }) + additions.push({ itemId: additionId, kind: 'connected' }) } } @@ -542,7 +714,7 @@ export class MutationCollector implements MutationDataCollector { if (targetType) { for (const itemId of hasManyState.serverIds) { if (hasManyState.plannedRemovals.has(itemId)) continue - if (this.excludedEntityIds.has(itemId) || this.vetoedEntityIds.has(itemId)) continue + if (this.isExcluded(targetType, itemId) || this.isVetoed(targetType, itemId)) continue const itemSnapshot = this.store.getEntitySnapshot(targetType, itemId) if (!itemSnapshot) continue @@ -559,6 +731,15 @@ export class MutationCollector implements MutationDataCollector { } if (Object.keys(changes).length > 0) { + this._nestedUpdates.push({ + parentEntityType: entityType, + parentEntityId: entityId, + fieldName, + relationType: 'hasMany', + entityType: targetType, + entityId: itemId, + data: changes, + }) operations.push({ update: { by: { id: itemId }, @@ -570,6 +751,9 @@ export class MutationCollector implements MutationDataCollector { } } + if (additions.length > 0 || removals.length > 0) { + this._hasManyChanges.push({ entityType, entityId, fieldName, additions, removals }) + } return operations.length > 0 ? operations : null } diff --git a/packages/bindx/src/persistence/PersistExecution.ts b/packages/bindx/src/persistence/PersistExecution.ts new file mode 100644 index 00000000..ccb6597c --- /dev/null +++ b/packages/bindx/src/persistence/PersistExecution.ts @@ -0,0 +1,217 @@ +import type { DirtyEntity } from './ChangeRegistry.js' +import type { + CollectedHasManyChange, + CollectedHasOneChange, + CollectedNestedCreate, + CollectedNestedEntity, + CollectedNestedUpdate, + CollectedRelationField, +} from './MutationCollector.js' +import type { PersistScope, TransactionMutation } from './types.js' +import type { SnapshotStore } from '../store/SnapshotStore.js' + +export interface ExecutionEntity { + readonly entityType: string + readonly entityId: string + readonly operation: 'create' | 'update' | 'delete' + readonly scalarData: Readonly> + readonly nested: boolean +} + +export interface PersistExecution { + readonly mutations: readonly TransactionMutation[] + readonly entities: readonly ExecutionEntity[] + readonly hasOneChanges: readonly CollectedHasOneChange[] + readonly hasManyChanges: readonly CollectedHasManyChange[] + readonly vetoed: readonly DirtyEntity[] + readonly nestedEntities: readonly CollectedNestedEntity[] + readonly relationFields: readonly CollectedRelationField[] + readonly nestedCreates: readonly CollectedNestedCreate[] + readonly nestedUpdates: readonly CollectedNestedUpdate[] + readonly suppressedRelationItems: ReadonlyMap> +} + +export function entityIdentityKey(entityType: string, entityId: string): string { + return `${entityType}:${entityId}` +} + +export function createPersistExecution( + store: SnapshotStore, + mutations: readonly TransactionMutation[], + entities: readonly DirtyEntity[], + nestedEntities: readonly CollectedNestedEntity[], + relationFieldMetadata: readonly CollectedRelationField[], + nestedCreateMetadata: readonly CollectedNestedCreate[], + nestedUpdateMetadata: readonly CollectedNestedUpdate[], + hasOneChanges: readonly CollectedHasOneChange[], + hasManyChanges: readonly CollectedHasManyChange[], + vetoed: readonly DirtyEntity[], + suppressedRelationItems: ReadonlyMap>, + scope: PersistScope, +): PersistExecution { + const uniqueHasOne = new Map( + hasOneChanges.map(change => [relationIdentityKey(change.entityType, change.entityId, change.fieldName), change]), + ) + const uniqueHasMany = new Map( + hasManyChanges.map(change => [relationIdentityKey(change.entityType, change.entityId, change.fieldName), change]), + ) + const sentHasOneChanges = [...uniqueHasOne.values()] + const sentHasManyChanges = [...uniqueHasMany.values()] + const uniqueNestedCreates = new Map( + nestedCreateMetadata.map(create => [nestedIdentityKey( + create.parentEntityType, + create.parentEntityId, + create.fieldName, + create.entityType, + create.entityId, + ), create]), + ) + const uniqueNestedUpdates = new Map( + nestedUpdateMetadata.map(update => [nestedIdentityKey( + update.parentEntityType, + update.parentEntityId, + update.fieldName, + update.entityType, + update.entityId, + ), update]), + ) + const nestedCreates = [...uniqueNestedCreates.values()] + const nestedUpdates = [...uniqueNestedUpdates.values()] + const topLevelOperations = new Map( + mutations.map(mutation => [entityIdentityKey(mutation.entityType, mutation.entityId), mutation.operation]), + ) + const topLevelMutations = new Map( + mutations.map(mutation => [entityIdentityKey(mutation.entityType, mutation.entityId), mutation]), + ) + const uniqueRelationFields = new Map( + relationFieldMetadata.map(field => [relationIdentityKey(field.entityType, field.entityId, field.fieldName), field]), + ) + const relationFields = new Map>() + for (const change of uniqueRelationFields.values()) { + const key = entityIdentityKey(change.entityType, change.entityId) + const fields = relationFields.get(key) + if (fields) fields.add(change.fieldName) + else relationFields.set(key, new Set([change.fieldName])) + } + const nestedKeys = new Set(nestedEntities.map(entity => entityIdentityKey(entity.entityType, entity.entityId))) + const nestedMutationData = new Map>>() + for (const create of nestedCreates) { + nestedMutationData.set(entityIdentityKey(create.entityType, create.entityId), create.createData) + } + for (const update of nestedUpdates) { + nestedMutationData.set(entityIdentityKey(update.entityType, update.entityId), update.data) + nestedKeys.add(entityIdentityKey(update.entityType, update.entityId)) + } + const all = new Map() + + const candidates: DirtyEntity[] = [...entities] + for (const nested of nestedEntities) { + candidates.push({ + entityType: nested.entityType, + entityId: nested.entityId, + changeType: 'create', + dirtyFields: [], + dirtyRelations: [], + }) + } + for (const nested of nestedUpdates) { + candidates.push({ + entityType: nested.entityType, + entityId: nested.entityId, + changeType: 'update', + dirtyFields: Object.keys(nested.data), + dirtyRelations: [], + }) + } + + for (const entity of candidates) { + const key = entityIdentityKey(entity.entityType, entity.entityId) + if (all.has(key) || vetoed.some(item => entityIdentityKey(item.entityType, item.entityId) === key)) continue + const operation = topLevelOperations.get(key) ?? entity.changeType + all.set(key, { + entityType: entity.entityType, + entityId: entity.entityId, + operation, + scalarData: collectSentScalars( + store, + entity, + scope, + topLevelMutations.get(key)?.data ?? nestedMutationData.get(key), + relationFields.get(key) ?? new Set(), + ), + nested: nestedKeys.has(key), + }) + } + + return { + mutations: mutations.map(mutation => ({ + ...mutation, + data: mutation.data ? { ...mutation.data } : undefined, + })), + entities: [...all.values()], + hasOneChanges: sentHasOneChanges.map(change => ({ ...change, transition: { ...change.transition } })), + hasManyChanges: sentHasManyChanges.map(change => ({ + ...change, + additions: change.additions.map(addition => ({ ...addition })), + removals: change.removals.map(removal => ({ ...removal })), + })), + vetoed: [...vetoed], + nestedEntities: [...nestedEntities], + relationFields: [...uniqueRelationFields.values()], + nestedCreates: nestedCreates.map(create => ({ + ...create, + createData: { ...create.createData }, + knownServerIds: [...create.knownServerIds], + })), + nestedUpdates: nestedUpdates.map(update => ({ ...update, data: { ...update.data } })), + suppressedRelationItems: new Map( + [...suppressedRelationItems].map(([key, ids]) => [key, new Set(ids)]), + ), + } +} + +function relationIdentityKey(entityType: string, entityId: string, fieldName: string): string { + return `${entityIdentityKey(entityType, entityId)}:${fieldName}` +} + +function nestedIdentityKey( + parentEntityType: string, + parentEntityId: string, + fieldName: string, + entityType: string, + entityId: string, +): string { + return `${relationIdentityKey(parentEntityType, parentEntityId, fieldName)}>${entityIdentityKey(entityType, entityId)}` +} + +function collectSentScalars( + store: SnapshotStore, + entity: Pick, + scope: PersistScope, + mutationData: Readonly> | undefined, + relationFields: ReadonlySet, +): Readonly> { + if (entity.changeType === 'delete') return {} + if (mutationData) { + const result: Record = {} + for (const [field, value] of Object.entries(mutationData)) { + if (!relationFields.has(field)) result[field] = value + } + return result + } + const snapshot = store.getEntitySnapshot>(entity.entityType, entity.entityId) + if (!snapshot) return {} + const fields = scope.type === 'fields' + && scope.entityType === entity.entityType + && scope.entityId === entity.entityId + ? scope.fields + : entity.changeType === 'create' + ? Object.keys(snapshot.data) + : store.getDirtyFields(entity.entityType, entity.entityId) + const result: Record = {} + for (const field of fields) { + if (field === 'id' && entity.entityId.startsWith('__temp_')) continue + result[field] = snapshot.data[field] + } + return result +} diff --git a/tests/unit/persistence/errors.test.ts b/tests/unit/persistence/errors.test.ts index 8f3e64b1..f6d2896f 100644 --- a/tests/unit/persistence/errors.test.ts +++ b/tests/unit/persistence/errors.test.ts @@ -60,10 +60,10 @@ describe('BatchPersister - Error Handling', () => { const result = await persister.persistAll() expect(result.success).toBe(false) - expect(result.failedCount).toBe(1) + expect(result.failedCount).toBe(2) - // Find the failed result - const failedResult = result.results.find(r => !r.success) + // Atomic failure marks every mutation failed, while retaining the specific server error. + const failedResult = result.results.find(r => r.entityId === 'a-2') expect(failedResult?.entityId).toBe('a-2') expect(failedResult?.error?.message).toBe('Title is required') }) @@ -288,15 +288,15 @@ describe('BatchPersister - Error Handling', () => { const result = await persister.persistAll() expect(result.success).toBe(false) - expect(result.successCount).toBe(2) - expect(result.failedCount).toBe(2) + expect(result.successCount).toBe(0) + expect(result.failedCount).toBe(4) // Check individual results const successResults = result.results.filter(r => r.success) const failedResults = result.results.filter(r => !r.success) - expect(successResults.length).toBe(2) - expect(failedResults.length).toBe(2) + expect(successResults.length).toBe(0) + expect(failedResults.length).toBe(4) // Failed entities should have error messages for (const failed of failedResults) { diff --git a/tests/unit/persistence/nestedCreateNormalisedScalar.test.ts b/tests/unit/persistence/nestedCreateNormalisedScalar.test.ts index a92dc0ad..bfc2c5eb 100644 --- a/tests/unit/persistence/nestedCreateNormalisedScalar.test.ts +++ b/tests/unit/persistence/nestedCreateNormalisedScalar.test.ts @@ -215,7 +215,7 @@ describe('Nested create reconciliation when the server normalises a scalar', () return blockId }) - expect((await persister.persistAll()).success).toBe(true) + expect((await persister.persistAll()).success).toBe(false) for (const blockId of blockIds) { expect(store.getPersistedId('Block', blockId)).toBeNull() @@ -247,7 +247,7 @@ describe('Pairing create operations with response rows', () => { expect(server.rowsById.get(preciseId!)?.['type']).toBe('button') }) - test('maps indistinguishable siblings, which any pairing describes equally well', async () => { + test('leaves indistinguishable siblings unresolved instead of pairing by position', async () => { const store = new SnapshotStore() const server = createMockServer() const persister = createPersister(store, server.adapter) @@ -256,10 +256,9 @@ describe('Pairing create operations with response rows', () => { const first = addBlock(store, { order: 1, type: 'button' }) const second = addBlock(store, { order: 1, type: 'button' }) - expect((await persister.persistAll()).success).toBe(true) + expect((await persister.persistAll()).success).toBe(false) - expect(store.getPersistedId('Block', first)).not.toBeNull() - expect(store.getPersistedId('Block', second)).not.toBeNull() - expect(store.getPersistedId('Block', first)).not.toBe(store.getPersistedId('Block', second)) + expect(store.getPersistedId('Block', first)).toBeNull() + expect(store.getPersistedId('Block', second)).toBeNull() }) }) diff --git a/tests/unit/persistence/persistPlanning.test.ts b/tests/unit/persistence/persistPlanning.test.ts new file mode 100644 index 00000000..8b446a4e --- /dev/null +++ b/tests/unit/persistence/persistPlanning.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, test } from 'bun:test' +import { + ActionDispatcher, + BatchPersister, + ContemberSchemaMutationAdapter, + MutationCollector, + SnapshotStore, + UndoManager, + type BackendAdapter, + type SchemaNames, +} from '@contember/bindx' + +const schema: SchemaNames = { + entities: { + Page: { + name: 'Page', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + blocks: { type: 'many', entity: 'Block' }, + }, + }, + Block: { + name: 'Block', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + link: { type: 'one', entity: 'Link' }, + }, + }, + Link: { + name: 'Link', + scalars: ['id', 'url'], + fields: { id: { type: 'column' }, url: { type: 'column' } }, + }, + }, + enums: {}, +} + +interface Deferred { + readonly promise: Promise + readonly resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolvePromise: ((value: T) => void) | undefined + const promise = new Promise(resolve => { + resolvePromise = resolve + }) + return { + promise, + resolve: value => { + if (!resolvePromise) throw new Error('Deferred promise is not initialized') + resolvePromise(value) + }, + } +} + +function createPersister(store: SnapshotStore, dispatcher: ActionDispatcher, adapter: BackendAdapter, undoManager?: UndoManager): BatchPersister { + return new BatchPersister(adapter, store, dispatcher, { + mutationCollector: new MutationCollector(store, new ContemberSchemaMutationAdapter(schema)), + undoManager, + }) +} + +function seedTree(store: SnapshotStore, pageId: string): { blockId: string; linkId: string } { + store.setEntityData('Page', pageId, { id: pageId, title: 'Original' }, true) + store.getOrCreateHasMany('Page', pageId, 'blocks', []) + const blockId = store.createEntity('Block', { title: `Block ${pageId}` }) + const linkId = store.createEntity('Link', { url: `https://${pageId}.example` }) + store.getOrCreateRelation('Block', blockId, 'link', { + currentId: linkId, + serverId: null, + state: 'connected', + serverState: 'disconnected', + placeholderData: {}, + }) + store.addToHasMany('Page', pageId, 'blocks', blockId) + return { blockId, linkId } +} + +describe('persist planning and collector sessions', () => { + test('discovers nested hooks monotonically and includes accepted late writes', async () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + const tree = seedTree(store, 'page-1') + const response = deferred<{ ok: true; data: Record }>() + const called = deferred>() + const adapter: BackendAdapter = { + query: () => Promise.resolve([]), + persist: (_type, _id, changes) => { + called.resolve(changes) + return response.promise + }, + create: () => Promise.resolve({ ok: true, data: { id: 'unused' } }), + delete: () => Promise.resolve({ ok: true }), + } + const persister = createPersister(store, dispatcher, adapter) + let blockHooks = 0 + let linkHooks = 0 + dispatcher.getEventEmitter().interceptEntity('entity:persisting', 'Block', tree.blockId, () => { + blockHooks++ + expect(store.isPersisting('Block', tree.blockId)).toBe(true) + store.setFieldValue('Block', tree.blockId, ['title'], 'Normalized') + return { action: 'continue' } + }) + dispatcher.getEventEmitter().interceptEntity('entity:persisting', 'Link', tree.linkId, () => { + linkHooks++ + expect(store.isPersisting('Link', tree.linkId)).toBe(true) + return { action: 'continue' } + }) + + const promise = persister.persist('Page', 'page-1') + const changes = await called.promise + expect(JSON.stringify(changes)).toContain('Normalized') + store.setFieldValue('Block', tree.blockId, ['title'], 'Newer') + response.resolve({ + ok: true, + data: { + id: 'page-1', + blocks: [{ + id: 'block-server', + title: 'Normalized', + link: { id: 'link-server', url: 'https://page-1.example' }, + }], + }, + }) + + expect((await promise).success).toBe(true) + expect(blockHooks).toBe(1) + expect(linkHooks).toBe(1) + expect(store.getEntitySnapshot>('Block', 'block-server')?.data['title']).toBe('Newer') + expect(store.getEntitySnapshot>('Block', 'block-server')?.serverData['title']).toBe('Normalized') + expect(store.getDirtyFields('Block', 'block-server')).toContain('title') + }) + + test('reports a late nested veto as skipped and never emits persisted success', async () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + const tree = seedTree(store, 'page-1') + let changes: Record | undefined + const adapter: BackendAdapter = { + query: () => Promise.resolve([]), + persist: (_type, _id, data) => { + changes = data + return Promise.resolve({ + ok: true, + data: { id: 'page-1', blocks: [{ id: 'block-server', title: 'Block page-1' }] }, + }) + }, + create: () => Promise.resolve({ ok: true, data: { id: 'unused' } }), + delete: () => Promise.resolve({ ok: true }), + } + const persisted: string[] = [] + dispatcher.getEventEmitter().on('entity:persisted', event => persisted.push(event.entityId)) + dispatcher.getEventEmitter().interceptEntity('entity:persisting', 'Link', tree.linkId, () => ({ action: 'cancel' })) + + const result = await createPersister(store, dispatcher, adapter).persistAll() + const skipped = result.results.find(entry => entry.entityId === tree.linkId) + expect(skipped?.skipped).toBe(true) + expect(persisted).not.toContain(tree.linkId) + expect(JSON.stringify(changes)).not.toContain('https://page-1.example') + }) + + test('keeps concurrent collector sessions isolated and undo blocked until both settle', async () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + const undo = new UndoManager(store, { debounceMs: 0 }) + const firstTree = seedTree(store, 'page-1') + const secondTree = seedTree(store, 'page-2') + const pending = new Map }>>() + const adapter: BackendAdapter = { + query: () => Promise.resolve([]), + persist: (_type, id) => { + const request = deferred<{ ok: true; data: Record }>() + pending.set(id, request) + return request.promise + }, + create: () => Promise.resolve({ ok: true, data: { id: 'unused' } }), + delete: () => Promise.resolve({ ok: true }), + } + const persister = createPersister(store, dispatcher, adapter, undo) + + const first = persister.persist('Page', 'page-1') + const second = persister.persist('Page', 'page-2') + expect(undo.getState().isBlocked).toBe(true) + pending.get('page-1')?.resolve({ + ok: true, + data: { + id: 'page-1', + blocks: [{ + id: 'block-server-1', title: 'Block page-1', + link: { id: 'link-server-1', url: 'https://page-1.example' }, + }], + }, + }) + expect((await first).success).toBe(true) + expect(undo.getState().isBlocked).toBe(true) + pending.get('page-2')?.resolve({ + ok: true, + data: { + id: 'page-2', + blocks: [{ + id: 'block-server-2', title: 'Block page-2', + link: { id: 'link-server-2', url: 'https://page-2.example' }, + }], + }, + }) + expect((await second).success).toBe(true) + expect(undo.getState().isBlocked).toBe(false) + expect(store.getPersistedId('Block', firstTree.blockId)).toBe('block-server-1') + expect(store.getPersistedId('Link', firstTree.linkId)).toBe('link-server-1') + expect(store.getPersistedId('Block', secondTree.blockId)).toBe('block-server-2') + expect(store.getPersistedId('Link', secondTree.linkId)).toBe('link-server-2') + }) + + test('fails safely when a temp nested create has no returned ID', async () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + const tree = seedTree(store, 'page-1') + const adapter: BackendAdapter = { + query: () => Promise.resolve([]), + persist: () => Promise.resolve({ + ok: true, + data: { + id: 'page-1', + blocks: [{ title: 'Block page-1', link: { url: 'https://page-1.example' } }], + }, + }), + create: () => Promise.resolve({ ok: true, data: { id: 'unused' } }), + delete: () => Promise.resolve({ ok: true }), + } + + const result = await createPersister(store, dispatcher, adapter).persist('Page', 'page-1') + expect(result.success).toBe(false) + expect(store.getPersistedId('Block', tree.blockId)).toBeNull() + expect(store.existsOnServer('Block', tree.blockId)).toBe(false) + }) + + test('accepts a client-assigned nested ID without a returned ID', async () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + store.setEntityData('Page', 'page-1', { id: 'page-1', title: 'Original' }, true) + store.getOrCreateHasMany('Page', 'page-1', 'blocks', []) + const blockId = store.createEntity('Block', { id: 'client-block', title: 'Stable' }) + store.addToHasMany('Page', 'page-1', 'blocks', blockId) + const adapter: BackendAdapter = { + query: () => Promise.resolve([]), + persist: () => Promise.resolve({ ok: true, data: { id: 'page-1', blocks: [{ title: 'Stable' }] } }), + create: () => Promise.resolve({ ok: true, data: { id: 'unused' } }), + delete: () => Promise.resolve({ ok: true }), + } + + expect((await createPersister(store, dispatcher, adapter).persist('Page', 'page-1')).success).toBe(true) + expect(store.existsOnServer('Block', blockId)).toBe(true) + expect(store.getEntitySnapshot>('Block', blockId)?.id).toBe('client-block') + }) + + test('maps the sent has-one child when the live relation switches while pending', async () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + store.setEntityData('Block', 'block-1', { id: 'block-1', title: 'Block' }, true) + const sentLink = store.createEntity('Link', { url: 'https://sent.example' }) + store.getOrCreateRelation('Block', 'block-1', 'link', { + currentId: sentLink, serverId: null, state: 'connected', serverState: 'disconnected', placeholderData: {}, + }) + const pending = deferred<{ ok: true; data: Record }>() + const adapter: BackendAdapter = { + query: () => Promise.resolve([]), + persist: () => pending.promise, + create: () => Promise.resolve({ ok: true, data: { id: 'unused' } }), + delete: () => Promise.resolve({ ok: true }), + } + const persister = createPersister(store, dispatcher, adapter) + const promise = persister.persist('Block', 'block-1') + const newerLink = store.createEntity('Link', { url: 'https://newer.example' }) + store.setRelation('Block', 'block-1', 'link', { currentId: newerLink, state: 'connected' }) + pending.resolve({ ok: true, data: { id: 'block-1', link: { id: 'link-server', url: 'https://sent.example' } } }) + + expect((await promise).success).toBe(true) + expect(store.getPersistedId('Link', sentLink)).toBe('link-server') + expect(store.getPersistedId('Link', newerLink)).toBeNull() + const relation = store.getRelation('Block', 'block-1', 'link') + expect(relation?.serverId).toBe('link-server') + expect(relation?.currentId).toBe(newerLink) + }) + + test('reconciles resolved siblings and retries only the unresolved create', async () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + store.setEntityData('Page', 'page-1', { id: 'page-1', title: 'Page' }, true) + store.getOrCreateHasMany('Page', 'page-1', 'blocks', []) + const resolved = store.createEntity('Block', { title: 'Resolved' }) + const unresolved = store.createEntity('Block', { title: 'Unresolved' }) + store.addToHasMany('Page', 'page-1', 'blocks', resolved) + store.addToHasMany('Page', 'page-1', 'blocks', unresolved) + const calls: Record[] = [] + let attempt = 0 + const adapter: BackendAdapter = { + query: () => Promise.resolve([]), + persist: (_type, _id, data) => { + calls.push(data) + attempt++ + return Promise.resolve({ + ok: true, + data: attempt === 1 + ? { id: 'page-1', blocks: [{ id: 'block-resolved', title: 'Resolved' }, { title: 'Unresolved' }] } + : { id: 'page-1', blocks: [{ id: 'block-unresolved', title: 'Unresolved' }] }, + }) + }, + create: () => Promise.resolve({ ok: true, data: { id: 'unused' } }), + delete: () => Promise.resolve({ ok: true }), + } + const persister = createPersister(store, dispatcher, adapter) + + expect((await persister.persist('Page', 'page-1')).success).toBe(false) + expect(store.getPersistedId('Block', resolved)).toBe('block-resolved') + expect(store.getPersistedId('Block', unresolved)).toBeNull() + expect((await persister.persist('Page', 'page-1')).success).toBe(true) + expect(JSON.stringify(calls[1])).not.toContain('Resolved') + expect(store.getPersistedId('Block', unresolved)).toBe('block-unresolved') + }) + + test('fails deterministically when two nested types share one wire alias', async () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + store.setEntityData('Page', 'page-1', { id: 'page-1', title: 'Page' }, true) + store.getOrCreateHasMany('Page', 'page-1', 'blocks', []) + const sharedId = '__temp_shared' + store.createEntity('Block', { id: sharedId, title: 'Block' }) + store.createEntity('Link', { id: sharedId, url: 'https://link.example' }) + store.getOrCreateRelation('Block', sharedId, 'link', { + currentId: sharedId, serverId: null, state: 'connected', serverState: 'disconnected', placeholderData: {}, + }) + store.addToHasMany('Page', 'page-1', 'blocks', sharedId) + const adapter: BackendAdapter = { + query: () => Promise.resolve([]), + persist: () => Promise.resolve({ + ok: true, + data: { + id: 'page-1', + blocks: [{ id: 'block-server', title: 'Block', link: { id: 'link-server', url: 'https://link.example' } }], + }, + }), + create: () => Promise.resolve({ ok: true, data: { id: 'unused' } }), + delete: () => Promise.resolve({ ok: true }), + } + + const result = await createPersister(store, dispatcher, adapter).persist('Page', 'page-1') + expect(result.success).toBe(false) + expect(store.getPersistedId('Block', sharedId)).toBeNull() + expect(store.getPersistedId('Link', sharedId)).toBeNull() + expect(result.error?.message).toContain('Block:__temp_shared') + expect(result.error?.message).toContain('Link:__temp_shared') + }) + + test('accepts an Unknown adapter type when the nested alias has one expected type', async () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + store.setEntityData('Page', 'page-1', { id: 'page-1', title: 'Page' }, true) + store.getOrCreateHasMany('Page', 'page-1', 'blocks', []) + const blockId = store.createEntity('Block', { title: 'Block' }) + store.addToHasMany('Page', 'page-1', 'blocks', blockId) + const adapter: BackendAdapter = { + query: () => Promise.resolve([]), + persist: () => Promise.resolve({ ok: true }), + persistTransaction: mutations => Promise.resolve({ + ok: true, + results: mutations.map(mutation => ({ + entityType: mutation.entityType, + entityId: mutation.entityId, + ok: true, + nestedResults: [{ + entityType: 'Unknown', + entityId: blockId, + ok: true, + persistedId: 'block-server', + }], + })), + }), + create: () => Promise.resolve({ ok: true, data: { id: 'unused' } }), + delete: () => Promise.resolve({ ok: true }), + } + + expect((await createPersister(store, dispatcher, adapter).persist('Page', 'page-1')).success).toBe(true) + expect(store.getPersistedId('Block', blockId)).toBe('block-server') + }) +}) diff --git a/tests/unit/persistence/persistReconciliation.test.ts b/tests/unit/persistence/persistReconciliation.test.ts new file mode 100644 index 00000000..635eb7ed --- /dev/null +++ b/tests/unit/persistence/persistReconciliation.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, test } from 'bun:test' +import { + ActionDispatcher, + BatchPersister, + ContemberSchemaMutationAdapter, + MutationCollector, + SnapshotStore, + type BackendAdapter, + type MutationDataCollector, + type SchemaNames, +} from '@contember/bindx' + +const schema: SchemaNames = { + entities: { + Article: { + name: 'Article', + scalars: ['id', 'title'], + fields: { + id: { type: 'column' }, + title: { type: 'column' }, + author: { type: 'one', entity: 'Author' }, + tags: { type: 'many', entity: 'Tag' }, + }, + }, + Author: { name: 'Author', scalars: ['id', 'name'], fields: { id: { type: 'column' }, name: { type: 'column' } } }, + Tag: { name: 'Tag', scalars: ['id', 'name'], fields: { id: { type: 'column' }, name: { type: 'column' } } }, + }, + enums: {}, +} + +interface Deferred { + readonly promise: Promise + readonly resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolvePromise: ((value: T) => void) | undefined + const promise = new Promise(resolve => { + resolvePromise = resolve + }) + return { + promise, + resolve: value => { + if (!resolvePromise) throw new Error('Deferred promise is not initialized') + resolvePromise(value) + }, + } +} + +function field(store: SnapshotStore, type: string, id: string, name: string): unknown { + const data = store.getEntitySnapshot>(type, id)?.data + return data && name in data ? data[name] : undefined +} + +function scalarAdapter( + persist: BackendAdapter['persist'], + overrides: Partial> = {}, +): BackendAdapter { + return { + query: () => Promise.resolve([]), + persist, + ...overrides, + } +} + +describe('exact persistence reconciliation', () => { + test('commits only the scalar value that was sent', async () => { + const store = new SnapshotStore() + const pending = deferred<{ ok: true }>() + const adapter = scalarAdapter(() => pending.promise) + const persister = new BatchPersister(adapter, store, new ActionDispatcher(store)) + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'Sent') + + const promise = persister.persist('Article', 'a-1') + store.setFieldValue('Article', 'a-1', ['title'], 'Newer') + pending.resolve({ ok: true }) + expect((await promise).success).toBe(true) + + expect(field(store, 'Article', 'a-1', 'title')).toBe('Newer') + expect(store.getEntitySnapshot>('Article', 'a-1')?.serverData['title']).toBe('Sent') + expect(store.getDirtyFields('Article', 'a-1')).toContain('title') + }) + + test('rebases sent has-one and has-many changes over newer local intent', async () => { + const store = new SnapshotStore() + const pending = deferred<{ ok: true; data: Record }>() + const adapter = scalarAdapter(() => pending.promise) + const collector = new MutationCollector(store, new ContemberSchemaMutationAdapter(schema)) + const persister = new BatchPersister(adapter, store, new ActionDispatcher(store), { mutationCollector: collector }) + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original' }, true) + for (const id of ['author-a', 'author-b', 'author-c']) store.setEntityData('Author', id, { id, name: id }, true) + for (const id of ['tag-a', 'tag-b', 'tag-c']) store.setEntityData('Tag', id, { id, name: id }, true) + store.getOrCreateRelation('Article', 'a-1', 'author', { + currentId: 'author-b', serverId: 'author-a', state: 'connected', serverState: 'connected', placeholderData: {}, + }) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['tag-a']) + store.connectExistingToHasMany('Article', 'a-1', 'tags', 'tag-b') + + const promise = persister.persist('Article', 'a-1') + store.setRelation('Article', 'a-1', 'author', { currentId: 'author-c', state: 'connected' }) + store.removeFromHasMany('Article', 'a-1', 'tags', 'tag-b', 'disconnect') + store.connectExistingToHasMany('Article', 'a-1', 'tags', 'tag-c') + pending.resolve({ ok: true, data: { id: 'a-1' } }) + expect((await promise).success).toBe(true) + + const author = store.getRelation('Article', 'a-1', 'author') + expect(author?.serverId).toBe('author-b') + expect(author?.currentId).toBe('author-c') + const tags = store.getHasMany('Article', 'a-1', 'tags') + expect(tags?.serverIds.has('tag-b')).toBe(true) + expect(tags?.plannedRemovals.get('tag-b')).toBe('disconnect') + expect(tags?.plannedAdditions.get('tag-c')).toBe('connected') + }) + + test('keeps an early sequential create confirmed across later failure and retry', async () => { + const store = new SnapshotStore() + let failSecond = true + const calls: string[] = [] + const adapter = scalarAdapter( + () => Promise.resolve({ ok: true }), + { + create: (_type, data) => { + const title = String(data['title']) + calls.push(title) + return title === 'Second' && failSecond + ? Promise.resolve({ ok: false, errorMessage: 'second failed' }) + : Promise.resolve({ ok: true, data: { id: `server-${title.toLowerCase()}` } }) + }, + }, + ) + const persister = new BatchPersister(adapter, store, new ActionDispatcher(store)) + const first = store.createEntity('Article', { title: 'First' }) + store.createEntity('Article', { title: 'Second' }) + + const failed = await persister.persistAll({ rollbackOnError: true }) + expect(failed.success).toBe(false) + expect(store.getPersistedId('Article', first)).toBe('server-first') + failSecond = false + expect((await persister.persistAll()).success).toBe(true) + expect(calls.filter(title => title === 'First')).toHaveLength(1) + }) + + test('preflights missing create and delete methods before any request', async () => { + const store = new SnapshotStore() + let persistCalls = 0 + const adapter = scalarAdapter(() => { + persistCalls++ + return Promise.resolve({ ok: true }) + }) + const persister = new BatchPersister(adapter, store, new ActionDispatcher(store)) + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'Updated') + store.createEntity('Article', { title: 'Created' }) + store.setEntityData('Article', 'a-2', { id: 'a-2', title: 'Delete' }, true) + store.scheduleForDeletion('Article', 'a-2') + + const result = await persister.persistAll() + expect(result.success).toBe(false) + expect(persistCalls).toBe(0) + }) + + test('does not reconcile an atomic failure even when one entry says ok', async () => { + const store = new SnapshotStore() + const adapter = scalarAdapter( + () => Promise.resolve({ ok: true }), + { + persistTransaction: mutations => Promise.resolve({ + ok: false, + results: mutations.map((mutation, index) => ({ + entityType: mutation.entityType, + entityId: mutation.entityId, + ok: index === 0, + errorMessage: index === 0 ? undefined : 'failed', + })), + }), + }, + ) + const persister = new BatchPersister(adapter, store, new ActionDispatcher(store)) + const callbacks: boolean[] = [] + for (const id of ['a-1', 'a-2']) { + store.setEntityData('Article', id, { id, title: 'Original' }, true) + store.setFieldValue('Article', id, ['title'], 'Updated') + } + + const result = await persister.persistAll({ onEntityPersisted: entry => callbacks.push(entry.success) }) + expect(result.success).toBe(false) + expect(result.successCount).toBe(0) + expect(result.failedCount).toBe(2) + expect(result.results.every(entry => !entry.success)).toBe(true) + expect(callbacks).toEqual([false, false]) + expect(store.getDirtyFields('Article', 'a-1')).toContain('title') + expect(store.getDirtyFields('Article', 'a-2')).toContain('title') + }) + + test('removes a confirmed delete but reports a reversal as a conflict', async () => { + const store = new SnapshotStore() + const pending = deferred<{ ok: true }>() + const adapter = scalarAdapter( + () => Promise.resolve({ ok: true }), + { delete: () => pending.promise }, + ) + const persister = new BatchPersister(adapter, store, new ActionDispatcher(store)) + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original' }, true) + store.scheduleForDeletion('Article', 'a-1') + const promise = persister.persist('Article', 'a-1') + store.unscheduleForDeletion('Article', 'a-1') + pending.resolve({ ok: true }) + + expect((await promise).success).toBe(false) + expect(store.getEntitySnapshot('Article', 'a-1')).toBeDefined() + expect(store.existsOnServer('Article', 'a-1')).toBe(false) + + const secondStore = new SnapshotStore() + const second = new BatchPersister( + scalarAdapter(() => Promise.resolve({ ok: true }), { delete: () => Promise.resolve({ ok: true }) }), + secondStore, + new ActionDispatcher(secondStore), + ) + secondStore.setEntityData('Article', 'a-2', { id: 'a-2', title: 'Original' }, true) + secondStore.scheduleForDeletion('Article', 'a-2') + expect((await second.persist('Article', 'a-2')).success).toBe(true) + expect(secondStore.getEntitySnapshot('Article', 'a-2')).toBeUndefined() + }) + + test('rejects relation output from a custom collector before the adapter call', async () => { + const store = new SnapshotStore() + let calls = 0 + const collector: MutationDataCollector = { + collectUpdateData: () => ({ author: { connect: { id: 'author-b' } } }), + } + const persister = new BatchPersister( + scalarAdapter(() => { + calls++ + return Promise.resolve({ ok: true }) + }), + store, + new ActionDispatcher(store), + { mutationCollector: collector }, + ) + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original' }, true) + store.getOrCreateRelation('Article', 'a-1', 'author', { + currentId: 'author-b', serverId: null, state: 'connected', serverState: 'disconnected', placeholderData: {}, + }) + + await expect(persister.persist('Article', 'a-1')).rejects.toThrow(/scalar data only/) + expect(calls).toBe(0) + }) + + test('rejects a custom collector that returns null while relations are dirty', async () => { + const store = new SnapshotStore() + let calls = 0 + const collector: MutationDataCollector = { collectUpdateData: () => null } + const persister = new BatchPersister( + scalarAdapter(() => { + calls++ + return Promise.resolve({ ok: true }) + }), + store, + new ActionDispatcher(store), + { mutationCollector: collector }, + ) + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original' }, true) + store.getOrCreateRelation('Article', 'a-1', 'author', { + currentId: 'author-b', serverId: null, state: 'connected', serverState: 'disconnected', placeholderData: {}, + }) + + await expect(persister.persist('Article', 'a-1')).rejects.toThrow(/scalar data only/) + expect(calls).toBe(0) + }) + + test('reconciles a nested update only to the sent child value', async () => { + const store = new SnapshotStore() + const pending = deferred<{ ok: true; data: Record }>() + const collector = new MutationCollector(store, new ContemberSchemaMutationAdapter(schema)) + const persister = new BatchPersister( + scalarAdapter(() => pending.promise), + store, + new ActionDispatcher(store), + { mutationCollector: collector }, + ) + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'Parent sent') + store.setEntityData('Tag', 'tag-1', { id: 'tag-1', name: 'Original' }, true) + store.getOrCreateHasMany('Article', 'a-1', 'tags', ['tag-1']) + store.setFieldValue('Tag', 'tag-1', ['name'], 'Child sent') + + const promise = persister.persist('Article', 'a-1') + store.setFieldValue('Tag', 'tag-1', ['name'], 'Child newer') + pending.resolve({ ok: true, data: { id: 'a-1', title: 'Parent sent', tags: [{ id: 'tag-1', name: 'Child sent' }] } }) + expect((await promise).success).toBe(true) + + expect(field(store, 'Tag', 'tag-1', 'name')).toBe('Child newer') + expect(store.getEntitySnapshot>('Tag', 'tag-1')?.serverData['name']).toBe('Child sent') + expect(store.getDirtyFields('Tag', 'tag-1')).toContain('name') + expect(store.getEntitySnapshot>('Article', 'a-1')?.serverData['tags']).toBeUndefined() + }) + + test('continues reconciliation and rekey after a relation conflict', async () => { + const store = new SnapshotStore() + const firstResponse = deferred<{ ok: true; data: Record }>() + const calls: Record[] = [] + let attempt = 0 + const adapter = scalarAdapter((_type, _id, changes) => { + calls.push(changes) + attempt++ + return attempt === 1 + ? firstResponse.promise + : Promise.resolve({ ok: true, data: { id: 'a-1', author: { id: 'author-a' }, tags: [{ id: 'tag-server' }] } }) + }) + const collector = new MutationCollector(store, new ContemberSchemaMutationAdapter(schema)) + const persister = new BatchPersister(adapter, store, new ActionDispatcher(store), { mutationCollector: collector }) + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original' }, true) + store.setEntityData('Author', 'author-a', { id: 'author-a', name: 'Author' }, true) + store.getOrCreateRelation('Article', 'a-1', 'author', { + currentId: 'author-a', serverId: 'author-a', state: 'deleted', serverState: 'connected', placeholderData: {}, + }) + store.getOrCreateHasMany('Article', 'a-1', 'tags', []) + const tag = store.createEntity('Tag', { name: 'New tag' }) + store.addToHasMany('Article', 'a-1', 'tags', tag) + + const first = persister.persist('Article', 'a-1') + store.setRelation('Article', 'a-1', 'author', { currentId: 'author-a', state: 'connected' }) + firstResponse.resolve({ ok: true, data: { id: 'a-1', tags: [{ id: 'tag-server', name: 'New tag' }] } }) + expect((await first).success).toBe(false) + expect(store.getPersistedId('Tag', tag)).toBe('tag-server') + expect(store.getHasMany('Article', 'a-1', 'tags')?.serverIds.has('tag-server')).toBe(true) + + expect((await persister.persist('Article', 'a-1')).success).toBe(true) + expect(JSON.stringify(calls[1])).not.toContain('New tag') + }) +}) diff --git a/tests/unit/persistence/pessimistic.test.ts b/tests/unit/persistence/pessimistic.test.ts index 14da2299..c04cb52c 100644 --- a/tests/unit/persistence/pessimistic.test.ts +++ b/tests/unit/persistence/pessimistic.test.ts @@ -341,11 +341,7 @@ describe('pessimistic update mode', () => { expect((store.getEntitySnapshot('Article', '2')?.serverData as { title: string })?.title).toBe('Title 2') }) - // PERSIST-1 regression: on a PARTIAL failure (one mutation succeeds, the - // transaction fails overall via the non-atomic sequential fallback — the only - // path any real adapter takes), the succeeded-but-reset entity must NOT be - // stranded at the server view. Every captured entity is restored for retry. - test('should restore even the individually-succeeded entity on a partial batch failure', async () => { + test('should confirm a sequential success before a later mutation fails', async () => { // One mutation succeeds, the other fails → transaction reported as failed. const adapter: BackendAdapter = { query: mock(() => Promise.resolve([])), @@ -367,11 +363,10 @@ describe('pessimistic update mode', () => { const result = await persister.persistAll({ updateMode: 'pessimistic' }) expect(result.success).toBe(false) - // '1' succeeded individually but the transaction failed — it must be restored - // to its dirty edit (not left showing the reset server view) and kept dirty. + // Sequential success is real server state and must not be sent again on retry. expect((store.getEntitySnapshot('Article', '1')?.data as { title: string })?.title).toBe('Updated 1') expect((store.getEntitySnapshot('Article', '2')?.data as { title: string })?.title).toBe('Updated 2') - expect(store.getAllDirtyEntities()).toContainEqual({ + expect(store.getAllDirtyEntities()).not.toContainEqual({ entityType: 'Article', entityId: '1', changeType: 'update', }) expect(store.getAllDirtyEntities()).toContainEqual({ From 6afe94dc99ece8a476ea8e7908688767a7d37182 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 21:03:57 +0200 Subject: [PATCH 50/55] test(bindx): enforce fail-safe nested reconciliation --- tests/nestedHasManyCreate.test.ts | 98 +++++++++++++++++-------------- 1 file changed, 55 insertions(+), 43 deletions(-) diff --git a/tests/nestedHasManyCreate.test.ts b/tests/nestedHasManyCreate.test.ts index cef31374..fad1e116 100644 --- a/tests/nestedHasManyCreate.test.ts +++ b/tests/nestedHasManyCreate.test.ts @@ -267,16 +267,15 @@ describe('Nested hasMany create — 3-level deep (Program → Approval → Round * is nested inside another entity's create via collectHasManyOperations. */ /** - * After persistAll(), embedded relation data (like `reviews: [{ reviewType: 'expert' }]`) - * should be materialized into store entities during mutation building, and those - * entities should be committed after the parent mutation succeeds. + * Embedded relation data is materialized before persistence, but an atomic response + * without IDs for temp creates must leave the entire nested change pending. * * This verifies: * 1. Embedded data is materialized into proper store entities with temp IDs - * 2. Materialized entities are committed after successful persist - * 3. Entities are accessible via the hasMany store state + * 2. Missing IDs fail the atomic persist + * 3. Materialized entities remain accessible and pending for retry */ - test('after persistAll, materialized embedded entities should be committed in store', async () => { + test('atomic persist retains materialized entities when nested IDs are missing', async () => { const adapter: BackendAdapter = { query: mock(() => Promise.resolve([])), persist: mock(() => Promise.resolve({ ok: true })), @@ -334,21 +333,30 @@ describe('Nested hasMany create — 3-level deep (Program → Approval → Round store.getOrCreateHasMany('Approval', approvalId, 'rounds', []) store.addToHasMany('Approval', approvalId, 'rounds', roundId) - // Persist const result = await persister.persistAll() - expect(result.success).toBe(true) + expect(result.success).toBe(false) + expect(store.getPersistedId('Approval', approvalId)).toBeNull() + expect(store.getPersistedId('Round', roundId)).toBeNull() + const approvalRelation = store.getRelation('Program', 'prog-1', 'approval') + expect(approvalRelation?.currentId).toBe(approvalId) + expect(approvalRelation?.serverId).toBeNull() + const roundsState = store.getHasMany('Approval', approvalId, 'rounds') + expect(roundsState?.serverIds.size).toBe(0) + expect(roundsState?.plannedAdditions.has(roundId)).toBe(true) - // After persist + commit, the embedded review should have been materialized - // into a store entity. commitAllRelations moves createdEntities → serverIds. const reviewsState = store.getHasMany('Round', roundId, 'reviews') expect(reviewsState).not.toBeUndefined() - expect(reviewsState!.serverIds.size).toBe(1) + if (reviewsState === undefined) throw new Error('Expected materialized reviews state') + expect(reviewsState.serverIds.size).toBe(0) + expect(reviewsState.plannedAdditions.size).toBe(1) - // The review entity should exist in the store and be committed (existsOnServer) - const reviewTempId = [...reviewsState!.serverIds][0]! + const reviewTempId = [...reviewsState.plannedAdditions.keys()][0] + expect(reviewTempId).toBeDefined() + if (reviewTempId === undefined) throw new Error('Expected pending review') const reviewSnapshot = store.getEntitySnapshot('Review', reviewTempId) expect(reviewSnapshot).not.toBeUndefined() - expect(store.existsOnServer('Review', reviewTempId)).toBe(true) + expect(store.existsOnServer('Review', reviewTempId)).toBe(false) + expect(store.getPersistedId('Review', reviewTempId)).toBeNull() // The review should have the correct data const reviewData = reviewSnapshot!.data as Record @@ -390,7 +398,12 @@ describe('Nested hasMany create — 3-level deep (Program → Approval → Round // Build nested results by walking the mutation data and assigning server IDs let serverIdCounter = 0 function buildNestedResults(mutation: TransactionMutation): Array<{ entityType: string; entityId: string; ok: boolean; persistedId: string }> { - const results: Array<{ entityType: string; entityId: string; ok: boolean; persistedId: string }> = [] + const results: Array<{ entityType: string; entityId: string; ok: boolean; persistedId: string }> = [{ + entityType: 'Unknown', + entityId: approvalId, + ok: true, + persistedId: `server-id-${++serverIdCounter}`, + }] if (!mutation.data) return results walkMutationData(mutation.data, results) return results @@ -457,6 +470,8 @@ describe('Nested hasMany create — 3-level deep (Program → Approval → Round const result = await persister.persistAll() expect(result.success).toBe(true) + expect(store.getPersistedId('Approval', approvalId)).toMatch(/^server-id-/) + expect(store.getPersistedId('Round', roundId)).toMatch(/^server-id-/) // The adapter should have received mutations expect(capturedMutations.length).toBeGreaterThan(0) @@ -592,12 +607,10 @@ describe('Nested hasMany create — 3-level deep (Program → Approval → Round }) /** - * When the adapter returns fewer new items than create operations (e.g. partial server - * failure or ACL filtering), position-based matching is skipped entirely to avoid - * wrong ID mappings. The entities should still be committed (they exist on the server - * as part of the parent mutation) but keep their temp IDs. + * When the adapter returns fewer new items than create operations, only the uniquely + * resolved child is committed. The unresolved child remains pending for retry. */ - test('partial failure: fewer response items than creates skips ID mapping but still commits', async () => { + test('partial response commits resolved children and retains unresolved children for retry', async () => { let serverIdCounter = 0 const adapter: BackendAdapter = { @@ -692,32 +705,31 @@ describe('Nested hasMany create — 3-level deep (Program → Approval → Round store.addToHasMany('Approval', approvalId, 'rounds', roundId) const result = await persister.persistAll() - expect(result.success).toBe(true) + expect(result.success).toBe(false) - // Both reviews should be committed (existsOnServer = true) const reviewsState = store.getHasMany('Round', roundId, 'reviews') expect(reviewsState).not.toBeUndefined() - expect(reviewsState!.serverIds.size).toBe(2) - - const reviewTempIds = [...reviewsState!.serverIds] - for (const tempId of reviewTempIds) { - expect(store.existsOnServer('Review', tempId)).toBe(true) - } - - // Content matching maps the one review present in the response. - // The other review (missing from response due to ACL) keeps its temp ID. - let mappedCount = 0 - let unmappedCount = 0 - for (const tempId of reviewTempIds) { - const persistedId = store.getPersistedId('Review', tempId) - if (persistedId) { - mappedCount++ - } else { - unmappedCount++ - } - } - expect(mappedCount).toBe(1) - expect(unmappedCount).toBe(1) + if (reviewsState === undefined) throw new Error('Expected reviews state') + expect(reviewsState.serverIds.size).toBe(1) + expect(reviewsState.plannedAdditions.size).toBe(1) + + const resolvedId = [...reviewsState.serverIds][0] + const unresolvedId = [...reviewsState.plannedAdditions.keys()][0] + expect(resolvedId).toMatch(/^server-/) + expect(unresolvedId).toBeDefined() + if (resolvedId === undefined || unresolvedId === undefined) throw new Error('Expected resolved and pending reviews') + expect(store.existsOnServer('Review', resolvedId)).toBe(true) + expect(store.existsOnServer('Review', unresolvedId)).toBe(false) + expect(store.getPersistedId('Review', unresolvedId)).toBeNull() + expect(store.getEntitySnapshot('Review', unresolvedId)).not.toBeUndefined() + + const retry = await persister.persistAll() + expect(retry.success).toBe(true) + const retriedState = store.getHasMany('Round', roundId, 'reviews') + expect(retriedState).not.toBeUndefined() + if (retriedState === undefined) throw new Error('Expected reviews state after retry') + expect(retriedState.serverIds.size).toBe(2) + expect(retriedState.plannedAdditions.size).toBe(0) }) /** From 47ed880e9a3a56b0457aac836b5382ee3d02824d Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 21:11:58 +0200 Subject: [PATCH 51/55] fix(bindx-dataview): preserve typed runtime rows --- packages/bindx-dataview/src/useDataGridSetup.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/bindx-dataview/src/useDataGridSetup.ts b/packages/bindx-dataview/src/useDataGridSetup.ts index 4e45e62c..970c1152 100644 --- a/packages/bindx-dataview/src/useDataGridSetup.ts +++ b/packages/bindx-dataview/src/useDataGridSetup.ts @@ -15,7 +15,7 @@ import type { SelectionValues, SchemaRegistry, } from '@contember/bindx' -import { createFullTextFilterHandler, SelectionScope, buildQueryFromSelection } from '@contember/bindx' +import { createFullTextFilterHandler, SelectionScope, buildQueryFromSelection, FIELD_REF_META } from '@contember/bindx' import { createCollectorProxy, mergeSelections, @@ -41,6 +41,13 @@ const ELEMENT_MARKER_TYPES: ReadonlySet> = new Set([ DataViewElement, ]) +function isEntityAccessorFor( + accessor: EntityAccessor, + entityType: string, +): accessor is EntityAccessor { + return accessor[FIELD_REF_META].entityType === entityType +} + export interface DataGridCommonProps { children: (it: EntityAccessor) => ReactNode initialSorting?: Partial> @@ -112,6 +119,9 @@ export function useDataGridSetup({ const getRuntimeColumns = (accessor: EntityAccessor): readonly ColumnLeafProps[] => { const cached = runtimeColumnsByAccessor.get(accessor) if (cached) return cached + if (!isEntityAccessorFor(accessor, entityType)) { + throw new Error(`DataGrid expected a "${entityType}" row accessor, received "${accessor[FIELD_REF_META].entityType}".`) + } const runtimeJsx = children(accessor) const runtimeColumns = analyzeChildren(runtimeJsx, MARKER_TYPES).getAll(ColumnLeaf) From 5f0744e27d79cc1802efb1f1bf41f99588bb9a69 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 21 Aug 2026 21:19:47 +0200 Subject: [PATCH 52/55] fix(bindx): unify identity across persisted rekeys --- .../src/hooks/ItemAccessorCache.ts | 10 + .../bindx-react/src/hooks/useEntityList.ts | 10 +- packages/bindx/src/core/ActionDispatcher.ts | 1 + packages/bindx/src/events/EventEmitter.ts | 92 ++++-- packages/bindx/src/handles/BaseHandle.ts | 7 +- packages/bindx/src/handles/EntityHandle.ts | 35 +-- packages/bindx/src/handles/FieldHandle.ts | 30 +- .../bindx/src/handles/HasManyListHandle.ts | 22 +- packages/bindx/src/handles/HasOneHandle.ts | 21 +- .../bindx/src/handles/PlaceholderHandle.ts | 199 ++++++++++--- packages/bindx/src/store/RekeyOrchestrator.ts | 13 +- packages/bindx/src/store/SnapshotStore.ts | 17 +- .../hooks/useEntityList/persistRekey.test.tsx | 38 +++ tests/unit/events/eventEmitter.test.ts | 69 +++++ tests/unit/handles/hasManyItemCache.test.ts | 18 ++ tests/unit/handles/proxyEnumeration.test.ts | 142 +++++++++ tests/unit/handles/rekeyLifecycle.test.ts | 280 ++++++++++++++++++ tests/unit/store/rekeyOrchestrator.test.ts | 13 + 18 files changed, 907 insertions(+), 110 deletions(-) create mode 100644 tests/unit/handles/rekeyLifecycle.test.ts diff --git a/packages/bindx-react/src/hooks/ItemAccessorCache.ts b/packages/bindx-react/src/hooks/ItemAccessorCache.ts index 1a3ffbd2..82dfcfc9 100644 --- a/packages/bindx-react/src/hooks/ItemAccessorCache.ts +++ b/packages/bindx-react/src/hooks/ItemAccessorCache.ts @@ -34,6 +34,7 @@ export class ItemAccessorCache { build(items: ReadonlyArray<{ id: string }>): Array> { const accessors: Array> = [] const liveIds = new Set() + this.canonicalizeEntries() for (const item of items) { const id = this.resolveId(item.id) @@ -50,6 +51,15 @@ export class ItemAccessorCache { return accessors } + private canonicalizeEntries(): void { + for (const [id, accessor] of [...this.entries]) { + const canonicalId = this.resolveId(id) + if (canonicalId === id) continue + if (!this.entries.has(canonicalId)) this.entries.set(canonicalId, accessor) + this.entries.delete(id) + } + } + private resolve(id: string): EntityAccessor { let accessor = this.entries.get(id) if (!accessor) { diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index d1b96ae5..edbbd671 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -1,6 +1,6 @@ import { useRef, useEffect, useMemo, useCallback } from 'react' import type { EntityDef, EntityAccessor, SelectionInput, SelectionMeta, FieldError, SchemaRegistry, CommonEntity, EntityForRoles, RoleNames } from '@contember/bindx' -import { EntityHandle, isTempId, isPersistedId, resolveSelectionMeta, buildQueryFromSelection, refreshServerData, createLoadError } from '@contember/bindx' +import { EntityHandle, isTempId, resolveSelectionMeta, buildQueryFromSelection, refreshServerData, createLoadError } from '@contember/bindx' import { useBindxContext, useSchemaRegistry } from './BackendAdapterContext.js' import { useStoreSubscription } from './useStoreSubscription.js' import { ItemAccessorCache } from './ItemAccessorCache.js' @@ -242,7 +242,7 @@ export function useEntityList( // Canonical id of a list item: a temp id follows its temp→persisted rekey. The list // state keeps the id it was given, so every id comparison goes through this. const resolveItemId = useCallback( - (id: string): string => (isPersistedId(id) ? id : store.getPersistedId(entityType, id) ?? id), + (id: string): string => store.resolveEntityId(entityType, id), [store, entityType], ) @@ -364,6 +364,10 @@ export function useEntityList( } else if (state.status === 'error') { result = createErrorListResult(state.error!) } else { + state.items = state.items.map(item => { + const id = resolveItemId(item.id) + return id === item.id ? item : { id, data: item.data } + }) // The array itself is deliberately NOT identity-stable: consumers that only re-render // through a parent (the DataGrid render chain) rely on a fresh array per store bump. // Per-item accessor identity is the stable part — see ItemAccessorCache. @@ -394,7 +398,7 @@ export function useEntityList( } return result - }, [store, itemAccessorCache, addItem, removeItem, moveItem]) + }, [store, itemAccessorCache, addItem, removeItem, moveItem, resolveItemId]) const isEqual = useCallback( (a: UseEntityListResult, b: UseEntityListResult): boolean => { diff --git a/packages/bindx/src/core/ActionDispatcher.ts b/packages/bindx/src/core/ActionDispatcher.ts index 4049884e..83eeeddc 100644 --- a/packages/bindx/src/core/ActionDispatcher.ts +++ b/packages/bindx/src/core/ActionDispatcher.ts @@ -27,6 +27,7 @@ export class ActionDispatcher { eventEmitter?: EventEmitter, ) { this.eventEmitter = eventEmitter ?? new EventEmitter() + this.store.attachRekeyParticipant(this.eventEmitter) } /** diff --git a/packages/bindx/src/events/EventEmitter.ts b/packages/bindx/src/events/EventEmitter.ts index e2dee657..639dd4ab 100644 --- a/packages/bindx/src/events/EventEmitter.ts +++ b/packages/bindx/src/events/EventEmitter.ts @@ -15,6 +15,9 @@ import type { InterceptorResult, Unsubscribe, } from './types.js' +import type { RekeyContext, Rekeyable } from '../store/RekeyOrchestrator.js' + +const SCOPE_SEPARATOR = '\u0000' interface ScopeKey { entityType: string @@ -31,7 +34,7 @@ interface ScopeKey { * - Field-scoped subscriptions (events for a specific field/relation) * - Interceptors for before-events (can cancel or modify) */ -export class EventEmitter { +export class EventEmitter implements Rekeyable { // Global listeners by event type private readonly globalListeners = new Map>>() @@ -44,6 +47,9 @@ export class EventEmitter { // Scoped interceptors private readonly scopedInterceptors = new Map>>() + /** Old scope keys retained so pre-rekey unsubscribe closures still resolve. */ + private readonly scopeKeyRedirects = new Map() + // ============================================================================ // Listener Subscription Methods // ============================================================================ @@ -69,10 +75,11 @@ export class EventEmitter { entityId: string, listener: EventListener, ): Unsubscribe { - const key = this.buildScopeKey(eventType, { entityType, entityId }) + const key = this.resolveScopeKey(this.buildScopeKey(eventType, { entityType, entityId })) const listeners = this.getOrCreateSet(this.scopedListeners, key) - listeners.add(listener as EventListener) - return () => listeners.delete(listener as EventListener) + const typedListener = listener as EventListener + listeners.add(typedListener) + return () => this.removeScoped(this.scopedListeners, key, typedListener) } /** @@ -85,10 +92,11 @@ export class EventEmitter { fieldName: string, listener: EventListener, ): Unsubscribe { - const key = this.buildScopeKey(eventType, { entityType, entityId, fieldName }) + const key = this.resolveScopeKey(this.buildScopeKey(eventType, { entityType, entityId, fieldName })) const listeners = this.getOrCreateSet(this.scopedListeners, key) - listeners.add(listener as EventListener) - return () => listeners.delete(listener as EventListener) + const typedListener = listener as EventListener + listeners.add(typedListener) + return () => this.removeScoped(this.scopedListeners, key, typedListener) } // ============================================================================ @@ -117,10 +125,11 @@ export class EventEmitter { entityId: string, interceptor: Interceptor, ): Unsubscribe { - const key = this.buildScopeKey(eventType, { entityType, entityId }) + const key = this.resolveScopeKey(this.buildScopeKey(eventType, { entityType, entityId })) const interceptors = this.getOrCreateSet(this.scopedInterceptors, key) - interceptors.add(interceptor as Interceptor) - return () => interceptors.delete(interceptor as Interceptor) + const typedInterceptor = interceptor as Interceptor + interceptors.add(typedInterceptor) + return () => this.removeScoped(this.scopedInterceptors, key, typedInterceptor) } /** @@ -133,10 +142,11 @@ export class EventEmitter { fieldName: string, interceptor: Interceptor, ): Unsubscribe { - const key = this.buildScopeKey(eventType, { entityType, entityId, fieldName }) + const key = this.resolveScopeKey(this.buildScopeKey(eventType, { entityType, entityId, fieldName })) const interceptors = this.getOrCreateSet(this.scopedInterceptors, key) - interceptors.add(interceptor as Interceptor) - return () => interceptors.delete(interceptor as Interceptor) + const typedInterceptor = interceptor as Interceptor + interceptors.add(typedInterceptor) + return () => this.removeScoped(this.scopedInterceptors, key, typedInterceptor) } /** @@ -147,7 +157,7 @@ export class EventEmitter { const global = this.globalInterceptors.get(eventType) if (global !== undefined && global.size > 0) return true - const scoped = this.scopedInterceptors.get(this.buildScopeKey(eventType, { entityType, entityId })) + const scoped = this.scopedInterceptors.get(this.resolveScopeKey(this.buildScopeKey(eventType, { entityType, entityId }))) return scoped !== undefined && scoped.size > 0 } @@ -174,7 +184,7 @@ export class EventEmitter { fieldName, }) const result = await this.runInterceptorSet( - this.scopedInterceptors.get(fieldKey), + this.scopedInterceptors.get(this.resolveScopeKey(fieldKey)), currentEvent, ) if (result === null) return null @@ -188,7 +198,7 @@ export class EventEmitter { entityId: event.entityId, }) const entityResult = await this.runInterceptorSet( - this.scopedInterceptors.get(entityKey), + this.scopedInterceptors.get(this.resolveScopeKey(entityKey)), currentEvent, ) if (entityResult === null) return null @@ -221,7 +231,7 @@ export class EventEmitter { entityId: event.entityId, fieldName, }) - this.notifyListeners(this.scopedListeners.get(fieldKey), event) + this.notifyListeners(this.scopedListeners.get(this.resolveScopeKey(fieldKey)), event) } // Entity-level listeners @@ -229,7 +239,7 @@ export class EventEmitter { entityType: event.entityType, entityId: event.entityId, }) - this.notifyListeners(this.scopedListeners.get(entityKey), event) + this.notifyListeners(this.scopedListeners.get(this.resolveScopeKey(entityKey)), event) // Global listeners this.notifyListeners(this.globalListeners.get(event.type), event) @@ -243,11 +253,42 @@ export class EventEmitter { * Builds a scope key for listener/interceptor lookup. */ private buildScopeKey(eventType: string, scope: ScopeKey): string { - const parts = [eventType, scope.entityType, scope.entityId] - if (scope.fieldName) { - parts.push(scope.fieldName) + const parts = [eventType, scope.entityType, scope.entityId, scope.fieldName ?? ''] + return parts.join(SCOPE_SEPARATOR) + } + + private resolveScopeKey(key: string): string { + let resolved = key + let next = this.scopeKeyRedirects.get(resolved) + while (next && next !== resolved) { + resolved = next + next = this.scopeKeyRedirects.get(resolved) + } + return resolved + } + + private removeScoped(map: Map>, key: string, value: T): void { + map.get(this.resolveScopeKey(key))?.delete(value) + } + + rekey(ctx: RekeyContext): void { + this.rekeyScopedMap(this.scopedListeners, ctx) + this.rekeyScopedMap(this.scopedInterceptors, ctx) + } + + private rekeyScopedMap(map: Map>, ctx: RekeyContext): void { + const entityType = ctx.oldKey.slice(0, ctx.oldKey.length - ctx.oldId.length - 1) + const oldIdentity = `${SCOPE_SEPARATOR}${entityType}${SCOPE_SEPARATOR}${ctx.oldId}${SCOPE_SEPARATOR}` + const newIdentity = `${SCOPE_SEPARATOR}${entityType}${SCOPE_SEPARATOR}${ctx.newId}${SCOPE_SEPARATOR}` + + for (const [oldScopeKey, values] of [...map]) { + if (!oldScopeKey.includes(oldIdentity)) continue + const newScopeKey = oldScopeKey.replace(oldIdentity, newIdentity) + const destination = this.getOrCreateSet(map, newScopeKey) + for (const value of values) destination.add(value) + map.delete(oldScopeKey) + this.scopeKeyRedirects.set(oldScopeKey, newScopeKey) } - return parts.join(':') } /** @@ -309,7 +350,7 @@ export class EventEmitter { fieldName, }) const result = this.runInterceptorSetSync( - this.scopedInterceptors.get(fieldKey), + this.scopedInterceptors.get(this.resolveScopeKey(fieldKey)), currentEvent, ) if (result === null) return null @@ -322,7 +363,7 @@ export class EventEmitter { entityId: event.entityId, }) const entityResult = this.runInterceptorSetSync( - this.scopedInterceptors.get(entityKey), + this.scopedInterceptors.get(this.resolveScopeKey(entityKey)), currentEvent, ) if (entityResult === null) return null @@ -436,6 +477,7 @@ export class EventEmitter { this.scopedListeners.clear() this.globalInterceptors.clear() this.scopedInterceptors.clear() + this.scopeKeyRedirects.clear() } /** @@ -449,7 +491,7 @@ export class EventEmitter { // Count scoped listeners for (const [key, listeners] of this.scopedListeners) { - if (key.startsWith(eventType + ':')) { + if (key.startsWith(eventType + SCOPE_SEPARATOR)) { count += listeners.size } } diff --git a/packages/bindx/src/handles/BaseHandle.ts b/packages/bindx/src/handles/BaseHandle.ts index a37d869e..62e9a131 100644 --- a/packages/bindx/src/handles/BaseHandle.ts +++ b/packages/bindx/src/handles/BaseHandle.ts @@ -39,13 +39,18 @@ export abstract class BaseHandle { export abstract class EntityRelatedHandle extends BaseHandle { constructor( protected readonly entityType: string, - protected readonly entityId: string, + private readonly sourceEntityId: string, store: SnapshotStore, dispatcher: ActionDispatcher, ) { super(store, dispatcher) } + /** Current canonical id; the handle keeps no mutable rekey state. */ + protected get entityId(): string { + return this.store.resolveEntityId(this.entityType, this.sourceEntityId) + } + /** * Subscribe to entity changes. */ diff --git a/packages/bindx/src/handles/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index 0289927e..51cdc211 100644 --- a/packages/bindx/src/handles/EntityHandle.ts +++ b/packages/bindx/src/handles/EntityHandle.ts @@ -123,11 +123,7 @@ export class EntityHandle extends Enti */ get selectedFieldNames(): readonly string[] { if (!this.selection) return [] - const names = new Set() - for (const meta of this.selection.fields.values()) { - names.add(meta.fieldName) - } - return [...names] + return [...this.selection.fields.keys()] } get [FIELD_REF_META](): FieldRefMeta { @@ -271,24 +267,26 @@ export class EntityHandle extends Enti * Gets a field handle for a specific field. * Returns cached handle to ensure stable identity. */ - field(fieldName: K): FieldAccessor { - const cacheKey = String(fieldName) + field(fieldName: K, dataFieldName = String(fieldName)): FieldAccessor { + const schemaFieldName = String(fieldName) + const cacheKey = dataFieldName const cached = this.fieldHandleCache.get(cacheKey) if (cached) { return cached.proxy as FieldAccessor } - const enumName = this.schema.getEnumName(this.entityType, cacheKey) - const columnType = this.schema.getColumnType(this.entityType, cacheKey) + const enumName = this.schema.getEnumName(this.entityType, schemaFieldName) + const columnType = this.schema.getColumnType(this.entityType, schemaFieldName) const raw = FieldHandle.createRaw( this.entityType, this.entityId, - [cacheKey], + [schemaFieldName], this.store, this.dispatcher, enumName, columnType, + [dataFieldName], ) const proxy = FieldHandle.wrapProxy(raw) this.fieldHandleCache.set(cacheKey, { raw, proxy } as CachedFieldHandle) @@ -299,8 +297,8 @@ export class EntityHandle extends Enti /** * Gets a has-one relation handle. */ - hasOne(fieldName: string, nestedSelection?: SelectionMeta): HasOneAccessor { - const cacheKey = `hasOne:${fieldName}` + hasOne(fieldName: string, nestedSelection?: SelectionMeta, dataFieldName = fieldName): HasOneAccessor { + const cacheKey = `hasOne:${dataFieldName}` const cached = this.relationHandleCache.get(cacheKey) if (cached) { @@ -324,6 +322,7 @@ export class EntityHandle extends Enti this.schema, undefined, nestedSelection, + dataFieldName, ) const proxy = HasOneHandle.wrapProxy(raw) this.relationHandleCache.set(cacheKey, { raw, proxy }) @@ -482,29 +481,31 @@ export class EntityHandle extends Enti } const nestedSelection = fieldMeta?.nested + const schemaFieldName = fieldMeta?.fieldName ?? fieldName + const dataFieldName = fieldMeta?.alias ?? fieldName // Use schema to determine field type - const fieldDef = this.schema.getFieldDef(this.entityType, fieldName) + const fieldDef = this.schema.getFieldDef(this.entityType, schemaFieldName) if (!fieldDef || fieldDef.type === 'scalar') { // Scalar field - return FieldHandle - return this.field(fieldName as keyof T) + return this.field(schemaFieldName as keyof T, dataFieldName) } if (fieldDef.type === 'hasOne') { // Has-one relation - return HasOneHandle - return this.hasOne(fieldName, nestedSelection) + return this.hasOne(schemaFieldName, nestedSelection, dataFieldName) } if (fieldDef.type === 'hasMany') { // Has-many relation - return HasManyListHandle. // Thread the selected alias so the handle reads data stored under the // auto-generated alias (e.g. `tags_`) for params-bearing relations. - return this.hasMany(fieldName, fieldMeta?.alias, nestedSelection) + return this.hasMany(schemaFieldName, fieldMeta?.alias, nestedSelection) } // Unknown field type - fallback to FieldHandle - return this.field(fieldName as keyof T) + return this.field(schemaFieldName as keyof T, dataFieldName) }, }) } diff --git a/packages/bindx/src/handles/FieldHandle.ts b/packages/bindx/src/handles/FieldHandle.ts index 0c5890bd..7fccb951 100644 --- a/packages/bindx/src/handles/FieldHandle.ts +++ b/packages/bindx/src/handles/FieldHandle.ts @@ -37,6 +37,7 @@ export class FieldHandle extends EntityRelatedHandle { dispatcher: ActionDispatcher, private readonly _enumName?: string, private readonly _columnType?: string, + private readonly dataFieldPath: string[] = fieldPath, ) { super(entityType, entityId, store, dispatcher) } @@ -49,8 +50,9 @@ export class FieldHandle extends EntityRelatedHandle { dispatcher: ActionDispatcher, enumName?: string, columnType?: string, + dataFieldPath?: string[], ): FieldAccessor { - return createAliasProxy, FieldAccessor>(new FieldHandle(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType)) + return createAliasProxy, FieldAccessor>(new FieldHandle(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType, dataFieldPath)) } static createRaw( @@ -61,8 +63,9 @@ export class FieldHandle extends EntityRelatedHandle { dispatcher: ActionDispatcher, enumName?: string, columnType?: string, + dataFieldPath?: string[], ): FieldHandle { - return new FieldHandle(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType) + return new FieldHandle(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType, dataFieldPath) } static wrapProxy(handle: FieldHandle): FieldAccessor { @@ -93,7 +96,7 @@ export class FieldHandle extends EntityRelatedHandle { get value(): T | null { const data = this.getPresentationData() if (!data) return null - return getNestedValue(data, this.fieldPath) as T | null + return this.getCurrentValue(data) as T | null } /** @@ -102,7 +105,7 @@ export class FieldHandle extends EntityRelatedHandle { get serverValue(): T | null { const serverData = this.getServerData() if (!serverData) return null - return getNestedValue(serverData, this.fieldPath) as T | null + return this.getServerValue(serverData) as T | null } /** @@ -112,10 +115,20 @@ export class FieldHandle extends EntityRelatedHandle { */ get isDirty(): boolean { const data = this.getEntityData() - const value = data ? (getNestedValue(data, this.fieldPath) as T | null) : null + const value = data ? (this.getCurrentValue(data) as T | null) : null return !deepEqual(value, this.serverValue) } + private getCurrentValue(data: Record): unknown { + const value = getNestedValue(data, this.fieldPath) + return value === undefined ? getNestedValue(data, this.dataFieldPath) : value + } + + private getServerValue(data: Record): unknown { + const value = getNestedValue(data, this.fieldPath) + return value === undefined ? getNestedValue(data, this.dataFieldPath) : value + } + /** * Checks if the field has been touched (interacted with by the user). * Useful for showing errors only after user interaction. @@ -222,12 +235,16 @@ export class FieldHandle extends EntityRelatedHandle { nested>( key: K, ): FieldAccessor[K]> { + const fieldName = String(key) return FieldHandle.create[K]>( this.entityType, this.entityId, - [...this.fieldPath, key as string], + [...this.fieldPath, fieldName], this.store, this.dispatcher, + undefined, + undefined, + [...this.dataFieldPath, fieldName], ) } @@ -282,4 +299,3 @@ function getNestedValue(obj: Record, path: string[]): unknown { return current } - diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index 0ac120e0..8a4c6103 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -24,7 +24,6 @@ import type { HasManyDisconnectingEvent, } from '../events/types.js' import { createAliasProxy } from './proxyFactory.js' -import { isPersistedId } from '../store/entityId.js' /** * HasManyListHandle provides access to a has-many relation (list of entities). @@ -165,8 +164,7 @@ export class HasManyListHandle this.resolveItemKey(id))) for (const key of this.itemHandleCacheProxy.keys()) { if (liveKeys.has(key)) continue - // A key with no live id is dead — including the temp key of a rekeyed item, whose - // handle must be rebuilt under the persisted id (a carried-over handle would keep - // reporting the temp id as its `id`). + this.itemHandleCacheProxy.delete(key) + } + } + + private canonicalizeItemHandleCache(): void { + for (const [key, proxy] of [...this.itemHandleCacheProxy]) { + const canonicalKey = this.resolveItemKey(key) + if (canonicalKey === key) continue + if (!this.itemHandleCacheProxy.has(canonicalKey)) { + this.itemHandleCacheProxy.set(canonicalKey, proxy) + } this.itemHandleCacheProxy.delete(key) } } @@ -352,6 +359,7 @@ export class HasManyListHandle { + this.canonicalizeItemHandleCache() const key = this.resolveItemKey(itemId) let proxy = this.itemHandleCacheProxy.get(key) diff --git a/packages/bindx/src/handles/HasOneHandle.ts b/packages/bindx/src/handles/HasOneHandle.ts index b390489a..c2714e24 100644 --- a/packages/bindx/src/handles/HasOneHandle.ts +++ b/packages/bindx/src/handles/HasOneHandle.ts @@ -65,6 +65,7 @@ export class HasOneHandle private readonly schema: SchemaRegistry, brands?: Set, private readonly selection?: SelectionMeta, + private readonly dataFieldName: string = fieldName, ) { super(parentEntityType, parentEntityId, store, dispatcher) this.__brands = brands @@ -80,8 +81,9 @@ export class HasOneHandle schema: SchemaRegistry, brands?: Set, selection?: SelectionMeta, + dataFieldName?: string, ): HasOneAccessor { - return HasOneHandle.wrapProxy(new HasOneHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection)) + return HasOneHandle.wrapProxy(new HasOneHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection, dataFieldName)) } static createRaw( @@ -94,8 +96,9 @@ export class HasOneHandle schema: SchemaRegistry, brands?: Set, selection?: SelectionMeta, + dataFieldName?: string, ): HasOneHandle { - return new HasOneHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection) + return new HasOneHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection, dataFieldName) } static wrapProxy(handle: HasOneHandle): HasOneAccessor { @@ -240,7 +243,7 @@ export class HasOneHandle if (embeddedReference.kind === 'connected' && existing.serverId === embeddedReference.id && existing.serverState === 'connected') { return } - if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.fieldName, embeddedData)) { + if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.dataFieldName, embeddedData)) { return } @@ -272,12 +275,12 @@ export class HasOneHandle /** Reads the embedded related object from the parent's canonical current data. */ private readEmbeddedRelatedData(): unknown { - return this.getEntityData()?.[this.fieldName] + return this.getEntityData()?.[this.dataFieldName] } /** Extracts the related id from the parent's embedded server data, or null. */ private readServerRelatedId(): string | null { - return extractRelatedId(this.getServerData()?.[this.fieldName]) + return extractRelatedId(this.getServerData()?.[this.dataFieldName]) } /** @@ -385,7 +388,7 @@ export class HasOneHandle return } - const embeddedData = (parentSnapshot.data as Record)[this.fieldName] + const embeddedData = (parentSnapshot.data as Record)[this.dataFieldName] if (!embeddedData || typeof embeddedData !== 'object') { return } @@ -403,7 +406,7 @@ export class HasOneHandle // A new reference means the parent was re-fetched from the server. // Same reference means the embedded data is stale and must not overwrite // child state that may have been updated by a local commit. - if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.fieldName, embeddedData)) { + if (!this.store.hasEmbeddedDataChanged(this.entityType, this.entityId, this.dataFieldName, embeddedData)) { return } @@ -412,7 +415,7 @@ export class HasOneHandle // (e.g. polling). A new reference with identical values means no actual change. const existing = this.store.getEntitySnapshot(this.targetType, id) if (existing?.serverData && embeddedDataMatchesSnapshot(embeddedData as Record, existing.serverData as Record)) { - this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.fieldName, embeddedData) + this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.dataFieldName, embeddedData) return } @@ -427,7 +430,7 @@ export class HasOneHandle embeddedData as Record, true, // skipNotify - called during render, data already exists embedded in parent ) - this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.fieldName, embeddedData) + this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.dataFieldName, embeddedData) } /** diff --git a/packages/bindx/src/handles/PlaceholderHandle.ts b/packages/bindx/src/handles/PlaceholderHandle.ts index 4c5f4b7f..172f9586 100644 --- a/packages/bindx/src/handles/PlaceholderHandle.ts +++ b/packages/bindx/src/handles/PlaceholderHandle.ts @@ -44,10 +44,12 @@ export class PlaceholderHandle void>() + private pendingRelationUnsubscribe: Unsubscribe | null = null private constructor( private readonly parentEntityType: string, - private readonly parentEntityId: string, + private readonly sourceParentEntityId: string, private readonly fieldName: string, private readonly targetType: string, private readonly store: SnapshotStore, @@ -59,6 +61,56 @@ export class PlaceholderHandle { + const id = this.findCurrentEntityId() + if (id) this.activatePendingEventSubscriptions(id) + }, + ) + } + + private activatePendingEventSubscriptions(entityId: string): void { + if (this.pendingEventSubscriptions.size === 0) return + const subscriptions = [...this.pendingEventSubscriptions] + this.pendingEventSubscriptions.clear() + this.pendingRelationUnsubscribe?.() + this.pendingRelationUnsubscribe = null + for (const subscribe of subscriptions) subscribe(entityId) + } + + private stopWatchingMaterializationWhenIdle(): void { + if (this.pendingEventSubscriptions.size > 0) return + this.pendingRelationUnsubscribe?.() + this.pendingRelationUnsubscribe = null + } + static create( parentEntityType: string, parentEntityId: string, @@ -94,32 +146,49 @@ export class PlaceholderHandle(this.targetType, entityId)?.data ?? null + : this.store.getRelation( + this.parentEntityType, + this.parentEntityId, + this.fieldName, + )?.placeholderData ?? null + if (data && Object.keys(data).length === 0) return null + return data as TSelected | null } /** * Placeholder is dirty if it has any data. */ get isDirty(): boolean { + const entityId = this.currentEntityId + if (entityId) { + return this.store.getDirtyFields(this.targetType, entityId).length > 0 || + this.store.getDirtyRelations(this.targetType, entityId).length > 0 + } const relation = this.store.getRelation( this.parentEntityType, this.parentEntityId, @@ -132,21 +201,24 @@ export class PlaceholderHandle>(self.targetType, entityId)?.data[fieldName] ?? null + } const relation = self.store.getRelation( self.parentEntityType, self.parentEntityId, @@ -268,6 +344,10 @@ export class PlaceholderHandle { + if (self.currentEntityId) { + self.dispatcher.dispatch({ + type: 'SET_FIELD', + entityType: self.targetType, + entityId: self.id, + fieldPath: [fieldName], + value, + }) + return + } self.dispatcher.dispatch({ type: 'SET_PLACEHOLDER_DATA', entityType: self.parentEntityType, @@ -287,6 +377,10 @@ export class PlaceholderHandle { + const entityId = self.currentEntityId + if (entityId) { + return self.store.getPresentationSnapshot>(self.targetType, entityId)?.data[fieldName] ?? null + } const relation = self.store.getRelation( self.parentEntityType, self.parentEntityId, @@ -295,6 +389,16 @@ export class PlaceholderHandle { + if (self.currentEntityId) { + self.dispatcher.dispatch({ + type: 'SET_FIELD', + entityType: self.targetType, + entityId: self.id, + fieldPath: [fieldName], + value, + }) + return + } self.dispatcher.dispatch({ type: 'SET_PLACEHOLDER_DATA', entityType: self.parentEntityType, @@ -354,7 +458,7 @@ export class PlaceholderHandle( - _eventType: E, - _listener: EventListener, + eventType: E, + listener: EventListener, ): Unsubscribe { - return () => {} + const emitter = this.dispatcher.getEventEmitter() + if (this.currentEntityId) { + return emitter.onEntity(eventType, this.targetType, this.id, listener) + } + let unsubscribe: Unsubscribe | null = null + const subscribe = (entityId: string): void => { + unsubscribe = emitter.onEntity(eventType, this.targetType, entityId, listener) + } + this.pendingEventSubscriptions.add(subscribe) + this.watchPendingMaterialization() + return () => { + this.pendingEventSubscriptions.delete(subscribe) + this.stopWatchingMaterializationWhenIdle() + unsubscribe?.() + } } /** - * No-op for placeholder entities. + * Activates the interceptor when the placeholder materializes. */ intercept( - _eventType: E, - _interceptor: Interceptor, + eventType: E, + interceptor: Interceptor, ): Unsubscribe { - return () => {} + const emitter = this.dispatcher.getEventEmitter() + if (this.currentEntityId) { + return emitter.interceptEntity(eventType, this.targetType, this.id, interceptor) + } + let unsubscribe: Unsubscribe | null = null + const subscribe = (entityId: string): void => { + unsubscribe = emitter.interceptEntity(eventType, this.targetType, entityId, interceptor) + } + this.pendingEventSubscriptions.add(subscribe) + this.watchPendingMaterialization() + return () => { + this.pendingEventSubscriptions.delete(subscribe) + this.stopWatchingMaterializationWhenIdle() + unsubscribe?.() + } } /** - * No-op for placeholder entities. + * Subscribes to persistence after materialization. */ - onPersisted(_listener: EventListener): Unsubscribe { - return () => {} + onPersisted(listener: EventListener): Unsubscribe { + return this.on('entity:persisted', listener) } /** - * No-op for placeholder entities. + * Intercepts persistence after materialization. */ - interceptPersisting(_interceptor: Interceptor): Unsubscribe { - return () => {} + interceptPersisting(interceptor: Interceptor): Unsubscribe { + return this.intercept('entity:persisting', interceptor) } } diff --git a/packages/bindx/src/store/RekeyOrchestrator.ts b/packages/bindx/src/store/RekeyOrchestrator.ts index 87895500..4e3a3d9c 100644 --- a/packages/bindx/src/store/RekeyOrchestrator.ts +++ b/packages/bindx/src/store/RekeyOrchestrator.ts @@ -42,12 +42,23 @@ export interface Rekeyable { export class RekeyOrchestrator { /** "entityType:tempId" → persistedId. The single identity-redirect map. */ private readonly tempToPersisted = new Map() + private readonly participants: Rekeyable[] = [] + private readonly participantSet = new Set() /** * @param participants the sub-stores to migrate, in the exact order * {@link rekey} must visit them (the order is load-bearing — see rekey()). */ - constructor(private readonly participants: readonly Rekeyable[]) {} + constructor(participants: readonly Rekeyable[]) { + for (const participant of participants) this.registerParticipant(participant) + } + + /** Adds a participant once, preserving registration order. */ + registerParticipant(participant: Rekeyable): void { + if (this.participantSet.has(participant)) return + this.participantSet.add(participant) + this.participants.push(participant) + } /** Resolves an entity key, following a temp→persisted redirect if present. */ resolveKey(entityType: string, id: string): string { diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index ae2e9714..e7a1d180 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -20,7 +20,7 @@ import { EntitySnapshotStore } from './EntitySnapshotStore.js' import { RootRegistry } from './RootRegistry.js' import { ReachabilityAnalyzer } from './ReachabilityAnalyzer.js' import { RekeyOrchestrator } from './RekeyOrchestrator.js' -import type { RekeyContext } from './RekeyOrchestrator.js' +import type { RekeyContext, Rekeyable } from './RekeyOrchestrator.js' import type { UndoJournal, JournalTarget, @@ -183,17 +183,22 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { } private getRelationKey(parentType: string, parentId: string, fieldName: string): string { - const resolvedParentId = this.resolveId(parentType, parentId) + const resolvedParentId = this.resolveEntityId(parentType, parentId) return `${parentType}:${resolvedParentId}:${fieldName}` } /** * Resolves an ID to its persisted ID if it has been rekeyed. */ - private resolveId(entityType: string, id: string): string { + resolveEntityId(entityType: string, id: string): string { return this.rekeyOrchestrator.resolveId(entityType, id) } + /** Attaches identity-keyed state to the store's single rekey fan-out. */ + attachRekeyParticipant(participant: Rekeyable): void { + this.rekeyOrchestrator.registerParticipant(participant) + } + // ==================== SnapshotVersionBumper ==================== bumpEntitySnapshotVersion(key: string): void { @@ -237,7 +242,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { * Removes all propagation tracking entries for a given parent entity. */ clearPropagatedDataForEntity(parentType: string, parentId: string): void { - const prefix = `${parentType}:${this.resolveId(parentType, parentId)}:` + const prefix = `${parentType}:${this.resolveEntityId(parentType, parentId)}:` for (const key of this.lastPropagatedData.keys()) { if (key.startsWith(prefix)) { this.lastPropagatedData.delete(key) @@ -281,7 +286,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { // initial write of a freshly created entity, which captures an absent pre-image). if (!isServerData) this.journal?.recordEntity(key) // The caller may still hold a rekeyed temp id; the snapshot must carry the live one. - const newSnapshot = this.entitySnapshots.setData(key, this.resolveId(entityType, id), entityType, data, isServerData) + const newSnapshot = this.entitySnapshots.setData(key, this.resolveEntityId(entityType, id), entityType, data, isServerData) if (isServerData) { this.meta.setExistsOnServer(key, true) @@ -305,7 +310,7 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget { skipNotify: boolean = false, ): EntitySnapshot { const key = this.getEntityKey(entityType, id) - const newSnapshot = this.entitySnapshots.refreshServerData(key, this.resolveId(entityType, id), entityType, data) + const newSnapshot = this.entitySnapshots.refreshServerData(key, this.resolveEntityId(entityType, id), entityType, data) this.meta.setExistsOnServer(key, true) if (!skipNotify) { this.notifyEntitySubscribers(key) diff --git a/tests/react/hooks/useEntityList/persistRekey.test.tsx b/tests/react/hooks/useEntityList/persistRekey.test.tsx index 7d6ba3fb..4ec19397 100644 --- a/tests/react/hooks/useEntityList/persistRekey.test.tsx +++ b/tests/react/hooks/useEntityList/persistRekey.test.tsx @@ -5,6 +5,7 @@ import '../../../setup' import { describe, test, expect, afterEach } from 'bun:test' import { render, waitFor, act, cleanup } from '@testing-library/react' import React from 'react' +import { ActionDispatcher, EntityHandle, SchemaRegistry, SnapshotStore } from '@contember/bindx' import { BindxProvider, MockAdapter, @@ -15,7 +16,9 @@ import { useEntityList, usePersist, useSnapshotStore, + type EntityAccessor, } from '@contember/bindx-react' +import { ItemAccessorCache } from '../../../../packages/bindx-react/src/hooks/ItemAccessorCache.js' afterEach(() => { cleanup() @@ -44,6 +47,29 @@ const schema = defineSchema({ const authorDef = entityDef('Author') describe('useEntityList item accessor across temp -> persisted rekey', () => { + test('prefers an existing persisted-key accessor when cache keys collide', () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + const schemaRegistry = new SchemaRegistry(schema) + let redirectFrom = '' + let redirectTo = '' + const cache = new ItemAccessorCache( + id => EntityHandle.createRaw(id, 'Author', store, dispatcher, schemaRegistry), + id => id === redirectFrom ? redirectTo : id, + ) + const initial = cache.build([{ id: 'temp' }, { id: 'persisted' }]) + const tempAccessor = initial[0] + const persistedAccessor = initial[1] + if (!tempAccessor || !persistedAccessor) throw new Error('Expected both cached accessors') + + redirectFrom = 'temp' + redirectTo = 'persisted' + const after = cache.build([{ id: 'temp' }]) + + expect(after).toEqual([persistedAccessor]) + expect(after[0]).not.toBe(tempAccessor) + }) + test('reports the persisted id after $add + persist', async () => { const adapter = new MockAdapter({ Author: { @@ -54,6 +80,8 @@ describe('useEntityList item accessor across temp -> persisted rekey', () => { let addAuthor: (() => string) | null = null let persistAll: (() => Promise) | null = null let renderedIds: string[] = [] + let renderedItems: Array> = [] + let removeAuthor: ((id: string) => void) | null = null let readPersistedId: ((tempId: string) => string | null) | null = null function List(): React.ReactElement { @@ -64,6 +92,8 @@ describe('useEntityList item accessor across temp -> persisted rekey', () => { persistAll = () => persist.persistAll() if (authors.$status !== 'ready') return
addAuthor = () => authors.$add({ name: 'Fresh' }) + removeAuthor = id => authors.$remove(id) + renderedItems = authors.items renderedIds = authors.items.map(item => item.id) return (
    @@ -88,6 +118,8 @@ describe('useEntityList item accessor across temp -> persisted rekey', () => { }) await waitFor(() => expect(container.querySelectorAll('[data-testid="row"]').length).toBe(2)) expect(renderedIds[1]).toBe(tempId) + const draftAccessor = renderedItems[1] + if (!draftAccessor) throw new Error('Expected the added draft accessor') await act(async () => { await persistAll!() @@ -104,6 +136,12 @@ describe('useEntityList item accessor across temp -> persisted rekey', () => { }) // The id is user-facing: React keys, routing, `useEntity({ by: { id } })` on a detail view. expect(isTempId(renderedIds[1]!)).toBe(false) + expect(renderedItems[1]).toBe(draftAccessor) expect(container.querySelectorAll('[data-testid="row"]')[1]!.textContent).toBe('Fresh') + + act(() => { + removeAuthor!(tempId) + }) + await waitFor(() => expect(container.querySelectorAll('[data-testid="row"]').length).toBe(1)) }) }) diff --git a/tests/unit/events/eventEmitter.test.ts b/tests/unit/events/eventEmitter.test.ts index bd477eff..f5e36423 100644 --- a/tests/unit/events/eventEmitter.test.ts +++ b/tests/unit/events/eventEmitter.test.ts @@ -431,4 +431,73 @@ describe('EventEmitter', () => { expect(listener).toHaveBeenCalledTimes(1) }) }) + + describe('Scoped subscription rekey', () => { + const context = { + oldKey: 'Article:__temp_1', + newKey: 'Article:a-1', + oldKeyPrefix: 'Article:__temp_1:', + newKeyPrefix: 'Article:a-1:', + oldId: '__temp_1', + newId: 'a-1', + } + + test('migrates merged listeners once and preserves field/entity/global order', () => { + const order: string[] = [] + const shared = (): void => { order.push('shared') } + emitter.onField('field:changed', 'Article', '__temp_1', 'title', () => order.push('field')) + emitter.onEntity('field:changed', 'Article', '__temp_1', shared) + emitter.onEntity('field:changed', 'Article', 'a-1', shared) + emitter.onEntity('field:changed', 'Article', 'a-1', () => order.push('entity')) + emitter.on('field:changed', () => order.push('global')) + + emitter.rekey(context) + emitter.emit(createFieldChangedEvent({ entityId: 'a-1' })) + + expect(order).toEqual(['field', 'shared', 'entity', 'global']) + }) + + test('accepts old and new event scopes after rekey', () => { + const listener = mock(() => {}) + emitter.onEntity('field:changed', 'Article', '__temp_1', listener) + emitter.rekey(context) + + emitter.emit(createFieldChangedEvent({ entityId: '__temp_1' })) + emitter.emit(createFieldChangedEvent({ entityId: 'a-1' })) + + expect(listener).toHaveBeenCalledTimes(2) + }) + + test('pre-rekey unsubscribe removes the migrated callback after a merge', () => { + const listener = mock(() => {}) + const unsubscribe = emitter.onEntity('field:changed', 'Article', '__temp_1', listener) + emitter.onEntity('field:changed', 'Article', 'a-1', () => {}) + emitter.rekey(context) + + unsubscribe() + emitter.emit(createFieldChangedEvent({ entityId: 'a-1' })) + + expect(listener).not.toHaveBeenCalled() + }) + + test('migrates interceptors with order and old/new hasInterceptors lookup', () => { + const order: string[] = [] + emitter.interceptField('field:changing', 'Article', '__temp_1', 'title', () => { + order.push('field') + }) + emitter.interceptEntity('field:changing', 'Article', '__temp_1', () => { + order.push('entity') + }) + emitter.intercept('field:changing', () => { + order.push('global') + }) + + emitter.rekey(context) + expect(emitter.hasInterceptors('field:changing', 'Article', '__temp_1')).toBe(true) + expect(emitter.hasInterceptors('field:changing', 'Article', 'a-1')).toBe(true) + emitter.runInterceptorsSync(createFieldChangingEvent({ entityId: 'a-1' })) + + expect(order).toEqual(['field', 'entity', 'global']) + }) + }) }) diff --git a/tests/unit/handles/hasManyItemCache.test.ts b/tests/unit/handles/hasManyItemCache.test.ts index 392924f3..d692639d 100644 --- a/tests/unit/handles/hasManyItemCache.test.ts +++ b/tests/unit/handles/hasManyItemCache.test.ts @@ -207,6 +207,7 @@ describe('HasManyListHandle item handle cache', () => { const tempId = handle.add({ name: 'Fresh' }) expect(itemIds(handle)).toEqual(['t-1', tempId]) expect(handle.itemHandleCacheSize).toBe(2) + const draftAccessor = handle.getById(tempId) store.mapTempIdToPersistedId('Tag', tempId, 't-9') const after = handle.items @@ -216,10 +217,27 @@ describe('HasManyListHandle item handle cache', () => { expect(handle.itemHandleCacheSize).toBe(2) // A lookup by the dead temp id resolves to the persisted item's handle. expect(handle.getById(tempId)).toBe(at(after, 1)) + expect(at(after, 1)).toBe(draftAccessor) expect(handle.itemHandleCacheSize).toBe(2) expect(at(after, 1)[FIELD_REF_META].entityId).toBe('t-9') }) + test('an existing persisted-key accessor wins a cache collision', () => { + loadTags([{ id: 't-1', name: 'One' }]) + const handle = createListHandle() + expect(handle.items.length).toBe(1) + const tempId = handle.add({ name: 'Fresh' }) + const tempAccessor = handle.getById(tempId) + const persistedAccessor = handle.getById('t-9') + + store.mapTempIdToPersistedId('Tag', tempId, 't-9') + + expect(handle.getById(tempId)).toBe(persistedAccessor) + expect(handle.getById('t-9')).toBe(persistedAccessor) + expect(handle.getById('t-9')).not.toBe(tempAccessor) + expect(handle.itemHandleCacheSize).toBe(2) + }) + test('a never-persisted temp item keeps its accessor', () => { loadTags([{ id: 't-1', name: 'One' }]) const handle = createListHandle() diff --git a/tests/unit/handles/proxyEnumeration.test.ts b/tests/unit/handles/proxyEnumeration.test.ts index 47f9b07b..5015d118 100644 --- a/tests/unit/handles/proxyEnumeration.test.ts +++ b/tests/unit/handles/proxyEnumeration.test.ts @@ -1,9 +1,13 @@ import { describe, test, expect } from 'bun:test' import { ActionDispatcher, + BatchPersister, EntityHandle, + FIELD_REF_META, + MutationCollector, SchemaRegistry, SnapshotStore, + type BackendAdapter, type SchemaDefinition, type SelectionMeta, } from '@contember/bindx' @@ -11,10 +15,17 @@ import { interface TestArticle { id: string title: string + author: TestAuthor | null + tags: TestTag[] } +interface TestAuthor { id: string; name: string } +interface TestTag { id: string; name: string } + interface TestSchema { Article: TestArticle + Author: TestAuthor + Tag: TestTag [key: string]: object } @@ -24,8 +35,12 @@ const schemaDefinition: SchemaDefinition = { fields: { id: { type: 'scalar' }, title: { type: 'scalar' }, + author: { type: 'hasOne', target: 'Author' }, + tags: { type: 'hasMany', target: 'Tag', relationKind: 'manyHasMany' }, }, }, + Author: { fields: { id: { type: 'scalar' }, name: { type: 'scalar' } } }, + Tag: { fields: { id: { type: 'scalar' }, name: { type: 'scalar' } } }, }, } @@ -50,6 +65,62 @@ function createHandle(selected?: SelectionMeta): EntityHandle { ) } +const aliasedSelection: SelectionMeta = { + fields: new Map([ + ['data', { fieldName: 'title', alias: 'data', path: ['title'], isArray: false, isRelation: false }], + ['isDirty', { + fieldName: 'author', + alias: 'isDirty', + path: ['author'], + isArray: false, + isRelation: true, + nested: { fields: new Map([ + ['name', { fieldName: 'name', alias: 'name', path: ['name'], isArray: false, isRelation: false }], + ]) }, + }], + ['tags_filtered', { + fieldName: 'tags', + alias: 'tags_filtered', + path: ['tags'], + isArray: true, + isRelation: true, + nested: { fields: new Map([ + ['name', { fieldName: 'name', alias: 'name', path: ['name'], isArray: false, isRelation: false }], + ]) }, + }], + ['tags_auto_hash', { + fieldName: 'tags', + alias: 'tags_auto_hash', + path: ['tags'], + isArray: true, + isRelation: true, + nested: { fields: new Map([ + ['name', { fieldName: 'name', alias: 'name', path: ['name'], isArray: false, isRelation: false }], + ]) }, + }], + ]), +} + +function createAliasedHandle(): EntityHandle { + const store = new SnapshotStore() + store.setEntityData('Article', 'a-1', { + id: 'a-1', + data: 'Aliased title', + isDirty: { id: 'author-1', name: 'Ada' }, + tags_filtered: [{ id: 'tag-1', name: 'One' }], + tags_auto_hash: [{ id: 'tag-2', name: 'Two' }], + }, true) + return EntityHandle.createRaw( + 'a-1', + 'Article', + store, + new ActionDispatcher(store), + new SchemaRegistry(schemaDefinition), + undefined, + aliasedSelection, + ) +} + /** * Enumerating an entity accessor must list `id` and the selected fields only — never * the handle's instance fields, which a generic walker would then read as entity @@ -76,4 +147,75 @@ describe('entity accessor enumeration', () => { const accessor = EntityHandle.wrapProxy(createHandle()) expect(Object.keys(accessor)).toEqual(['id']) }) + + test('enumerates aliases and every scalar/relation value is readable', () => { + const accessor = EntityHandle.wrapProxy(createAliasedHandle()) + + expect(Object.keys(accessor)).toEqual(['id', 'data', 'isDirty', 'tags_filtered', 'tags_auto_hash']) + expect(Reflect.get(accessor, 'data').value).toBe('Aliased title') + expect(Reflect.get(accessor, 'data')[FIELD_REF_META].fieldName).toBe('title') + expect(Reflect.get(accessor, 'isDirty').name.value).toBe('Ada') + expect(Reflect.get(accessor, 'isDirty')[FIELD_REF_META].fieldName).toBe('author') + expect(Reflect.get(accessor, 'tags_filtered').items[0].name.value).toBe('One') + expect(Reflect.get(accessor, 'tags_filtered')[FIELD_REF_META].fieldName).toBe('tags') + expect(Reflect.get(accessor, 'tags_auto_hash').items[0].name.value).toBe('Two') + for (const value of Object.values(accessor)) expect(value).toBeDefined() + }) + + test('writes and reconciles an aliased scalar through its schema field', async () => { + const store = new SnapshotStore() + store.setEntityData('Article', 'a-1', { id: 'a-1', data: 'Aliased title' }, true) + const dispatcher = new ActionDispatcher(store) + const schema = new SchemaRegistry(schemaDefinition) + const handle = EntityHandle.createRaw( + 'a-1', + 'Article', + store, + dispatcher, + schema, + undefined, + aliasedSelection, + ) + const title = handle.field('title', 'data') + let changes = 0 + title.onChange(() => { changes++ }) + + expect(title.value).toBe('Aliased title') + title.setValue('Updated title') + + const data = store.getEntitySnapshot>('Article', 'a-1')?.data + expect(title.value).toBe('Updated title') + expect(data?.['data']).toBe('Aliased title') + expect(data?.['title']).toBe('Updated title') + expect(title.isDirty).toBe(true) + expect(store.getDirtyFields('Article', 'a-1')).toContain('title') + expect(changes).toBe(1) + const collector = new MutationCollector(store, schema) + expect(collector.collectUpdateData('Article', 'a-1')).toEqual({ + title: 'Updated title', + }) + + const adapter: BackendAdapter = { + query: () => Promise.resolve([]), + persist: () => Promise.resolve({ + ok: true, + data: { id: 'a-1', data: 'Aliased title', title: 'Updated title' }, + }), + create: (_entityType, createData) => Promise.resolve({ ok: true, data: createData }), + delete: () => Promise.resolve({ ok: true }), + } + await new BatchPersister(adapter, store, dispatcher, { + mutationCollector: collector, + }).persistAll() + + expect(title.value).toBe('Updated title') + expect(title.serverValue).toBe('Updated title') + expect(title.isDirty).toBe(false) + expect(store.getDirtyFields('Article', 'a-1')).toEqual([]) + }) + + test('keeps unselected fields unavailable', () => { + const accessor = EntityHandle.wrapProxy(createAliasedHandle()) + expect(() => Reflect.get(accessor, 'title')).toThrow('unfetched field') + }) }) diff --git a/tests/unit/handles/rekeyLifecycle.test.ts b/tests/unit/handles/rekeyLifecycle.test.ts new file mode 100644 index 00000000..39c609cf --- /dev/null +++ b/tests/unit/handles/rekeyLifecycle.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, test } from 'bun:test' +import { + ActionDispatcher, + BatchPersister, + EntityHandle, + FIELD_REF_META, + MutationCollector, + SchemaRegistry, + SnapshotStore, + type BackendAdapter, + type EntityPersistedEvent, + type EntityPersistingEvent, + type SchemaDefinition, +} from '@contember/bindx' + +interface Tag { + id: string + name: string +} + +interface Profile { + id: string + name: string + tags: Tag[] +} + +interface Article { + id: string + title: string + profile: Profile | null + tags: Tag[] +} + +interface TestSchema { + Article: Article + Profile: Profile + Tag: Tag + [key: string]: object +} + +const schemaDefinition: SchemaDefinition = { + entities: { + Article: { + fields: { + id: { type: 'scalar' }, + title: { type: 'scalar' }, + profile: { type: 'hasOne', target: 'Profile' }, + tags: { type: 'hasMany', target: 'Tag', relationKind: 'manyHasMany' }, + }, + }, + Profile: { + fields: { + id: { type: 'scalar' }, + name: { type: 'scalar' }, + tags: { type: 'hasMany', target: 'Tag', relationKind: 'manyHasMany' }, + }, + }, + Tag: { + fields: { + id: { type: 'scalar' }, + name: { type: 'scalar' }, + }, + }, + }, +} + +describe('live handles across temp id rekey', () => { + test('entity and cached field handles report and write the canonical identity', () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + const schema = new SchemaRegistry(schemaDefinition) + const tempId = store.createEntity('Article', { title: 'Draft' }) + const article = EntityHandle.create
    (tempId, 'Article', store, dispatcher, schema) + const title = article.title + let changes = 0 + let intercepts = 0 + title.onChange(() => { changes++ }) + title.onChanging(() => { intercepts++ }) + + store.mapTempIdToPersistedId('Article', tempId, 'article-1') + title.setValue('Published') + + expect(String(article.id)).toBe('article-1') + expect(article[FIELD_REF_META].entityId).toBe('article-1') + expect(title[FIELD_REF_META].entityId).toBe('article-1') + expect(store.getEntitySnapshot
    ('Article', 'article-1')?.data.title).toBe('Published') + expect(changes).toBe(1) + expect(intercepts).toBe(1) + }) + + test('entity lifecycle scopes survive rekey for every dispatcher attached to the store', () => { + const store = new SnapshotStore() + const firstDispatcher = new ActionDispatcher(store) + const secondDispatcher = new ActionDispatcher(store) + const schema = new SchemaRegistry(schemaDefinition) + const tempId = store.createEntity('Article', { title: 'Draft' }) + const first = EntityHandle.create
    (tempId, 'Article', store, firstDispatcher, schema) + const second = EntityHandle.create
    (tempId, 'Article', store, secondDispatcher, schema) + let persisted = 0 + let persisting = 0 + first.$onPersisted(() => { persisted++ }) + second.$onPersisted(() => { persisted++ }) + first.$interceptPersisting(() => { persisting++ }) + second.$interceptPersisting(() => { persisting++ }) + + store.mapTempIdToPersistedId('Article', tempId, 'article-1') + const persistedEvent: EntityPersistedEvent = { + type: 'entity:persisted', + timestamp: Date.now(), + entityType: 'Article', + entityId: 'article-1', + isNew: true, + persistedId: 'article-1', + } + const persistingEvent: EntityPersistingEvent = { + type: 'entity:persisting', + timestamp: Date.now(), + entityType: 'Article', + entityId: 'article-1', + isNew: false, + } + firstDispatcher.getEventEmitter().emit(persistedEvent) + secondDispatcher.getEventEmitter().emit(persistedEvent) + firstDispatcher.getEventEmitter().runInterceptorsSync(persistingEvent) + secondDispatcher.getEventEmitter().runInterceptorsSync(persistingEvent) + + expect(persisted).toBe(2) + expect(persisting).toBe(2) + }) + + test('has-one and has-many accessors preserve identity when their targets rekey', () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + const schema = new SchemaRegistry(schemaDefinition) + store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'Article' }, true) + const profileTempId = store.createEntity('Profile', { name: 'Draft profile' }) + const tagTempId = store.createEntity('Tag', { name: 'Draft tag' }) + store.setRelation('Article', 'article-1', 'profile', { + currentId: profileTempId, + state: 'connected', + }) + store.getOrCreateHasMany('Article', 'article-1', 'tags', []) + store.planHasManyConnection('Article', 'article-1', 'tags', tagTempId) + const article = EntityHandle.create
    ('article-1', 'Article', store, dispatcher, schema) + const profile = article.profile + const tags = article.tags + const profileBefore = profile.$entity + const tagBefore = tags.getById(tagTempId) + + store.mapTempIdToPersistedId('Profile', profileTempId, 'profile-1') + store.mapTempIdToPersistedId('Tag', tagTempId, 'tag-1') + + expect(profile.$entity).toBe(profileBefore) + expect(String(profileBefore.id)).toBe('profile-1') + expect(profile[FIELD_REF_META].entityId).toBe('article-1') + expect(tags.getById(tagTempId)).toBe(tagBefore) + expect(tags.getById('tag-1')).toBe(tagBefore) + expect(String(tagBefore.id)).toBe('tag-1') + expect(tags[FIELD_REF_META].entityId).toBe('article-1') + }) + + test('a materialized placeholder follows its persisted id for reads and writes', () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + const schema = new SchemaRegistry(schemaDefinition) + store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'Article' }, true) + const article = EntityHandle.create
    ('article-1', 'Article', store, dispatcher, schema) + const placeholder = article.profile.$entity + const name = placeholder.name + name.setValue('Before materialization') + placeholder.tags.add({ name: 'Child' }) + const profileTempId = store.getRelation('Article', 'article-1', 'profile')?.currentId + if (!profileTempId) throw new Error('Expected the placeholder to materialize') + + store.mapTempIdToPersistedId('Profile', profileTempId, 'profile-1') + name.setValue('After persistence') + + expect(String(placeholder.id)).toBe('profile-1') + expect(placeholder[FIELD_REF_META].entityId).toBe('profile-1') + expect(name[FIELD_REF_META].entityId).toBe('profile-1') + expect(placeholder.tags[FIELD_REF_META].entityId).toBe('profile-1') + expect(store.getEntitySnapshot('Profile', 'profile-1')?.data.name).toBe('After persistence') + }) + + test('placeholder lifecycle subscriptions activate on materialization and survive rekey', () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + const schema = new SchemaRegistry(schemaDefinition) + store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'Article' }, true) + const article = EntityHandle.create
    ('article-1', 'Article', store, dispatcher, schema) + const placeholder = article.profile.$entity + let persisted = 0 + let persisting = 0 + placeholder.$onPersisted(() => { persisted++ }) + placeholder.$interceptPersisting(() => { persisting++ }) + placeholder.tags.add({ name: 'Child' }) + const profileTempId = store.getRelation('Article', 'article-1', 'profile')?.currentId + if (!profileTempId) throw new Error('Expected the placeholder to materialize') + const emitter = dispatcher.getEventEmitter() + + emitter.runInterceptorsSync({ + type: 'entity:persisting', + timestamp: Date.now(), + entityType: 'Profile', + entityId: profileTempId, + isNew: true, + }) + store.mapTempIdToPersistedId('Profile', profileTempId, 'profile-1') + emitter.runInterceptorsSync({ + type: 'entity:persisting', + timestamp: Date.now(), + entityType: 'Profile', + entityId: 'profile-1', + isNew: false, + }) + emitter.emit({ + type: 'entity:persisted', + timestamp: Date.now(), + entityType: 'Profile', + entityId: 'profile-1', + isNew: true, + persistedId: 'profile-1', + }) + + expect(persisting).toBe(2) + expect(persisted).toBe(1) + }) + + test('a held scalar placeholder follows collector materialization and persistence', async () => { + const store = new SnapshotStore() + const dispatcher = new ActionDispatcher(store) + const schema = new SchemaRegistry(schemaDefinition) + const adapter: BackendAdapter = { + query: () => Promise.resolve([]), + persist: (_entityType, entityId) => Promise.resolve({ + ok: true, + data: { + id: entityId, + title: 'Article', + profile: { id: 'profile-1', name: 'Before persistence' }, + }, + }), + create: (_entityType, data) => Promise.resolve({ + ok: true, + data: { ...data, id: 'profile-1' }, + }), + delete: () => Promise.resolve({ ok: true }), + } + const persister = new BatchPersister(adapter, store, dispatcher, { + mutationCollector: new MutationCollector(store, schema), + }) + store.setEntityData('Article', 'article-1', { id: 'article-1', title: 'Article' }, true) + const article = EntityHandle.create
    ('article-1', 'Article', store, dispatcher, schema) + const placeholder = article.profile.$entity + const name = placeholder.name + let persisted = 0 + let persisting = 0 + placeholder.$onPersisted(() => { persisted++ }) + placeholder.$interceptPersisting(() => { persisting++ }) + name.setValue('Before persistence') + + await persister.persistAll() + + expect(String(placeholder.id)).toBe('profile-1') + expect(placeholder[FIELD_REF_META].entityId).toBe('profile-1') + expect(name[FIELD_REF_META].entityId).toBe('profile-1') + expect(name.value).toBe('Before persistence') + expect(persisted).toBe(1) + expect(persisting).toBe(1) + + dispatcher.getEventEmitter().runInterceptorsSync({ + type: 'entity:persisting', + timestamp: Date.now(), + entityType: 'Profile', + entityId: 'profile-1', + isNew: false, + }) + expect(persisting).toBe(2) + }) +}) diff --git a/tests/unit/store/rekeyOrchestrator.test.ts b/tests/unit/store/rekeyOrchestrator.test.ts index 4d4fe573..c913e647 100644 --- a/tests/unit/store/rekeyOrchestrator.test.ts +++ b/tests/unit/store/rekeyOrchestrator.test.ts @@ -63,6 +63,19 @@ describe('RekeyOrchestrator', () => { expect(calls).toEqual(['a', 'b', 'c']) }) + test('registers dynamic participants once and keeps their order', () => { + const calls: string[] = [] + const initial: Rekeyable = { rekey: () => calls.push('initial') } + const dynamic: Rekeyable = { rekey: () => calls.push('dynamic') } + const o = new RekeyOrchestrator([initial]) + + o.registerParticipant(dynamic) + o.registerParticipant(dynamic) + o.rekey('Article', TEMP, SERVER) + + expect(calls).toEqual(['initial', 'dynamic']) + }) + test('rekey passes a fully-derived context to participants', () => { let captured: RekeyContext | undefined const o = new RekeyOrchestrator([{ rekey: ctx => { captured = ctx } }]) From a8df7c485de85d56982aadcd9a4d6ae4b2284801 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 24 Aug 2026 17:00:35 +0200 Subject: [PATCH 53/55] fix(bindx): carry fullPath on live field refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extractFieldName` promises it "works in both collector and runtime proxies", and `FieldRefMeta.fullPath` is documented as the absolute chain from the root entity — but only the collector proxy ever set it. Live handles left it undefined, so `it.author.name` resolved to `"author.name"` during collection and `"name"` at runtime. Since "bind relation cells to live rows" re-analyzes the children callback per row, that asymmetry now fails a whole grid twice over: the positional guard reports a column-order change that never happened, and with the guard removed the runtime cell closure carries the bare leaf, so `accessField` looks the field up on the row entity and throws `UnfetchedFieldError`. Thread the path where `selection` already flows: an entity knows its segments from the query root, a has-one appends its own and hands the chain to whatever it resolves to, and a has-many restarts it for items — mirroring the collector proxy. `fieldName` and `path` stay the last segment, so store keys and selection metadata are untouched; `fullPath` has exactly one consumer. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EmqgPtZvAfpvWKymfFZCEX --- packages/bindx/src/handles/EntityHandle.ts | 14 +++- packages/bindx/src/handles/FieldHandle.ts | 9 ++- .../bindx/src/handles/HasManyListHandle.ts | 10 ++- packages/bindx/src/handles/HasOneHandle.ts | 12 +++- .../bindx/src/handles/PlaceholderHandle.ts | 16 ++++- .../nestedFieldColumnRuntimeRow.test.tsx | 65 +++++++++++++++++++ 6 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 tests/react/dataview/nestedFieldColumnRuntimeRow.test.tsx diff --git a/packages/bindx/src/handles/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index 51cdc211..2fdd86c1 100644 --- a/packages/bindx/src/handles/EntityHandle.ts +++ b/packages/bindx/src/handles/EntityHandle.ts @@ -80,6 +80,9 @@ export class EntityHandle extends Enti private readonly schema: SchemaRegistry, brands?: Set, private readonly selection?: SelectionMeta, + // Segments from the query root down to this entity. Empty at a root and at + // every has-many item, so item chains restart their own `fullPath`. + private readonly entityPath: readonly string[] = [], ) { super(entityType, id, store, dispatcher) this.__brands = brands @@ -93,8 +96,9 @@ export class EntityHandle extends Enti schema: SchemaRegistry, brands?: Set, selection?: SelectionMeta, + entityPath?: readonly string[], ): EntityAccessor { - return EntityHandle.wrapProxy(new EntityHandle(id, entityType, store, dispatcher, schema, brands, selection)) + return EntityHandle.wrapProxy(new EntityHandle(id, entityType, store, dispatcher, schema, brands, selection, entityPath)) } static createRaw( @@ -105,8 +109,9 @@ export class EntityHandle extends Enti schema: SchemaRegistry, brands?: Set, selection?: SelectionMeta, + entityPath?: readonly string[], ): EntityHandle { - return new EntityHandle(id, entityType, store, dispatcher, schema, brands, selection) + return new EntityHandle(id, entityType, store, dispatcher, schema, brands, selection, entityPath) } static wrapProxy(handle: EntityHandle): EntityAccessor { @@ -287,6 +292,7 @@ export class EntityHandle extends Enti enumName, columnType, [dataFieldName], + [...this.entityPath, schemaFieldName], ) const proxy = FieldHandle.wrapProxy(raw) this.fieldHandleCache.set(cacheKey, { raw, proxy } as CachedFieldHandle) @@ -323,6 +329,7 @@ export class EntityHandle extends Enti undefined, nestedSelection, dataFieldName, + [...this.entityPath, fieldName], ) const proxy = HasOneHandle.wrapProxy(raw) this.relationHandleCache.set(cacheKey, { raw, proxy }) @@ -363,6 +370,7 @@ export class EntityHandle extends Enti this.__brands, effectiveAlias, nestedSelection, + [...this.entityPath, fieldName], ) this.relationHandleCache.set(cacheKey, { raw: handle as unknown as RelationHandleRaw, proxy: handle }) @@ -398,6 +406,8 @@ export class EntityHandle extends Enti this.schema, undefined, undefined, + undefined, + [...this.entityPath, fieldName], ) const proxy = HasOneHandle.wrapProxy(raw) this.relationHandleCache.set(cacheKey, { raw, proxy }) diff --git a/packages/bindx/src/handles/FieldHandle.ts b/packages/bindx/src/handles/FieldHandle.ts index 7fccb951..5ad3346c 100644 --- a/packages/bindx/src/handles/FieldHandle.ts +++ b/packages/bindx/src/handles/FieldHandle.ts @@ -38,6 +38,8 @@ export class FieldHandle extends EntityRelatedHandle { private readonly _enumName?: string, private readonly _columnType?: string, private readonly dataFieldPath: string[] = fieldPath, + // Absolute chain from the query root; see FieldRefMeta.fullPath. + private readonly _fullPath: readonly string[] = fieldPath, ) { super(entityType, entityId, store, dispatcher) } @@ -51,8 +53,9 @@ export class FieldHandle extends EntityRelatedHandle { enumName?: string, columnType?: string, dataFieldPath?: string[], + fullPath?: readonly string[], ): FieldAccessor { - return createAliasProxy, FieldAccessor>(new FieldHandle(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType, dataFieldPath)) + return createAliasProxy, FieldAccessor>(new FieldHandle(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType, dataFieldPath, fullPath)) } static createRaw( @@ -64,8 +67,9 @@ export class FieldHandle extends EntityRelatedHandle { enumName?: string, columnType?: string, dataFieldPath?: string[], + fullPath?: readonly string[], ): FieldHandle { - return new FieldHandle(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType, dataFieldPath) + return new FieldHandle(entityType, entityId, fieldPath, store, dispatcher, enumName, columnType, dataFieldPath, fullPath) } static wrapProxy(handle: FieldHandle): FieldAccessor { @@ -86,6 +90,7 @@ export class FieldHandle extends EntityRelatedHandle { isRelation: false, enumName: this._enumName, columnType: this._columnType, + fullPath: this._fullPath, } } diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index 8a4c6103..52f7964c 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -76,6 +76,9 @@ export class HasManyListHandle, alias?: string, private readonly selection?: SelectionMeta, + // Segments from the query root down to this relation. Items do NOT inherit it: + // a has-many restarts the chain, mirroring the collector proxy. + private readonly relationPath: readonly string[] = [fieldName], ) { super(parentEntityType, parentEntityId, store, dispatcher) this.__brands = brands @@ -93,9 +96,10 @@ export class HasManyListHandle, alias?: string, selection?: SelectionMeta, + relationPath?: readonly string[], ): HasManyAccessor { return createAliasProxy, HasManyAccessor>( - HasManyListHandle.createRaw(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection), + HasManyListHandle.createRaw(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection, relationPath), ) } @@ -114,8 +118,9 @@ export class HasManyListHandle, alias?: string, selection?: SelectionMeta, + relationPath?: readonly string[], ): HasManyListHandle { - return new HasManyListHandle(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection) + return new HasManyListHandle(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection, relationPath) } /** @@ -131,6 +136,7 @@ export class HasManyListHandle brands?: Set, private readonly selection?: SelectionMeta, private readonly dataFieldName: string = fieldName, + // Segments from the query root down to this relation; also the entity path + // of whatever it resolves to. See FieldRefMeta.fullPath. + private readonly relationPath: readonly string[] = [fieldName], ) { super(parentEntityType, parentEntityId, store, dispatcher) this.__brands = brands @@ -82,8 +85,9 @@ export class HasOneHandle brands?: Set, selection?: SelectionMeta, dataFieldName?: string, + relationPath?: readonly string[], ): HasOneAccessor { - return HasOneHandle.wrapProxy(new HasOneHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection, dataFieldName)) + return HasOneHandle.wrapProxy(new HasOneHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection, dataFieldName, relationPath)) } static createRaw( @@ -97,8 +101,9 @@ export class HasOneHandle brands?: Set, selection?: SelectionMeta, dataFieldName?: string, + relationPath?: readonly string[], ): HasOneHandle { - return new HasOneHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection, dataFieldName) + return new HasOneHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema, brands, selection, dataFieldName, relationPath) } static wrapProxy(handle: HasOneHandle): HasOneAccessor { @@ -122,6 +127,7 @@ export class HasOneHandle isArray: false, isRelation: true, targetType: this.targetType, + fullPath: this.relationPath, } } @@ -321,6 +327,7 @@ export class HasOneHandle this.schema, this.__brands, this.selection, + this.relationPath, ) this.entityHandleCacheProxy = EntityHandle.wrapProxy(this.entityHandleCacheRaw) } @@ -337,6 +344,7 @@ export class HasOneHandle this.dispatcher, this.schema, this.__brands, + this.relationPath, ) this.placeholderCacheProxy = PlaceholderHandle.wrapProxy(this.placeholderCacheRaw) } diff --git a/packages/bindx/src/handles/PlaceholderHandle.ts b/packages/bindx/src/handles/PlaceholderHandle.ts index 172f9586..0e08df76 100644 --- a/packages/bindx/src/handles/PlaceholderHandle.ts +++ b/packages/bindx/src/handles/PlaceholderHandle.ts @@ -56,6 +56,9 @@ export class PlaceholderHandle, + // Segments from the query root down to the relation this placeholder stands in + // for, so its field refs carry the same `fullPath` a connected entity would. + private readonly relationPath: readonly string[] = [fieldName], ) { this.__brands = brands this.placeholderId = generatePlaceholderId() @@ -120,8 +123,9 @@ export class PlaceholderHandle, + relationPath?: readonly string[], ): EntityAccessor { - return PlaceholderHandle.wrapProxy(new PlaceholderHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema ?? null, brands)) + return PlaceholderHandle.wrapProxy(new PlaceholderHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema ?? null, brands, relationPath)) } static createRaw( @@ -133,8 +137,9 @@ export class PlaceholderHandle, + relationPath?: readonly string[], ): PlaceholderHandle { - return new PlaceholderHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema ?? null, brands) + return new PlaceholderHandle(parentEntityType, parentEntityId, fieldName, targetType, store, dispatcher, schema ?? null, brands, relationPath) } static wrapProxy(handle: PlaceholderHandle): EntityAccessor { @@ -290,6 +295,9 @@ export class PlaceholderHandle {} diff --git a/tests/react/dataview/nestedFieldColumnRuntimeRow.test.tsx b/tests/react/dataview/nestedFieldColumnRuntimeRow.test.tsx new file mode 100644 index 00000000..615e9e25 --- /dev/null +++ b/tests/react/dataview/nestedFieldColumnRuntimeRow.test.tsx @@ -0,0 +1,65 @@ +/** + * Regression: a DataGrid column whose `field` is a NESTED ref (`it.hasOne.scalar`) + * must survive the runtime column re-analysis added in "bind relation cells to + * live rows". + * + * The collection pass walks a collector proxy, whose `FIELD_REF_META` carries + * `fullPath` — so `extractFieldName` returns the dotted `"author.name"`. The + * runtime pass walks a live `FieldHandle`, which has no `fullPath`, so the same + * helper returns the bare `"name"`. The positional guard compares those two and + * throws, killing the whole grid route. + */ +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { BindxProvider, MockAdapter } from '@contember/bindx-react' +import { DataGrid } from '@contember/bindx-dataview' +import { DataGridTextColumn } from '@contember/bindx-ui' +import { schema, testSchema } from '../../shared/index.js' +import { TestTable, queryByTestId } from './helpers.js' + +afterEach(() => { + cleanup() +}) + +describe('nested field ref column', () => { + test('renders a column over a nested has-one scalar', async () => { + const adapter = new MockAdapter({ + Article: { + 'article-1': { + id: 'article-1', + title: 'First', + content: '', + author: { id: 'author-1', name: 'Alice', email: 'alice@example.com' }, + tags: [], + }, + }, + Author: { + 'author-1': { id: 'author-1', name: 'Alice', email: 'alice@example.com' }, + }, + Tag: {}, + Location: {}, + }, { delay: 0 }) + + const { container } = render( + + + {it => ( + <> + + + + + )} + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-table')).not.toBeNull() + }) + + expect(container.textContent).toContain('Alice') + }) +}) From 4702d28c68490607d39906474a83daa47a1bc60c Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 24 Aug 2026 17:14:25 +0200 Subject: [PATCH 54/55] refactor(bindx-dataview): extract field-ref helpers into a leaf module `extractFieldName` and friends lived in `columns.tsx`, so anything that needed to derive a field key had to import the whole column/JSX stack. Move them to `fieldRef.ts`, and move `getRelatedAccessor` next to the `accessField` it wraps in `columnTypes.ts`. That also breaks the `columns.tsx` <-> `createRelationColumn.tsx` import cycle, which was already close to biting on evaluation order. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EmqgPtZvAfpvWKymfFZCEX --- packages/bindx-dataview/src/columnTypes.ts | 8 +++ packages/bindx-dataview/src/columns.tsx | 56 +------------------ packages/bindx-dataview/src/createColumn.ts | 2 +- .../src/createRelationColumn.tsx | 4 +- packages/bindx-dataview/src/fieldRef.ts | 53 ++++++++++++++++++ packages/bindx-dataview/src/index.ts | 9 ++- 6 files changed, 72 insertions(+), 60 deletions(-) create mode 100644 packages/bindx-dataview/src/fieldRef.ts diff --git a/packages/bindx-dataview/src/columnTypes.ts b/packages/bindx-dataview/src/columnTypes.ts index 2ce1c0f8..51642523 100644 --- a/packages/bindx-dataview/src/columnTypes.ts +++ b/packages/bindx-dataview/src/columnTypes.ts @@ -73,6 +73,14 @@ export function accessField(accessor: EntityAccessor, fieldName: string) return current } +/** + * Access a related entity accessor from a parent row accessor by field name. + * EntityAccessor is a Proxy — bracket notation triggers the get trap. + */ +export function getRelatedAccessor(item: EntityAccessor, fieldName: string): EntityAccessor | null { + return accessField(item, fieldName) as EntityAccessor | null +} + function extractScalarValue(accessor: EntityAccessor, fieldName: string): T | null { const fieldRef = accessField(accessor, fieldName) as { value?: unknown } | null if (!fieldRef || typeof fieldRef !== 'object') return null diff --git a/packages/bindx-dataview/src/columns.tsx b/packages/bindx-dataview/src/columns.tsx index ac0d4c5c..d2dcd024 100644 --- a/packages/bindx-dataview/src/columns.tsx +++ b/packages/bindx-dataview/src/columns.tsx @@ -12,8 +12,8 @@ import React, { ReactNode } from 'react' import type { FieldRef, HasOneRef, HasManyRef, FilterHandler, FilterArtifact, EntityAccessor, EnumFilterArtifact, EnumListFilterArtifact, SelectionMeta } from '@contember/bindx' import { SelectionScope } from '@contember/bindx' -import { FIELD_REF_META, createCollectorProxy } from '@contember/bindx-react' import { createColumn, createColumnStaticRender, type ColumnRenderProps } from './createColumn.js' +import { extractFieldName } from './fieldRef.js' import { accessField } from './columnTypes.js' import { createRelationColumn, hasOneCellConfig, hasManyCellConfig, type RelationColumnProps } from './createRelationColumn.jsx' import { @@ -37,60 +37,6 @@ import { ColumnLeaf, type ColumnLeafProps } from './columnLeaf.js' export type ColumnMeta = ColumnLeafProps -// ============================================================================ -// Extraction Helpers -// ============================================================================ - -interface FieldRefMetaCarrier { - readonly [FIELD_REF_META]: { - readonly entityType: string - readonly fieldName: string - readonly fullPath?: readonly string[] - readonly isArray: boolean - readonly isRelation: boolean - readonly enumName?: string - } -} - -/** Type guard: checks if a value carries FIELD_REF_META symbol. */ -export function hasFieldRefMeta(ref: unknown): ref is FieldRefMetaCarrier { - return ref != null && typeof ref === 'object' && FIELD_REF_META in ref -} - -/** - * Extract the dotted field path from a field ref (works in both collector and - * runtime proxies). For fields reached through has-one relations - * (e.g. `it.author.name`) this is the full dotted path (`"author.name"`) so the - * DataGrid can build correct nested where/orderBy clauses; for top-level fields - * it is simply the field name (`"title"`). - */ -export function extractFieldName(ref: unknown): string | null { - if (!hasFieldRefMeta(ref)) return null - const meta = ref[FIELD_REF_META] - const fullPath = meta.fullPath - return fullPath && fullPath.length > 0 ? fullPath.join('.') : meta.fieldName -} - -/** Extract enum name from a field ref (if field is an enum). */ -export function extractEnumName(ref: unknown): string | undefined { - return hasFieldRefMeta(ref) ? ref[FIELD_REF_META].enumName : undefined -} - -/** Extract related entity type name from a relation field ref. */ -export function extractRelatedEntityName(ref: unknown): string | null { - if (!hasFieldRefMeta(ref)) return null - const meta = ref[FIELD_REF_META] - return meta.entityType || null -} - -/** - * Access a related entity accessor from a parent row accessor by field name. - * EntityAccessor is a Proxy — bracket notation triggers the get trap. - */ -export function getRelatedAccessor(item: EntityAccessor, fieldName: string): EntityAccessor | null { - return accessField(item, fieldName) as EntityAccessor | null -} - // ============================================================================ // Default Cell Renderers // ============================================================================ diff --git a/packages/bindx-dataview/src/createColumn.ts b/packages/bindx-dataview/src/createColumn.ts index 9cc6583e..ce8879cc 100644 --- a/packages/bindx-dataview/src/createColumn.ts +++ b/packages/bindx-dataview/src/createColumn.ts @@ -10,7 +10,7 @@ import React from 'react' import type { FieldRef, FilterArtifact, FilterHandler, EntityAccessor } from '@contember/bindx' import type { ColumnTypeDef } from './columnTypes.js' import { ColumnLeaf, type ColumnLeafProps } from './columnLeaf.js' -import { extractFieldName, extractEnumName } from './columns.js' +import { extractFieldName, extractEnumName } from './fieldRef.js' // ============================================================================ // Render Props diff --git a/packages/bindx-dataview/src/createRelationColumn.tsx b/packages/bindx-dataview/src/createRelationColumn.tsx index b4082054..1ddc6934 100644 --- a/packages/bindx-dataview/src/createRelationColumn.tsx +++ b/packages/bindx-dataview/src/createRelationColumn.tsx @@ -15,7 +15,6 @@ import type { FieldRef, FilterArtifact, FilterHandler, EntityAccessor, Selection import { SelectionScope } from '@contember/bindx' import { createCollectorProxy, collectSelection as collectJsxSelection, SCOPE_REF } from '@contember/bindx-react' import type { ColumnTypeDef } from './columnTypes.js' -import { accessField } from './columnTypes.js' /** If a render result is a FieldRef-like object with `.value`, extract the string value. */ function unwrapRenderResult(result: React.ReactNode): React.ReactNode { @@ -26,7 +25,8 @@ function unwrapRenderResult(result: React.ReactNode): React.ReactNode { return result } import { ColumnLeaf, type ColumnLeafProps } from './columnLeaf.js' -import { extractFieldName, extractRelatedEntityName, getRelatedAccessor } from './columns.js' +import { extractFieldName, extractRelatedEntityName } from './fieldRef.js' +import { accessField, getRelatedAccessor } from './columnTypes.js' // ============================================================================ // Config diff --git a/packages/bindx-dataview/src/fieldRef.ts b/packages/bindx-dataview/src/fieldRef.ts new file mode 100644 index 00000000..40f7dc5d --- /dev/null +++ b/packages/bindx-dataview/src/fieldRef.ts @@ -0,0 +1,53 @@ +/** + * Field-ref introspection helpers shared by column definitions and DataView state. + * + * Kept in a leaf module so state hooks can derive field keys without pulling in + * the whole column/JSX stack. + */ + +import { FIELD_REF_META } from '@contember/bindx' + +interface FieldRefMetaCarrier { + readonly [FIELD_REF_META]: { + readonly entityType: string + readonly fieldName: string + readonly fullPath?: readonly string[] + readonly isArray: boolean + readonly isRelation: boolean + readonly enumName?: string + } +} + +/** Type guard: checks if a value carries FIELD_REF_META symbol. */ +export function hasFieldRefMeta(ref: unknown): ref is FieldRefMetaCarrier { + return ref != null && typeof ref === 'object' && FIELD_REF_META in ref +} + +/** + * Extract the dotted field path from a field ref (works in both collector and + * runtime proxies). For fields reached through has-one relations + * (e.g. `it.author.name`) this is the full dotted path (`"author.name"`) so the + * DataGrid can build correct nested where/orderBy clauses; for top-level fields + * it is simply the field name (`"title"`). + * + * This is the canonical sorting/filtering key for a column — anything that keys + * state by a field ref must go through here, or nested columns silently miss. + */ +export function extractFieldName(ref: unknown): string | null { + if (!hasFieldRefMeta(ref)) return null + const meta = ref[FIELD_REF_META] + const fullPath = meta.fullPath + return fullPath && fullPath.length > 0 ? fullPath.join('.') : meta.fieldName +} + +/** Extract enum name from a field ref (if field is an enum). */ +export function extractEnumName(ref: unknown): string | undefined { + return hasFieldRefMeta(ref) ? ref[FIELD_REF_META].enumName : undefined +} + +/** Extract related entity type name from a relation field ref. */ +export function extractRelatedEntityName(ref: unknown): string | null { + if (!hasFieldRefMeta(ref)) return null + const meta = ref[FIELD_REF_META] + return meta.entityType || null +} diff --git a/packages/bindx-dataview/src/index.ts b/packages/bindx-dataview/src/index.ts index 6bddba31..529afeb5 100644 --- a/packages/bindx-dataview/src/index.ts +++ b/packages/bindx-dataview/src/index.ts @@ -89,11 +89,16 @@ export { type DataGridActionColumnProps, type DataGridColumnProps, type ColumnMeta, +} from './columns.js' + +// Field ref introspection +export { extractFieldName, + extractEnumName, extractRelatedEntityName, hasFieldRefMeta, - getRelatedAccessor, -} from './columns.js' +} from './fieldRef.js' +export { getRelatedAccessor } from './columnTypes.js' // State hooks export { From 970c417f4827b164b0249eefecff65e9001b99e6 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 24 Aug 2026 17:14:33 +0200 Subject: [PATCH 55/55] fix(bindx-dataview): key sorting state by the dotted field path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A column declared with a nested field ref (`field={it.relation.scalar}`) registers its `sortingField` as the dotted path, because `createColumn` derives it via `extractFieldName`. But `setOrderBy` and `directionOf` keyed by `FIELD_REF_META.fieldName` — the leaf segment only — so the `sortableFields.has(...)` guard never matched, a header click was a silent no-op, and the sort indicator never lit. Seeding the same dotted key through `initialSorting` already resolved to the correct nested `orderBy`, so the dotted path was the canonical key everywhere except the ref -> key derivation. Route both through `extractFieldName`. This holds for live row refs as well as collector refs now that field handles carry `fullPath`. Fixes #68 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EmqgPtZvAfpvWKymfFZCEX --- .../bindx-dataview/src/useDataViewState.ts | 12 +- .../dataview/nestedFieldSortingKey.test.tsx | 155 ++++++++++++++++++ 2 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 tests/react/dataview/nestedFieldSortingKey.test.tsx diff --git a/packages/bindx-dataview/src/useDataViewState.ts b/packages/bindx-dataview/src/useDataViewState.ts index b0ac01cb..e789c79f 100644 --- a/packages/bindx-dataview/src/useDataViewState.ts +++ b/packages/bindx-dataview/src/useDataViewState.ts @@ -20,8 +20,8 @@ import type { SelectionState, FieldRef, } from '@contember/bindx' -import { FIELD_REF_META } from '@contember/bindx' import { useStoredState, type StateStorageOrName } from './stateStorage.js' +import { extractFieldName } from './fieldRef.js' // ============================================================================ // Filter State @@ -183,8 +183,9 @@ export function useSortingState(options: UseSortingOptions): SortingStateResult const setOrderBy = useCallback( (field: FieldRef, action: SortingDirectionAction, append?: boolean): void => { - const fieldName = field[FIELD_REF_META].fieldName - if (!sortableFields.has(fieldName)) return + // Dotted path, not the leaf — `sortableFields` is keyed the same way (see #68). + const fieldName = extractFieldName(field) + if (fieldName === null || !sortableFields.has(fieldName)) return const currentDir = directions[fieldName] ?? null const newDir = resolveSortAction(currentDir, action) @@ -210,7 +211,10 @@ export function useSortingState(options: UseSortingOptions): SortingStateResult }, [setDirections]) const directionOf = useCallback( - (field: FieldRef): OrderDirection | null => directions[field[FIELD_REF_META].fieldName] ?? null, + (field: FieldRef): OrderDirection | null => { + const fieldName = extractFieldName(field) + return fieldName === null ? null : directions[fieldName] ?? null + }, [directions], ) diff --git a/tests/react/dataview/nestedFieldSortingKey.test.tsx b/tests/react/dataview/nestedFieldSortingKey.test.tsx new file mode 100644 index 00000000..02afd099 --- /dev/null +++ b/tests/react/dataview/nestedFieldSortingKey.test.tsx @@ -0,0 +1,155 @@ +// Regression test for https://github.com/contember/bindx/issues/68 +// +// A DataGrid column declared with a NESTED field ref — field={it.relation.scalar}, +// e.g. a scalar on a oneHasOne view entity — registers its sortingField as the +// dotted path ('relation.scalar'): createColumn derives it via extractFieldName, +// which joins FIELD_REF_META.fullPath. But useSortingState's setOrderBy and +// directionOf key by FIELD_REF_META.fieldName — the LEAF segment only — so the +// `sortableFields.has(...)` guard never matches a nested ref and a header click +// is a silent no-op (and the sort indicator never lights). Seeding the same +// dotted key via initialSorting resolves to the correct nested orderBy, which +// shows the dotted path is the canonical sorting key; only the ref→key +// derivation in setOrderBy/directionOf disagrees with it. +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { renderHook, cleanup, act } from '@testing-library/react' +import { + defineSchema, + scalar, + hasOne, + createCollectorProxy, +} from '@contember/bindx-react' +import { SelectionScope, SchemaRegistry, EntityHandle } from '@contember/bindx' +import { extractFieldName, useSortingState } from '@contember/bindx-dataview' +import { createTestDispatcher } from '../../unit/shared/unitTestHelpers.js' + +afterEach(() => { + cleanup() +}) + +// ============================================================================ +// Schema — a Project with a oneHasOne stats relation (view-entity shape) +// ============================================================================ + +interface ProjectStats { + id: string + memberCount: number +} + +interface Project { + id: string + name: string + stats: ProjectStats | null +} + +interface TestSchema { + Project: Project + ProjectStats: ProjectStats +} + +const testSchema = defineSchema({ + entities: { + Project: { + fields: { + id: scalar(), + name: scalar(), + stats: hasOne('ProjectStats'), + }, + }, + ProjectStats: { + fields: { + id: scalar(), + memberCount: scalar(), + }, + }, + }, +}) + +const schemaRegistry = new SchemaRegistry(testSchema) + +function createProjectProxy() { + const scope = new SelectionScope() + return createCollectorProxy(scope, 'Project', schemaRegistry) +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('useSortingState — nested field refs', () => { + test('should toggle sorting when setOrderBy receives the same nested ref the column registered as sortable', () => { + const it = createProjectProxy() + const nestedRef = it.stats.memberCount + + // Premise: this is exactly what createColumn stores as the column's + // sortingField (and therefore what lands in sortableFields). + expect(extractFieldName(nestedRef)).toBe('stats.memberCount') + + const { result } = renderHook(() => + useSortingState({ sortableFields: new Set(['stats.memberCount']) }), + ) + + // A header click dispatches setOrderBy(fieldRef, 'next') with the SAME + // ref the column was declared with. + act(() => { + result.current.setOrderBy(nestedRef, 'next') + }) + + // FAILS today: setOrderBy keys the guard by FIELD_REF_META.fieldName + // ('memberCount'), which is not in sortableFields, so the call is a + // silent no-op — resolvedOrderBy stays undefined and directionOf null. + expect(result.current.resolvedOrderBy).toEqual([{ stats: { memberCount: 'asc' } }]) + expect(result.current.directionOf(nestedRef)).toBe('asc') + }) + + test('should sort by a top-level ref (control — unaffected by the bug)', () => { + const it = createProjectProxy() + const topLevelRef = it.name + + const { result } = renderHook(() => + useSortingState({ sortableFields: new Set(['name']) }), + ) + + act(() => { + result.current.setOrderBy(topLevelRef, 'next') + }) + + expect(result.current.resolvedOrderBy).toEqual([{ name: 'asc' }]) + expect(result.current.directionOf(topLevelRef)).toBe('asc') + }) + + test('should resolve a dotted initialSorting key to a nested orderBy (control — shows the dotted path is the canonical key)', () => { + const { result } = renderHook(() => + useSortingState({ + sortableFields: new Set(['stats.memberCount']), + initialSorting: { 'stats.memberCount': 'desc' }, + }), + ) + + expect(result.current.resolvedOrderBy).toEqual([{ stats: { memberCount: 'desc' } }]) + }) + + // The same guard has to hold for a LIVE row accessor, not just the collector + // proxy: DataViewSortingTrigger accepts any FieldRef, so a nested ref taken + // off a rendered row must produce the same dotted key. + test('should toggle sorting for a nested ref taken off a live entity handle', () => { + const { store, dispatcher } = createTestDispatcher() + store.setEntityData('Project', 'p-1', { id: 'p-1', name: 'Alpha', stats: { id: 's-1', memberCount: 3 } }, true) + + const project = EntityHandle.create('p-1', 'Project', store, dispatcher, schemaRegistry) + const liveNestedRef = project.stats.memberCount + + expect(extractFieldName(liveNestedRef)).toBe('stats.memberCount') + + const { result } = renderHook(() => + useSortingState({ sortableFields: new Set(['stats.memberCount']) }), + ) + + act(() => { + result.current.setOrderBy(liveNestedRef, 'next') + }) + + expect(result.current.resolvedOrderBy).toEqual([{ stats: { memberCount: 'asc' } }]) + expect(result.current.directionOf(liveNestedRef)).toBe('asc') + }) +})