Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
- 9000:8000

contember-engine:
image: contember/engine:2.1.0-beta.2
image: contember/engine:2.1.0-rc.3
env:
NODE_ENV: development
CONTEMBER_PORT: "4000"
Expand Down Expand Up @@ -114,7 +114,7 @@ jobs:
-e CONTEMBER_PROJECT_NAME=example \
-v ${{ github.workspace }}:/src \
-w /src/packages/example \
contember/cli:2.1.0-beta.2 \
contember/cli:2.1.0-rc.3 \
migrations:execute --yes
CONTEMBER_API_URL=http://localhost:1581 CONTEMBER_API_TOKEN=0000000000000000000000000000000000000000 bun run packages/example/seed.ts

Expand Down
4 changes: 2 additions & 2 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
contember-engine:
image: contember/engine:2.1.0-beta.2
image: contember/engine:2.1.0-rc.3

environment:
NODE_ENV: 'development'
Expand Down Expand Up @@ -43,7 +43,7 @@ services:
condition: service_healthy

contember-cli:
image: contember/cli:2.1.0-beta.2
image: contember/cli:2.1.0-rc.3
user: '1000:1000'

deploy:
Expand Down
17 changes: 11 additions & 6 deletions packages/bindx-dataview/src/HasManyDataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ interface ListState {

const INITIAL_LIST_STATE: ListState = { status: 'loading', items: [] }

function getRowId(entityType: string, row: Record<string, unknown>): string {
const id = row['id']
if (typeof id !== 'string') throw new Error(`${entityType} relation row has no string id`)
return id
}

// ============================================================================
// Implementation
// ============================================================================
Expand Down Expand Up @@ -183,12 +189,11 @@ function HasManyDataGridImpl<TEntity extends object>({
return
}

const items = relation.rows.map((data: Record<string, unknown>) => {
const id = data['id'] as string
dispatcher.dispatch(
setEntityData(targetEntityType, id, data, true),
)
return { id, data: data as object }
const items = relation.rows.map(data => ({ id: getRowId(targetEntityType, data), data }))
store.batchNotifications(() => {
for (const item of items) {
dispatcher.dispatch(setEntityData(targetEntityType, item.id, item.data, true))
}
})

setListState({ status: 'ready', items, totalCount: relation.totalCount })
Expand Down
9 changes: 5 additions & 4 deletions packages/bindx-react/src/hooks/useEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,12 +290,13 @@ export function useEntity(
if (result.type === 'get' && result.data === null) {
dispatcher.dispatch(setLoadState(entityType, id, 'not_found'))
} else if (result.type === 'get' && result.data) {
const data = result.data
// Revalidation: advance the server baseline but keep local dirty
// edits intact (see EntitySnapshotStore.refreshServerData).
dispatcher.dispatch(
refreshServerData(entityType, id, result.data),
)
dispatcher.dispatch(setLoadState(entityType, id, 'success'))
store.batchNotifications(() => {
dispatcher.dispatch(refreshServerData(entityType, id, data))
dispatcher.dispatch(setLoadState(entityType, id, 'success'))
})
}
} catch (error) {
if (abortController.signal.aborted) return
Expand Down
27 changes: 15 additions & 12 deletions packages/bindx-react/src/hooks/useEntityList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ function createLoadingListResult(): LoadingEntityListResult {
}
}

function getRowId(entityType: string, row: Record<string, unknown>): string {
const id = row['id']
if (typeof id !== 'string') throw new Error(`${entityType} list row has no string id`)
return id
}

function createErrorListResult(error: FieldError): ErrorEntityListResult {
return {
$status: 'error',
Expand Down Expand Up @@ -456,19 +462,16 @@ export function useEntityList(
throw new Error('Unexpected query result type')
}

const items = result.data.map((data: Record<string, unknown>) => {
const id = data['id'] as string
// Revalidation: advance the server baseline but keep local dirty
// edits intact (see EntitySnapshotStore.refreshServerData).
dispatcher.dispatch(
refreshServerData(entityType, id, data),
)
return { id, data: data as object }
const items = result.data.map(data => ({ id: getRowId(entityType, data), data }))
store.batchNotifications(() => {
for (const item of items) {
// Revalidation preserves local edits while advancing the server baseline.
dispatcher.dispatch(refreshServerData(entityType, item.id, item.data))
}
listStateRef.current = { status: 'ready', items, isRefetching: false }
versionRef.current++
store.notify()
})

listStateRef.current = { status: 'ready', items, isRefetching: false }
versionRef.current++
store.notify()
} catch (error) {
if (abortController.signal.aborted) return

Expand Down
12 changes: 6 additions & 6 deletions packages/bindx/src/core/EntityLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,13 @@ export class EntityLoader {
}
}

// Store each entity in snapshot store
for (const item of result.data) {
const record = item as Record<string, unknown>
if (typeof record['id'] === 'string') {
this.store.setEntityData(entityType, record['id'], record, true)
this.store.batchNotifications(() => {
for (const record of result.data) {
if (typeof record['id'] === 'string') {
this.store.setEntityData(entityType, record['id'], record, true)
}
}
}
})

return { status: 'success', data: result.data as T[] }
} catch (error) {
Expand Down
26 changes: 15 additions & 11 deletions packages/bindx/src/persistence/BatchPersister.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,16 +327,18 @@ export class BatchPersister {
}

const transactionResult = await this.executeTransaction(execution, options?.signal)
result = this.processExecutionResult(execution, transactionResult, options)
result = this.store.batchNotifications(() => this.processExecutionResult(execution, transactionResult, options))
result = this.mergeCancelled(result, execution.vetoed)
return result

} finally {
this.releaseEntities([...claimed.values()])
this.undoManager?.unblock()
if (result?.success) {
this.store.sweepUnreachableCreated()
}
this.store.batchNotifications(() => {
this.releaseEntities([...claimed.values()])
this.undoManager?.unblock()
if (result?.success) {
this.store.sweepUnreachableCreated()
}
})
}

// Unreachable, but keeps the return type explicit when control-flow analysis changes.
Expand Down Expand Up @@ -991,11 +993,13 @@ export class BatchPersister {
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))
}
this.store.batchNotifications(() => {
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))
}
})
}

private releaseEntities(entities: readonly DirtyEntity[]): void {
Expand Down
75 changes: 39 additions & 36 deletions packages/bindx/src/store/SnapshotStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { EntitySnapshot, LoadStatus } from './snapshots.js'
import { createEntitySnapshot } from './snapshots.js'
import type { FieldError, FieldErrorFilter } from '../errors/types.js'
import { SubscriptionManager, type SnapshotVersionBumper } from './SubscriptionManager.js'
import { SubscriptionManager, type SnapshotVersionBumper, type SynchronousResult } from './SubscriptionManager.js'
import { ErrorStore } from './ErrorStore.js'
import {
RelationStore,
Expand Down Expand Up @@ -176,6 +176,11 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
}
}

/** Coalesces synchronous notifications, even on failure, without changing undo boundaries. */
batchNotifications<T>(fn: () => SynchronousResult<T>): T {
return this.subscriptions.batchNotifications(fn)
}

// ==================== Key Generation ====================

private getEntityKey(entityType: string, id: string): string {
Expand Down Expand Up @@ -1105,23 +1110,16 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
hasManyStates: Map<string, StoredHasManyState>
entityMetas: Map<string, EntityMeta>
}): void {
const notifiedEntityKeys = this.entitySnapshots.importSnapshots(snapshot.entitySnapshots)

this.meta.importMetas(snapshot.entityMetas)

const relationKeys = this.relations.importRelationStates(snapshot.relationStates)
const hasManyKeys = this.relations.importHasManyStates(snapshot.hasManyStates)
const notifiedRelationKeys = new Set([...relationKeys, ...hasManyKeys])
this.subscriptions.batchNotifications(() => {
const entityKeys = this.entitySnapshots.importSnapshots(snapshot.entitySnapshots)
this.meta.importMetas(snapshot.entityMetas)
const relationKeys = this.relations.importRelationStates(snapshot.relationStates)
const hasManyKeys = this.relations.importHasManyStates(snapshot.hasManyStates)

this.subscriptions.notifyGlobal()

for (const key of notifiedEntityKeys) {
this.subscriptions.notifyEntityDirect(key)
}

for (const key of notifiedRelationKeys) {
this.subscriptions.notifyRelationDirect(key)
}
this.subscriptions.notify()
for (const key of entityKeys) this.subscriptions.notifyEntityDirect(key)
for (const key of [...relationKeys, ...hasManyKeys]) this.subscriptions.notifyRelationDirect(key)
})
}

// ==================== Undo Journal Target (cell capture / restore) ====================
Expand Down Expand Up @@ -1236,39 +1234,44 @@ export class SnapshotStore implements SnapshotVersionBumper, JournalTarget {
* then present relations, then un-creates, then dropped relations.
*/
applyJournalImages(images: JournalCellImage[]): void {
const notifyEntities = new Set<string>()
const notifyRelations = new Set<string>()

const presentEntities: EntityCellImage[] = []
const presentRelations: Array<RelationCellImage | HasManyCellImage> = []
const absentEntities: EntityCellImage[] = []
const absentRelations: Array<RelationCellImage | HasManyCellImage> = []

for (const img of images) {
if (img.kind === 'entity') {
notifyEntities.add(img.key)
;(img.present ? presentEntities : absentEntities).push(img)
} else {
notifyRelations.add(img.key)
notifyEntities.add(entityKeyOfRelationKey(img.key))
;(img.present ? presentRelations : absentRelations).push(img)
}
}

for (const img of presentEntities) this.applyEntityImage(img)
for (const img of presentRelations) this.applyRelationImage(img)
for (const img of absentEntities) {
const [type, id] = splitEntityKey(img.key)
this.removeEntity(type, id)
}
for (const img of absentRelations) {
if (img.kind === 'relation') this.relations.removeRelationState(img.key)
else this.relations.removeHasManyState(img.key)
}
// removeEntity notifies; the batch holds that back until the restore is complete.
this.subscriptions.batchNotifications(() => {
for (const img of presentEntities) this.applyEntityImage(img)
for (const img of presentRelations) this.applyRelationImage(img)
for (const img of absentEntities) {
const [type, id] = splitEntityKey(img.key)
this.removeEntity(type, id)
}
for (const img of absentRelations) {
if (img.kind === 'relation') this.relations.removeRelationState(img.key)
else this.relations.removeHasManyState(img.key)
}

this.subscriptions.notify()
for (const img of images) this.notifyRestoredCell(img)
})
}

this.subscriptions.notifyGlobal()
for (const key of notifyEntities) this.subscriptions.notifyEntityDirect(key)
for (const key of notifyRelations) this.subscriptions.notifyRelationDirect(key)
private notifyRestoredCell(img: JournalCellImage): void {
if (img.kind === 'entity') {
this.subscriptions.notifyEntityDirect(img.key)
return
}
this.subscriptions.notifyRelationDirect(img.key)
this.subscriptions.notifyEntityDirect(entityKeyOfRelationKey(img.key))
}

private applyEntityImage(img: EntityCellImage): void {
Expand Down
Loading