From 06ebccd22989d4cd4b637a223dd352f3c8e9b42b Mon Sep 17 00:00:00 2001 From: nichenqin Date: Mon, 23 Mar 2026 06:02:56 +0000 Subject: [PATCH] [sync] T1879: keep hidden grid views stable for new fields (#1490) Synced from teableio/teable-ee@6f23ef2 --- .../PostgresTableRepository.spec.ts | 98 ++++++++++++++ .../repositories/PostgresTableRepository.ts | 109 +++++++++++++-- .../visitors/TableMetaUpdateVisitor.ts | 20 +++ .../projections/RealtimeProjections.spec.ts | 24 +++- ...ViewColumnMetaUpdatedRealtimeProjection.ts | 36 ++++- .../services/TableUpdateFlow.spec.ts | 62 +++++++++ .../application/services/TableUpdateFlow.ts | 97 +++++++++---- .../src/commands/CreateFieldHandler.spec.ts | 108 +++++++++++++++ .../v2/core/src/domain/table/Table.spec.ts | 128 ++++++++++++++++++ packages/v2/core/src/domain/table/Table.ts | 50 ++++++- .../v2/core/src/domain/table/TableMutator.ts | 50 ++----- .../table/events/ViewColumnMetaUpdated.ts | 15 +- .../specs/TableUpdateViewColumnMetaSpec.ts | 38 ++++++ .../TableUpdateViewColumnMetaSpec.spec.ts | 76 +++++++++++ packages/v2/core/src/ports/TableRepository.ts | 7 + packages/v2/e2e/src/createField.e2e.spec.ts | 89 ++++++++++++ .../v2/e2e/src/realtimeShareDb.e2e.spec.ts | 126 ++++++++++++++++- .../commands/CreateFieldHandler.db.spec.ts | 107 +++++++++++++++ 18 files changed, 1148 insertions(+), 92 deletions(-) diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.spec.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.spec.ts index b817b063b4..332ab31a4b 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.spec.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.spec.ts @@ -54,7 +54,9 @@ import { TableName, TableByNameSpec, TableUpdateFieldNameSpec, + TableUpdateViewColumnMetaSpec, TableSortKey, + ViewColumnMeta, createSingleSelectField, v2CoreTokens, domainError, @@ -1989,4 +1991,100 @@ describe('PostgresTableRepository (pg)', () => { await db.destroy(); } }); + + it('increments view version on view column meta updates', async () => { + const c = container.createChildContainer(); + const db = await createPgDb(pgContainer.getConnectionUri()); + await registerV2PostgresStateAdapter(c, { + db, + ensureSchema: true, + }); + const repo = c.resolve(v2CoreTokens.tableRepository); + + try { + const baseId = BaseId.generate()._unsafeUnwrap(); + const actorId = ActorId.create('system')._unsafeUnwrap(); + const context = { actorId }; + const spaceId = `spc${getRandomString(16)}`; + + await db + .insertInto('space') + .values({ id: spaceId, name: 'View Version Space', created_by: actorId.toString() }) + .execute(); + + await db + .insertInto('base') + .values({ + id: baseId.toString(), + space_id: spaceId, + name: 'View Version Base', + order: 1, + created_by: actorId.toString(), + }) + .execute(); + + const builder = Table.builder() + .withBaseId(baseId) + .withName(TableName.create('View Version Table')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + builder.view().defaultGrid().done(); + const table = builder.build()._unsafeUnwrap(); + + const inserted = (await repo.insert(context, table))._unsafeUnwrap(); + const view = inserted.views()[0]; + expect(view).toBeDefined(); + if (!view) return; + + const fieldId = inserted.primaryFieldId(); + const fieldKey = fieldId.toString(); + const currentMeta = view.columnMeta()._unsafeUnwrap().toDto(); + const nextMeta = ViewColumnMeta.create({ + ...currentMeta, + [fieldKey]: { + ...(currentMeta[fieldKey] ?? {}), + width: 320, + }, + })._unsafeUnwrap(); + + const persistResult = ( + await repo.updateOne( + context, + inserted, + TableUpdateViewColumnMetaSpec.create([ + { + viewId: view.id(), + fieldId, + columnMeta: nextMeta, + }, + ]) + ) + )._unsafeUnwrap(); + + expect(persistResult).toEqual({ + viewVersionChanges: [ + { + viewId: view.id().toString(), + oldVersion: 1, + newVersion: 2, + }, + ], + }); + + const row = await db + .selectFrom('view') + .select(['version', 'column_meta']) + .where('id', '=', view.id().toString()) + .executeTakeFirst(); + + expect(row?.version).toBe(2); + expect(row?.column_meta).toContain('"width":320'); + } finally { + await db.destroy(); + } + }); }); diff --git a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts index c7cb96ac8d..bb2c334890 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/PostgresTableRepository.ts @@ -813,24 +813,47 @@ export class PostgresTableRepository implements core.ITableRepository { ); const fieldVersionTouchOrder = updateVisitor.fieldVersionTouchOrder(); - if (fieldVersionTouchOrder.length === 0) { + const viewVersionTouchOrder = updateVisitor.viewVersionTouchOrder(); + if (fieldVersionTouchOrder.length === 0 && viewVersionTouchOrder.length === 0) { return ok(undefined); } - const fieldVersionsResult = await this.loadFieldVersionsByIds( - db, - tableId, - fieldVersionTouchOrder - ); - if (fieldVersionsResult.isErr()) { - return err(fieldVersionsResult.error); + let fieldVersionChanges: ReadonlyArray | undefined; + if (fieldVersionTouchOrder.length > 0) { + const fieldVersionsResult = await this.loadFieldVersionsByIds( + db, + tableId, + fieldVersionTouchOrder + ); + if (fieldVersionsResult.isErr()) { + return err(fieldVersionsResult.error); + } + fieldVersionChanges = this.buildFieldVersionChanges( + fieldVersionTouchOrder, + fieldVersionsResult.value + ); } - const fieldVersionChanges = this.buildFieldVersionChanges( - fieldVersionTouchOrder, - fieldVersionsResult.value - ); - return ok({ fieldVersionChanges }); + let viewVersionChanges: ReadonlyArray | undefined; + if (viewVersionTouchOrder.length > 0) { + const viewVersionsResult = await this.loadViewVersionsByIds( + db, + tableId, + viewVersionTouchOrder + ); + if (viewVersionsResult.isErr()) { + return err(viewVersionsResult.error); + } + viewVersionChanges = this.buildViewVersionChanges( + viewVersionTouchOrder, + viewVersionsResult.value + ); + } + + return ok({ + ...(fieldVersionChanges ? { fieldVersionChanges } : {}), + ...(viewVersionChanges ? { viewVersionChanges } : {}), + }); } catch (error) { return err( domainError.infrastructure({ message: `Failed to update table: ${describeError(error)}` }) @@ -872,6 +895,41 @@ export class PostgresTableRepository implements core.ITableRepository { } } + private async loadViewVersionsByIds( + db: Kysely | Transaction, + tableId: string, + viewIds: ReadonlyArray + ): Promise, DomainError>> { + const uniqueViewIds = [...new Set(viewIds)]; + if (uniqueViewIds.length === 0) { + return ok(new Map()); + } + + try { + const rows = await db + .selectFrom('view') + .select(['id', 'version']) + .where('table_id', '=', tableId) + .where('deleted_time', 'is', null) + .where('id', 'in', uniqueViewIds) + .execute(); + + const versions = new Map(rows.map((row) => [row.id, Number(row.version ?? 0)])); + for (const viewId of uniqueViewIds) { + if (!versions.has(viewId)) { + return err(domainError.notFound({ message: `View not found: ${viewId}` })); + } + } + return ok(versions); + } catch (error) { + return err( + domainError.infrastructure({ + message: `Failed to load view versions: ${describeError(error)}`, + }) + ); + } + } + private buildFieldVersionChanges( fieldVersionTouchOrder: ReadonlyArray, finalVersionByFieldId: ReadonlyMap @@ -897,6 +955,31 @@ export class PostgresTableRepository implements core.ITableRepository { }); } + private buildViewVersionChanges( + viewVersionTouchOrder: ReadonlyArray, + finalVersionByViewId: ReadonlyMap + ): ReadonlyArray { + const countByViewId = new Map(); + for (const viewId of viewVersionTouchOrder) { + countByViewId.set(viewId, (countByViewId.get(viewId) ?? 0) + 1); + } + + const indexByViewId = new Map(); + return viewVersionTouchOrder.map((viewId) => { + const totalCount = countByViewId.get(viewId) ?? 0; + const finalVersion = finalVersionByViewId.get(viewId) ?? 0; + const currentIndex = indexByViewId.get(viewId) ?? 0; + indexByViewId.set(viewId, currentIndex + 1); + + const oldVersion = Math.max(finalVersion - totalCount + currentIndex, 0); + return { + viewId, + oldVersion, + newVersion: oldVersion + 1, + }; + }); + } + @core.TraceSpan() async delete( context: core.IExecutionContext, diff --git a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableMetaUpdateVisitor.ts b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableMetaUpdateVisitor.ts index 2d13f9056f..2d7bc20625 100644 --- a/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableMetaUpdateVisitor.ts +++ b/packages/v2/adapter-repository-postgres/src/repositories/visitors/TableMetaUpdateVisitor.ts @@ -112,7 +112,9 @@ export class TableMetaUpdateVisitor { private readonly fieldRowBuilder: TableFieldPersistenceBuilder; private readonly fieldVersionIncrement: RawBuilder = sql`coalesce(version, 0) + 1`; + private readonly viewVersionIncrement: RawBuilder = sql`coalesce(version, 0) + 1`; private readonly fieldVersionTouches: string[] = []; + private readonly viewVersionTouches: string[] = []; constructor(private readonly params: TableMetaUpdateVisitorParams) { super(); @@ -128,6 +130,10 @@ export class TableMetaUpdateVisitor return [...this.fieldVersionTouches]; } + viewVersionTouchOrder(): ReadonlyArray { + return [...this.viewVersionTouches]; + } + visitTableByBaseId(_: TableByBaseIdSpec): Result, DomainError> { return err( domainError.validation({ message: 'TableByBaseIdSpec is not supported for table updates' }) @@ -231,11 +237,16 @@ export class TableMetaUpdateVisitor spec: TableUpdateViewColumnMetaSpec ): Result, DomainError> { const updates = spec.updates(); + for (const update of updates) { + this.trackViewVersionTouch(update.viewId.toString()); + } + const statements: ReadonlyArray = updates.map((update) => this.params.db .updateTable('view') .set({ column_meta: JSON.stringify(update.columnMeta.toDto()), + version: this.viewVersionIncrement, last_modified_time: this.params.now, last_modified_by: this.params.actorId, }) @@ -249,6 +260,10 @@ export class TableMetaUpdateVisitor visitTableUpdateViewQueryDefaults( spec: TableUpdateViewQueryDefaultsSpec ): Result, DomainError> { + for (const update of spec.updates()) { + this.trackViewVersionTouch(update.viewId.toString()); + } + const statements: ReadonlyArray = spec .updates() .map((update: TableViewQueryDefaultsUpdate) => { @@ -270,6 +285,7 @@ export class TableMetaUpdateVisitor : this.stringifyLegacyFilter(this.mapRecordFilterToLegacy(query.filter)), sort: sortPayload ? JSON.stringify(sortPayload) : null, group: query.group ? JSON.stringify(query.group) : null, + version: this.viewVersionIncrement, last_modified_time: this.params.now, last_modified_by: this.params.actorId, }) @@ -959,4 +975,8 @@ export class TableMetaUpdateVisitor private trackFieldVersionTouch(fieldId: FieldId): void { this.fieldVersionTouches.push(fieldId.toString()); } + + private trackViewVersionTouch(viewId: string): void { + this.viewVersionTouches.push(viewId); + } } diff --git a/packages/v2/core/src/application/projections/RealtimeProjections.spec.ts b/packages/v2/core/src/application/projections/RealtimeProjections.spec.ts index 1d1f930aad..d3f96a64b2 100644 --- a/packages/v2/core/src/application/projections/RealtimeProjections.spec.ts +++ b/packages/v2/core/src/application/projections/RealtimeProjections.spec.ts @@ -526,13 +526,33 @@ describe('Realtime projections', () => { tableId: table.id(), viewId, fieldId: table.primaryFieldId(), + oldVersion: 7, + newVersion: 8, }); const result = await projection.handle(createContext(), event); result._unsafeUnwrap(); - expect(engine.ensures).toHaveLength(1); - expect(engine.changes).toHaveLength(1); + expect(engine.ensures).toHaveLength(2); + expect(engine.ensures[0]?.docId.toString()).toBe( + `tbl_${table.baseId().toString()}/${table.id().toString()}` + ); + expect(engine.ensures[1]?.docId.toString()).toBe( + `viw_${table.id().toString()}/${viewId.toString()}` + ); + expect(engine.changes).toHaveLength(2); + expect(engine.changes[0]?.docId.toString()).toBe( + `tbl_${table.baseId().toString()}/${table.id().toString()}` + ); + expect(engine.changes[1]?.docId.toString()).toBe( + `viw_${table.id().toString()}/${viewId.toString()}` + ); + expect(engine.changes[1]?.change).toEqual({ + type: 'set', + path: ['columnMeta'], + value: buildTableDto(table).views[0]?.columnMeta, + }); + expect(engine.changes[1]?.options).toEqual({ version: 7 }); }); it('ignores missing views', async () => { diff --git a/packages/v2/core/src/application/projections/ViewColumnMetaUpdatedRealtimeProjection.ts b/packages/v2/core/src/application/projections/ViewColumnMetaUpdatedRealtimeProjection.ts index 29d27212b2..eed628c132 100644 --- a/packages/v2/core/src/application/projections/ViewColumnMetaUpdatedRealtimeProjection.ts +++ b/packages/v2/core/src/application/projections/ViewColumnMetaUpdatedRealtimeProjection.ts @@ -15,6 +15,7 @@ import { v2CoreTokens } from '../../ports/tokens'; import { ProjectionHandler } from './Projection'; const tableCollectionPrefix = 'tbl'; +const viewCollectionPrefix = 'viw'; @ProjectionHandler(ViewColumnMetaUpdated) @injectable() @@ -56,12 +57,35 @@ export class ViewColumnMetaUpdatedRealtimeProjection // Ensure table document exists first (for tables created before realtime was enabled) yield* (await realtimeEngine.ensure(context, docId, snapshot)).safeUnwrap(); - // Apply incremental change to update view columnMeta - return realtimeEngine.applyChange(context, docId, { - type: 'set', - path: ['views', viewIndex, 'columnMeta'], - value: viewDto.columnMeta, - }); + // Keep the table snapshot in sync for table-level consumers. + yield* ( + await realtimeEngine.applyChange(context, docId, { + type: 'set', + path: ['views', viewIndex, 'columnMeta'], + value: viewDto.columnMeta, + }) + ).safeUnwrap(); + + // Keep the standalone view document in sync for ShareDB/SDK view subscriptions. + const viewCollection = `${viewCollectionPrefix}_${event.tableId.toString()}`; + const viewDocId = yield* RealtimeDocId.fromParts( + viewCollection, + event.viewId.toString() + ).safeUnwrap(); + yield* (await realtimeEngine.ensure(context, viewDocId, viewDto)).safeUnwrap(); + + return realtimeEngine.applyChange( + context, + viewDocId, + { + type: 'set', + path: ['columnMeta'], + value: viewDto.columnMeta, + }, + { + version: event.oldVersion, + } + ); }); } } diff --git a/packages/v2/core/src/application/services/TableUpdateFlow.spec.ts b/packages/v2/core/src/application/services/TableUpdateFlow.spec.ts index ae0b357d04..bf52619c90 100644 --- a/packages/v2/core/src/application/services/TableUpdateFlow.spec.ts +++ b/packages/v2/core/src/application/services/TableUpdateFlow.spec.ts @@ -7,12 +7,15 @@ import { ActorId } from '../../domain/shared/ActorId'; import { domainError, type DomainError } from '../../domain/shared/DomainError'; import type { IDomainEvent } from '../../domain/shared/DomainEvent'; import type { ISpecification } from '../../domain/shared/specification/ISpecification'; +import { ViewColumnMetaUpdated } from '../../domain/table/events/ViewColumnMetaUpdated'; import { FieldName } from '../../domain/table/fields/FieldName'; import type { ITableSpecVisitor } from '../../domain/table/specs/ITableSpecVisitor'; +import { TableUpdateViewColumnMetaSpec } from '../../domain/table/specs/TableUpdateViewColumnMetaSpec'; import { Table } from '../../domain/table/Table'; import { TableId } from '../../domain/table/TableId'; import { TableName } from '../../domain/table/TableName'; import type { TableSortKey } from '../../domain/table/TableSortKey'; +import { ViewColumnMeta } from '../../domain/table/views/ViewColumnMeta'; import type { IEventBus } from '../../ports/EventBus'; import type { IExecutionContext, IUnitOfWorkTransaction } from '../../ports/ExecutionContext'; import type { IFindOptions } from '../../ports/RepositoryQuery'; @@ -212,6 +215,65 @@ describe('TableUpdateFlow', () => { expect(order).toEqual(['schema-update', 'after-persist', 'deferred-task']); }); + it('attaches persisted view versions to view column meta events', async () => { + const table = buildTable(); + const eventBus = new FakeEventBus(); + const repository = new FakeTableRepository(); + repository.updateOne = async () => + ok({ + viewVersionChanges: [ + { + viewId: table.views()[0]!.id().toString(), + oldVersion: 3, + newVersion: 4, + }, + ], + }); + + const flow = new TableUpdateFlow( + repository, + new FakeTableSchemaRepository(), + eventBus, + new FakeUnitOfWork() + ); + + const view = table.views()[0]!; + const fieldId = table.primaryFieldId(); + const fieldKey = fieldId.toString(); + const currentMeta = view.columnMeta()._unsafeUnwrap().toDto(); + const nextMeta = ViewColumnMeta.create({ + ...currentMeta, + [fieldKey]: { + ...(currentMeta[fieldKey] ?? {}), + hidden: true, + }, + })._unsafeUnwrap(); + + const result = await flow.execute(createContext(), { table }, (tableToUpdate) => + tableToUpdate.update((mutator) => + mutator.applySpecs([ + TableUpdateViewColumnMetaSpec.create([ + { + viewId: view.id(), + fieldId, + columnMeta: nextMeta, + }, + ]), + ]) + ) + ); + + const payload = result._unsafeUnwrap(); + const viewEvent = payload.events.find( + (event): event is ViewColumnMetaUpdated => event instanceof ViewColumnMetaUpdated + ); + + expect(viewEvent).toBeDefined(); + expect(viewEvent?.oldVersion).toBe(3); + expect(viewEvent?.newVersion).toBe(4); + expect(eventBus.published.some((event) => event instanceof ViewColumnMetaUpdated)).toBe(true); + }); + it('lets deferred tasks observe the latest table state in the transaction scope', async () => { const table = buildTable(); const observedNames: string[] = []; diff --git a/packages/v2/core/src/application/services/TableUpdateFlow.ts b/packages/v2/core/src/application/services/TableUpdateFlow.ts index 63d3553e9c..42a494a7ea 100644 --- a/packages/v2/core/src/application/services/TableUpdateFlow.ts +++ b/packages/v2/core/src/application/services/TableUpdateFlow.ts @@ -9,6 +9,7 @@ import type { IDomainEvent } from '../../domain/shared/DomainEvent'; import type { ISpecification } from '../../domain/shared/specification/ISpecification'; import { FieldOptionsAdded } from '../../domain/table/events/FieldOptionsAdded'; import { FieldUpdated } from '../../domain/table/events/FieldUpdated'; +import { ViewColumnMetaUpdated } from '../../domain/table/events/ViewColumnMetaUpdated'; import type { ITableSpecVisitor } from '../../domain/table/specs/ITableSpecVisitor'; import type { Table } from '../../domain/table/Table'; import { Table as TableAggregate } from '../../domain/table/Table'; @@ -174,7 +175,10 @@ export class TableUpdateFlow { return err(transactionResult.error); } - const normalizedEvents = handler.attachFieldEventVersions(events, tableUpdatePersistResult); + const normalizedEvents = handler.attachPersistedEventVersions( + events, + tableUpdatePersistResult + ); if (publishEvents) { // Publish events directly; projections fetch data themselves @@ -189,39 +193,43 @@ export class TableUpdateFlow { }); } - private attachFieldEventVersions( + private attachPersistedEventVersions( events: ReadonlyArray, persistResult: TableRepositoryPort.TableUpdatePersistResult | void ): ReadonlyArray { const fieldVersionChanges = persistResult?.fieldVersionChanges; - if (!events.length || !fieldVersionChanges?.length) { + const viewVersionChanges = persistResult?.viewVersionChanges; + if (!events.length || (!fieldVersionChanges?.length && !viewVersionChanges?.length)) { return events; } const queueByFieldId = new Map>(); - for (const change of fieldVersionChanges) { + for (const change of fieldVersionChanges ?? []) { const queue = queueByFieldId.get(change.fieldId) ?? []; queue.push(change); queueByFieldId.set(change.fieldId, queue); } - return events.map((event) => { - if (!(event instanceof FieldUpdated) && !(event instanceof FieldOptionsAdded)) { - return event; - } + const queueByViewId = new Map>(); + for (const change of viewVersionChanges ?? []) { + const queue = queueByViewId.get(change.viewId) ?? []; + queue.push(change); + queueByViewId.set(change.viewId, queue); + } - if (event.oldVersion != null && event.newVersion != null) { - return event; - } + return events.map((event) => { + if (event instanceof FieldUpdated) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } - const fieldId = event.fieldId.toString(); - const queue = queueByFieldId.get(fieldId); - const versionChange = queue?.shift(); - if (!versionChange) { - return event; - } + const fieldId = event.fieldId.toString(); + const queue = queueByFieldId.get(fieldId); + const versionChange = queue?.shift(); + if (!versionChange) { + return event; + } - if (event instanceof FieldUpdated) { return FieldUpdated.create({ tableId: event.tableId, baseId: event.baseId, @@ -234,14 +242,51 @@ export class TableUpdateFlow { }); } - return FieldOptionsAdded.create({ - tableId: event.tableId, - baseId: event.baseId, - fieldId: event.fieldId, - options: event.options, - oldVersion: versionChange.oldVersion, - newVersion: versionChange.newVersion, - }); + if (event instanceof FieldOptionsAdded) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + + const fieldId = event.fieldId.toString(); + const queue = queueByFieldId.get(fieldId); + const versionChange = queue?.shift(); + if (!versionChange) { + return event; + } + + return FieldOptionsAdded.create({ + tableId: event.tableId, + baseId: event.baseId, + fieldId: event.fieldId, + options: event.options, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + if (event instanceof ViewColumnMetaUpdated) { + if (event.oldVersion != null && event.newVersion != null) { + return event; + } + + const viewId = event.viewId.toString(); + const queue = queueByViewId.get(viewId); + const versionChange = queue?.shift(); + if (!versionChange) { + return event; + } + + return ViewColumnMetaUpdated.create({ + tableId: event.tableId, + baseId: event.baseId, + viewId: event.viewId, + fieldId: event.fieldId, + oldVersion: versionChange.oldVersion, + newVersion: versionChange.newVersion, + }); + } + + return event; }); } diff --git a/packages/v2/core/src/commands/CreateFieldHandler.spec.ts b/packages/v2/core/src/commands/CreateFieldHandler.spec.ts index 01891c8ca9..4ffa436f6f 100644 --- a/packages/v2/core/src/commands/CreateFieldHandler.spec.ts +++ b/packages/v2/core/src/commands/CreateFieldHandler.spec.ts @@ -20,11 +20,15 @@ import type { LinkField } from '../domain/table/fields/types/LinkField'; import type { LookupField } from '../domain/table/fields/types/LookupField'; import { SingleLineTextField } from '../domain/table/fields/types/SingleLineTextField'; import type { ITableSpecVisitor } from '../domain/table/specs/ITableSpecVisitor'; +import { TableUpdateViewColumnMetaSpec } from '../domain/table/specs/TableUpdateViewColumnMetaSpec'; import { Table } from '../domain/table/Table'; import { TABLE_FIELD_LIMIT_ERROR_CODE } from '../domain/table/TableFieldLimit'; import { TableId } from '../domain/table/TableId'; import { TableName } from '../domain/table/TableName'; import type { TableSortKey } from '../domain/table/TableSortKey'; +import { ViewColumnMeta } from '../domain/table/views/ViewColumnMeta'; +import { ViewName } from '../domain/table/views/ViewName'; +import { ViewColumnMetaUpdated } from '../domain/table/events/ViewColumnMetaUpdated'; import type { IEventBus } from '../ports/EventBus'; import type { IExecutionContext, IUnitOfWorkTransaction } from '../ports/ExecutionContext'; import { FieldOperationKind } from '../ports/FieldOperationPlugin'; @@ -174,11 +178,15 @@ class FakeTableSchemaRepository implements ITableSchemaRepository { } class FakeEventBus implements IEventBus { + published: IDomainEvent[] = []; + async publish(_context: IExecutionContext, _event: IDomainEvent) { + this.published.push(_event); return ok(undefined); } async publishMany(_context: IExecutionContext, _events: ReadonlyArray) { + this.published.push(..._events); return ok(undefined); } } @@ -237,6 +245,106 @@ const addTextFields = (table: Table, count: number, prefix: string): Table => { }; describe('CreateFieldHandler', () => { + it('publishes view column meta events when create field changes grid visibility metadata', async () => { + const baseId = `bse${'r'.repeat(16)}`; + const tableId = `tbl${'s'.repeat(16)}`; + const primaryFieldId = `fld${'t'.repeat(16)}`; + const notesFieldId = `fld${'u'.repeat(16)}`; + const newFieldId = `fld${'v'.repeat(16)}`; + + const builder = Table.builder() + .withId(TableId.create(tableId)._unsafeUnwrap()) + .withBaseId(BaseId.create(baseId)._unsafeUnwrap()) + .withName(TableName.create('Create Field Visibility Events')._unsafeUnwrap()); + builder + .field() + .singleLineText() + .withId(FieldId.create(primaryFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Name')._unsafeUnwrap()) + .primary() + .done(); + builder + .field() + .singleLineText() + .withId(FieldId.create(notesFieldId)._unsafeUnwrap()) + .withName(FieldName.create('Notes')._unsafeUnwrap()) + .done(); + builder.view().grid().withName(ViewName.create('View A')._unsafeUnwrap()).done(); + builder.view().grid().withName(ViewName.create('View B')._unsafeUnwrap()).done(); + + const initialTable = builder.build()._unsafeUnwrap(); + const viewA = initialTable.views().find((view) => view.name().toString() === 'View A'); + const viewB = initialTable.views().find((view) => view.name().toString() === 'View B'); + expect(viewA).toBeTruthy(); + expect(viewB).toBeTruthy(); + if (!viewA || !viewB) return; + + const viewAMeta = viewA.columnMeta()._unsafeUnwrap().toDto(); + const configuredTable = TableUpdateViewColumnMetaSpec.create([ + { + viewId: viewA.id(), + fieldId: FieldId.create(notesFieldId)._unsafeUnwrap(), + columnMeta: ViewColumnMeta.create({ + ...viewAMeta, + [notesFieldId]: { + ...(viewAMeta[notesFieldId] ?? {}), + hidden: false, + }, + })._unsafeUnwrap(), + }, + ]) + .mutate(initialTable) + ._unsafeUnwrap(); + + const tableRepository = new InMemoryTableRepository(); + tableRepository.tables.push(configuredTable); + const schemaRepository = new FakeTableSchemaRepository(); + const eventBus = new FakeEventBus(); + const unitOfWork = new FakeUnitOfWork(); + const tableUpdateFlow = new TableUpdateFlow( + tableRepository, + schemaRepository, + eventBus, + unitOfWork + ); + const fieldCreationSideEffectService = new FieldCreationSideEffectService(tableUpdateFlow); + const foreignTableLoaderService = new ForeignTableLoaderService(tableRepository); + const handler = new CreateFieldHandler( + tableRepository, + tableUpdateFlow, + fieldCreationSideEffectService, + foreignTableLoaderService, + createFieldOperationPluginRunner([new TableFieldLimitFieldOperationPlugin()]), + noopUndoRedoService, + noopFieldUndoRedoSnapshotService + ); + + const command = CreateFieldCommand.create({ + baseId, + tableId, + field: { + id: newFieldId, + type: 'singleLineText', + name: 'Created From View B', + }, + order: { + viewId: viewB.id().toString(), + orderIndex: 2.5, + }, + })._unsafeUnwrap(); + + const result = await handler.handle(createContext(), command); + result._unsafeUnwrap(); + + const viewEvents = eventBus.published.filter( + (event): event is ViewColumnMetaUpdated => event instanceof ViewColumnMetaUpdated + ); + + expect(viewEvents.length).toBeGreaterThan(0); + expect(viewEvents.some((event) => event.viewId.equals(viewA.id()))).toBe(true); + expect(viewEvents.some((event) => event.viewId.equals(viewB.id()))).toBe(true); + }); + it('supports all link relationships and self references', async () => { const baseId = `bse${'a'.repeat(16)}`; const hostTableId = `tbl${'b'.repeat(16)}`; diff --git a/packages/v2/core/src/domain/table/Table.spec.ts b/packages/v2/core/src/domain/table/Table.spec.ts index 36acc8647f..bb9b3884fd 100644 --- a/packages/v2/core/src/domain/table/Table.spec.ts +++ b/packages/v2/core/src/domain/table/Table.spec.ts @@ -26,9 +26,11 @@ import { SingleLineTextField } from './fields/types/SingleLineTextField'; import { TextDefaultValue } from './fields/types/TextDefaultValue'; import { RecordId } from './records/RecordId'; import { TableUpdateFieldNameSpec } from './specs/TableUpdateFieldNameSpec'; +import { TableUpdateViewColumnMetaSpec } from './specs/TableUpdateViewColumnMetaSpec'; import { Table } from './Table'; import { TableId } from './TableId'; import { TableName } from './TableName'; +import { ViewColumnMeta } from './views/ViewColumnMeta'; import { GridView } from './views/types/GridView'; import { ViewId } from './views/ViewId'; import { ViewName } from './views/ViewName'; @@ -387,6 +389,132 @@ describe('Table', () => { expect(addedEntry.order).toBe(maxOrder + 1); }); + it('hides newly added field in non-target grid views with explicit visibility config', () => { + const newFieldId = createFieldId('i')._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(createBaseId('i')._unsafeUnwrap()) + .withName(TableName.create('Hidden View Stability')._unsafeUnwrap()); + + builder.field().singleLineText().withName(FieldName.create('Title')._unsafeUnwrap()).done(); + builder.field().singleLineText().withName(FieldName.create('Notes')._unsafeUnwrap()).done(); + builder.view().grid().withName(ViewName.create('View A')._unsafeUnwrap()).done(); + builder.view().grid().withName(ViewName.create('View B')._unsafeUnwrap()).done(); + + const table = builder.build()._unsafeUnwrap(); + const notesField = table.getFields().find((field) => field.name().toString() === 'Notes'); + const hiddenView = table.views()[0]; + const defaultView = table.views()[1]; + + expect(notesField).toBeTruthy(); + expect(hiddenView).toBeTruthy(); + expect(defaultView).toBeTruthy(); + if (!notesField || !hiddenView || !defaultView) return; + + const hiddenViewMeta = hiddenView.columnMeta()._unsafeUnwrap().toDto(); + const hiddenFieldKey = notesField.id().toString(); + const configuredHiddenMeta = ViewColumnMeta.create({ + ...hiddenViewMeta, + [hiddenFieldKey]: { + ...(hiddenViewMeta[hiddenFieldKey] ?? {}), + hidden: false, + }, + })._unsafeUnwrap(); + + const configuredTable = TableUpdateViewColumnMetaSpec.create([ + { + viewId: hiddenView.id(), + fieldId: notesField.id(), + columnMeta: configuredHiddenMeta, + }, + ]) + .mutate(table) + ._unsafeUnwrap(); + + const newField = SingleLineTextField.create({ + id: newFieldId, + name: FieldName.create('Extra')._unsafeUnwrap(), + })._unsafeUnwrap(); + + const updatedTable = configuredTable + .update((mutator) => mutator.addField(newField)) + ._unsafeUnwrap().table; + + const hiddenEntry = updatedTable.views()[0]?.columnMeta()._unsafeUnwrap().toDto()[ + newField.id().toString() + ]; + const defaultEntry = updatedTable.views()[1]?.columnMeta()._unsafeUnwrap().toDto()[ + newField.id().toString() + ]; + + expect(hiddenEntry?.hidden).toBe(true); + expect( + updatedTable.views()[0]?.columnMeta()._unsafeUnwrap().toDto()[hiddenFieldKey]?.hidden + ).toBe(false); + expect(defaultEntry?.hidden).toBeUndefined(); + }); + + it('shows newly inserted field in the target grid view even when that view hides other fields', () => { + const newFieldId = createFieldId('j')._unsafeUnwrap(); + const builder = Table.builder() + .withBaseId(createBaseId('j')._unsafeUnwrap()) + .withName(TableName.create('Insert View Visibility')._unsafeUnwrap()); + + builder.field().singleLineText().withName(FieldName.create('Title')._unsafeUnwrap()).done(); + builder.field().singleLineText().withName(FieldName.create('Notes')._unsafeUnwrap()).done(); + builder.view().grid().withName(ViewName.create('View A')._unsafeUnwrap()).done(); + builder.view().grid().withName(ViewName.create('View B')._unsafeUnwrap()).done(); + + const table = builder.build()._unsafeUnwrap(); + const notesField = table.getFields().find((field) => field.name().toString() === 'Notes'); + const targetView = table.views()[0]; + + expect(notesField).toBeTruthy(); + expect(targetView).toBeTruthy(); + if (!notesField || !targetView) return; + + const targetViewMeta = targetView.columnMeta()._unsafeUnwrap().toDto(); + const configuredTargetMeta = ViewColumnMeta.create({ + ...targetViewMeta, + [notesField.id().toString()]: { + ...(targetViewMeta[notesField.id().toString()] ?? {}), + hidden: true, + }, + })._unsafeUnwrap(); + + const configuredTable = TableUpdateViewColumnMetaSpec.create([ + { + viewId: targetView.id(), + fieldId: notesField.id(), + columnMeta: configuredTargetMeta, + }, + ]) + .mutate(table) + ._unsafeUnwrap(); + + const newField = SingleLineTextField.create({ + id: newFieldId, + name: FieldName.create('Inserted')._unsafeUnwrap(), + })._unsafeUnwrap(); + + const updatedTable = configuredTable + .update((mutator) => + mutator.addField(newField, { + viewOrder: { + viewId: targetView.id(), + order: 1.5, + }, + }) + ) + ._unsafeUnwrap().table; + + const targetEntry = updatedTable.views()[0]?.columnMeta()._unsafeUnwrap().toDto()[ + newField.id().toString() + ]; + + expect(targetEntry?.hidden).toBeUndefined(); + expect(targetEntry?.order).toBe(1.5); + }); + it('rejects adding a field with duplicate dbFieldName', () => { const baseIdResult = createBaseId('d'); const tableNameResult = TableName.create('Duplicate DbFieldName'); diff --git a/packages/v2/core/src/domain/table/Table.ts b/packages/v2/core/src/domain/table/Table.ts index 1123434eb7..554bc9f6b2 100644 --- a/packages/v2/core/src/domain/table/Table.ts +++ b/packages/v2/core/src/domain/table/Table.ts @@ -68,7 +68,7 @@ import type { TableId } from './TableId'; import { TableMutator, type TableUpdateResult } from './TableMutator'; import type { TableName } from './TableName'; import type { View } from './views/View'; -import { ViewColumnMeta } from './views/ViewColumnMeta'; +import { ViewColumnMeta, type ViewColumnMetaEntry } from './views/ViewColumnMeta'; import type { ViewId } from './views/ViewId'; import { CloneViewVisitor } from './views/visitors/CloneViewVisitor'; @@ -714,6 +714,7 @@ export class Table extends AggregateRoot { options?: { foreignTables?: ReadonlyArray; domainContext?: IDomainContext; + targetViewId?: ViewId; } ): Result { if (this.fieldsValue.some((existing) => existing.id().equals(field.id()))) { @@ -748,7 +749,9 @@ export class Table extends AggregateRoot { if (validationResult.isErr()) return err(validationResult.error); const nextFields = [...this.fieldsValue, field]; - const nextViewsResult = this.cloneViewsWithField(nextFields, field); + const nextViewsResult = this.cloneViewsWithField(nextFields, field, { + targetViewId: options?.targetViewId, + }); if (nextViewsResult.isErr()) return err(nextViewsResult.error); const props: ITableBuildProps = { @@ -1129,7 +1132,10 @@ export class Table extends AggregateRoot { private cloneViewsWithField( fields: ReadonlyArray, - newField: Field + newField: Field, + options?: { + targetViewId?: ViewId; + } ): Result, DomainError> { const defaultMetaByType = new Map(); const newFieldKey = newField.id().toString(); @@ -1161,9 +1167,16 @@ export class Table extends AggregateRoot { ? Math.max(...currentEntries.map((entry) => entry.order ?? -1)) : -1; + const nextEntry = this.buildAddedFieldColumnMetaEntry({ + view, + currentMeta, + defaultEntry, + targetViewId: options?.targetViewId, + }); + const nextMeta = { ...currentMeta, - [newFieldKey]: { ...defaultEntry, order: maxOrder + 1 }, + [newFieldKey]: { ...nextEntry, order: maxOrder + 1 }, }; const nextMetaResult = ViewColumnMeta.create(nextMeta); @@ -1226,4 +1239,33 @@ export class Table extends AggregateRoot { ok([]) ); } + + private buildAddedFieldColumnMetaEntry(params: { + view: View; + currentMeta: Record; + defaultEntry: ViewColumnMetaEntry; + targetViewId?: ViewId; + }): ViewColumnMetaEntry { + const { view, currentMeta, defaultEntry, targetViewId } = params; + + if (targetViewId && view.id().equals(targetViewId)) { + return { ...defaultEntry }; + } + + if (view.type().toString() !== 'grid') { + return { ...defaultEntry }; + } + + const hasExplicitHiddenVisibilityConfig = Object.values(currentMeta).some((entry) => + Object.prototype.hasOwnProperty.call(entry, 'hidden') + ); + if (!hasExplicitHiddenVisibilityConfig) { + return { ...defaultEntry }; + } + + return { + ...defaultEntry, + hidden: true, + }; + } } diff --git a/packages/v2/core/src/domain/table/TableMutator.ts b/packages/v2/core/src/domain/table/TableMutator.ts index 3a68cb7e84..097b18998d 100644 --- a/packages/v2/core/src/domain/table/TableMutator.ts +++ b/packages/v2/core/src/domain/table/TableMutator.ts @@ -24,7 +24,6 @@ import { TableUpdateViewColumnMetaSpec } from './specs/TableUpdateViewColumnMeta import { TableEventGeneratingSpecVisitor } from './specs/visitors/TableEventGeneratingSpecVisitor'; import type { Table } from './Table'; import type { TableName } from './TableName'; -import { ViewColumnMeta } from './views/ViewColumnMeta'; import type { ViewId } from './views/ViewId'; class TableMutateSpecBuilder extends SpecBuilder { @@ -60,7 +59,11 @@ class TableMutateSpecBuilder extends SpecBuilder { if (!options?.viewOrder) { - return TableUpdateViewColumnMetaSpec.fromTableWithFieldId( - nextTableResult.value, - field.id() - ); + return TableUpdateViewColumnMetaSpec.fromTableWithFieldId(nextTable, field.id()); } - const viewResult = nextTableResult.value.getView(options.viewOrder.viewId); - if (viewResult.isErr()) { - return err(viewResult.error); - } - - const columnMetaResult = viewResult.value.columnMeta(); - if (columnMetaResult.isErr()) { - return err(columnMetaResult.error); - } - - const fieldId = field.id(); - const fieldIdStr = fieldId.toString(); - const currentMeta = columnMetaResult.value.toDto(); - const nextMetaResult = ViewColumnMeta.create({ - ...currentMeta, - [fieldIdStr]: { - ...(currentMeta[fieldIdStr] ?? {}), - order: options.viewOrder.order, - }, + return TableUpdateViewColumnMetaSpec.forFieldPlacement({ + table: nextTable, + fieldId: field.id(), + targetViewId: options.viewOrder.viewId, + order: options.viewOrder.order, }); - if (nextMetaResult.isErr()) { - return err(nextMetaResult.error); - } - - return ok( - TableUpdateViewColumnMetaSpec.create([ - { - viewId: options.viewOrder.viewId, - fieldId, - columnMeta: nextMetaResult.value, - }, - ]) - ); })(); if (viewSpecResult.isErr()) { this.recordError(viewSpecResult.error); @@ -293,6 +266,7 @@ class TableMutateSpecBuilder extends SpecBuilder { + const { table, fieldId, targetViewId, order } = params; + + return this.fromTableWithFieldId(table, fieldId).andThen((spec) => { + const fieldKey = fieldId.toString(); + const updatesResult = spec.updates().reduce>( + (acc, update) => + acc.andThen((updates) => { + if (!update.viewId.equals(targetViewId)) { + return ok([...updates, update]); + } + + return ViewColumnMeta.create({ + ...update.columnMeta.toDto(), + [fieldKey]: { + ...(update.columnMeta.toDto()[fieldKey] ?? {}), + order, + }, + }).map((columnMeta) => [ + ...updates, + { + ...update, + columnMeta, + }, + ]); + }), + ok([]) + ); + + return updatesResult.map((updates) => new TableUpdateViewColumnMetaSpec(updates)); + }); + } + static fromTableWithFieldIds( table: Table, fieldIds: ReadonlyArray diff --git a/packages/v2/core/src/domain/table/specs/__tests__/TableUpdateViewColumnMetaSpec.spec.ts b/packages/v2/core/src/domain/table/specs/__tests__/TableUpdateViewColumnMetaSpec.spec.ts index ef8e8b1499..4971270ca6 100644 --- a/packages/v2/core/src/domain/table/specs/__tests__/TableUpdateViewColumnMetaSpec.spec.ts +++ b/packages/v2/core/src/domain/table/specs/__tests__/TableUpdateViewColumnMetaSpec.spec.ts @@ -3,8 +3,11 @@ import { describe, expect, it } from 'vitest'; import { BaseId } from '../../../base/BaseId'; import { FieldId } from '../../fields/FieldId'; import { FieldName } from '../../fields/FieldName'; +import { SingleLineTextField } from '../../fields/types/SingleLineTextField'; import { Table } from '../../Table'; import { TableName } from '../../TableName'; +import { ViewColumnMeta } from '../../views/ViewColumnMeta'; +import { ViewName } from '../../views/ViewName'; import { TableUpdateViewColumnMetaSpec } from '../TableUpdateViewColumnMetaSpec'; const createBaseId = (seed: string) => BaseId.create(`bse${seed.repeat(16)}`)._unsafeUnwrap(); @@ -59,4 +62,77 @@ describe('TableUpdateViewColumnMetaSpec', () => { expect(typeof duplicatedOrder).toBe('number'); expect((duplicatedOrder as number) > (sourceOrder as number)).toBe(true); }); + + it('keeps non-target view visibility updates while overriding target view placement', () => { + const baseId = createBaseId('b'); + const builder = Table.builder() + .withBaseId(baseId) + .withName(TableName.create('Create Field View Order')._unsafeUnwrap()); + + builder + .field() + .singleLineText() + .withName(FieldName.create('Title')._unsafeUnwrap()) + .primary() + .done(); + builder.field().singleLineText().withName(FieldName.create('Notes')._unsafeUnwrap()).done(); + builder.view().grid().withName(ViewName.create('View A')._unsafeUnwrap()).done(); + builder.view().grid().withName(ViewName.create('View B')._unsafeUnwrap()).done(); + + const table = builder.build()._unsafeUnwrap(); + const notesField = table.getFields().find((field) => field.name().toString() === 'Notes'); + const viewA = table.views().find((view) => view.name().toString() === 'View A'); + const viewB = table.views().find((view) => view.name().toString() === 'View B'); + + expect(notesField).toBeTruthy(); + expect(viewA).toBeTruthy(); + expect(viewB).toBeTruthy(); + if (!notesField || !viewA || !viewB) return; + + const configuredTable = TableUpdateViewColumnMetaSpec.create([ + { + viewId: viewA.id(), + fieldId: notesField.id(), + columnMeta: ViewColumnMeta.create({ + ...viewA.columnMeta()._unsafeUnwrap().toDto(), + [notesField.id().toString()]: { + ...(viewA.columnMeta()._unsafeUnwrap().toDto()[notesField.id().toString()] ?? {}), + hidden: false, + }, + })._unsafeUnwrap(), + }, + ]) + .mutate(table) + ._unsafeUnwrap(); + + const newField = SingleLineTextField.create({ + id: FieldId.mustGenerate(), + name: FieldName.create('Inserted')._unsafeUnwrap(), + })._unsafeUnwrap(); + + const nextTable = configuredTable + .addField(newField, { + targetViewId: viewB.id(), + }) + ._unsafeUnwrap(); + + const spec = TableUpdateViewColumnMetaSpec.forFieldPlacement({ + table: nextTable, + fieldId: newField.id(), + targetViewId: viewB.id(), + order: 2.5, + })._unsafeUnwrap(); + + expect(spec.updates()).toHaveLength(2); + + const viewAUpdate = spec.updates().find((update) => update.viewId.equals(viewA.id())); + const viewBUpdate = spec.updates().find((update) => update.viewId.equals(viewB.id())); + expect(viewAUpdate).toBeTruthy(); + expect(viewBUpdate).toBeTruthy(); + if (!viewAUpdate || !viewBUpdate) return; + + expect(viewAUpdate.columnMeta.toDto()[newField.id().toString()]?.hidden).toBe(true); + expect(viewBUpdate.columnMeta.toDto()[newField.id().toString()]?.hidden).toBeUndefined(); + expect(viewBUpdate.columnMeta.toDto()[newField.id().toString()]?.order).toBe(2.5); + }); }); diff --git a/packages/v2/core/src/ports/TableRepository.ts b/packages/v2/core/src/ports/TableRepository.ts index 3df7d524b5..c2de943a68 100644 --- a/packages/v2/core/src/ports/TableRepository.ts +++ b/packages/v2/core/src/ports/TableRepository.ts @@ -20,8 +20,15 @@ export type FieldVersionChange = { newVersion: number; }; +export type ViewVersionChange = { + viewId: string; + oldVersion: number; + newVersion: number; +}; + export type TableUpdatePersistResult = { fieldVersionChanges?: ReadonlyArray; + viewVersionChanges?: ReadonlyArray; }; export type TableDeleteMode = 'soft' | 'permanent'; diff --git a/packages/v2/e2e/src/createField.e2e.spec.ts b/packages/v2/e2e/src/createField.e2e.spec.ts index 348945869f..fca6ade0b7 100644 --- a/packages/v2/e2e/src/createField.e2e.spec.ts +++ b/packages/v2/e2e/src/createField.e2e.spec.ts @@ -189,6 +189,95 @@ describe('v2 http createField (e2e)', () => { expect(JSON.parse(row?.ai_config ?? 'null')).toEqual(aiConfig); }); + it('keeps hidden grid view visibility stable when another view adds a field', async () => { + const titleFieldId = createFieldId(); + const notesFieldId = createFieldId(); + const newFieldId = createFieldId(); + + const table = await createTable({ + baseId: ctx.baseId, + name: 'Hidden View Stability E2E', + fields: [ + { type: 'singleLineText', id: titleFieldId, name: 'Name', isPrimary: true }, + { type: 'singleLineText', id: notesFieldId, name: 'Notes' }, + ], + views: [ + { type: 'grid', name: 'View A' }, + { type: 'grid', name: 'View B' }, + ], + }); + + try { + const viewA = table.views.find((view) => view.name === 'View A'); + const viewB = table.views.find((view) => view.name === 'View B'); + expect(viewA).toBeTruthy(); + expect(viewB).toBeTruthy(); + if (!viewA || !viewB) return; + + const viewAMeta = { ...(viewA.columnMeta as Record) }; + viewAMeta[notesFieldId] = { + ...(viewAMeta[notesFieldId] ?? {}), + hidden: false, + }; + + await ctx.testContainer.db + .updateTable('view') + .set({ column_meta: JSON.stringify(viewAMeta) }) + .where('id', '=', viewA.id) + .execute(); + + const response = await fetch(`${ctx.baseUrl}/tables/createField`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + baseId: ctx.baseId, + tableId: table.id, + field: { + id: newFieldId, + type: 'singleLineText', + name: 'Extra', + }, + }), + }); + + expect(response.status).toBe(200); + const rawBody = await response.json(); + const parsed = createFieldOkResponseSchema.safeParse(rawBody); + expect(parsed.success).toBe(true); + if (!parsed.success || !parsed.data.ok) return; + + const responseTable = parsed.data.data.table; + const responseViewA = responseTable.views.find((view) => view.id === viewA.id); + const responseViewB = responseTable.views.find((view) => view.id === viewB.id); + const responseViewAMeta = responseViewA?.columnMeta as + | Record + | undefined; + const responseViewBMeta = responseViewB?.columnMeta as + | Record + | undefined; + + expect(responseViewAMeta?.[newFieldId]?.hidden).toBe(true); + expect(responseViewAMeta?.[notesFieldId]?.hidden).toBe(false); + expect(responseViewBMeta?.[newFieldId]?.hidden).toBeUndefined(); + + const fetchedTable = await getTableById(table.id); + const fetchedViewA = fetchedTable.views.find((view) => view.id === viewA.id); + const fetchedViewB = fetchedTable.views.find((view) => view.id === viewB.id); + const fetchedViewAMeta = fetchedViewA?.columnMeta as + | Record + | undefined; + const fetchedViewBMeta = fetchedViewB?.columnMeta as + | Record + | undefined; + + expect(fetchedViewAMeta?.[newFieldId]?.hidden).toBe(true); + expect(fetchedViewAMeta?.[notesFieldId]?.hidden).toBe(false); + expect(fetchedViewBMeta?.[newFieldId]?.hidden).toBeUndefined(); + } finally { + await ctx.deleteTable(table.id).catch(() => undefined); + } + }); + it('creates search index for new searchable field when table search indexing is enabled', async () => { const trgmAvailable = await sql<{ available: boolean }>` SELECT EXISTS ( diff --git a/packages/v2/e2e/src/realtimeShareDb.e2e.spec.ts b/packages/v2/e2e/src/realtimeShareDb.e2e.spec.ts index 1719d33b1c..98740f2975 100644 --- a/packages/v2/e2e/src/realtimeShareDb.e2e.spec.ts +++ b/packages/v2/e2e/src/realtimeShareDb.e2e.spec.ts @@ -291,6 +291,7 @@ const deleteShareDbBackendDoc = async (params: { describe('v2 realtime sharedb (e2e)', () => { let server: Server | undefined; let shareDbRuntime: ShareDbRuntime | undefined; + let testContainer: Awaited> | undefined; let baseUrl: string; let shareDbUrl: string; let dispose: (() => Promise) | undefined; @@ -315,7 +316,7 @@ describe('v2 realtime sharedb (e2e)', () => { shareDbRuntime = runtime; shareDbUrl = `ws://127.0.0.1:${runtime.port}/socket`; - const testContainer = await createV2NodeTestContainer(); + testContainer = await createV2NodeTestContainer(); registerRealtime(testContainer.container, runtime); dispose = testContainer.dispose; baseId = testContainer.baseId.toString(); @@ -434,6 +435,129 @@ describe('v2 realtime sharedb (e2e)', () => { expect(snapshot.type).toBe('singleLineText'); }); + it('updates subscribed view docs when another view creates a hidden-by-default field', async () => { + if (!testContainer) { + throw new Error('Missing test container'); + } + + const notesFieldId = createFieldId(); + const newFieldId = createFieldId(); + const createTableResponse = await fetch(`${baseUrl}/tables/create`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + baseId, + name: 'Realtime View Visibility', + fields: [ + { type: 'singleLineText', name: 'Name' }, + { type: 'singleLineText', id: notesFieldId, name: 'Notes' }, + ], + views: [ + { type: 'grid', name: 'View A' }, + { type: 'grid', name: 'View B' }, + ], + } satisfies ICreateTableCommandInput), + }); + + expect(createTableResponse.status).toBe(201); + const createTableRaw = await createTableResponse.json(); + const createTableParsed = createTableOkResponseSchema.safeParse(createTableRaw); + expect(createTableParsed.success).toBe(true); + if (!createTableParsed.success || !createTableParsed.data.ok) return; + + const table = createTableParsed.data.data.table; + const viewA = table.views.find((view) => view.name === 'View A'); + const viewB = table.views.find((view) => view.name === 'View B'); + expect(viewA).toBeTruthy(); + expect(viewB).toBeTruthy(); + if (!viewA || !viewB) return; + + const viewAMeta = { + ...(viewA.columnMeta as Record), + }; + viewAMeta[notesFieldId] = { + ...(viewAMeta[notesFieldId] ?? {}), + hidden: false, + }; + + await testContainer.db + .updateTable('view') + .set({ column_meta: JSON.stringify(viewAMeta) }) + .where('id', '=', viewA.id) + .execute(); + + const socket = new WebSocket(shareDbUrl); + const connection = new Connection(socket as Socket); + const doc = connection.get(`viw_${table.id}`, viewA.id) as Doc< + ITablePersistenceDTO['views'][number] + >; + + try { + const subscribeError = await new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(new Error('ShareDB view doc subscribe timed out')); + }, 5000); + + doc.subscribe((error) => { + clearTimeout(timeout); + if (error) { + resolve(new Error(error.message)); + return; + } + resolve(undefined); + }); + }); + + expect(subscribeError).toBeUndefined(); + if (subscribeError) return; + + const createFieldResponse = await fetch(`${baseUrl}/tables/createField`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + baseId, + tableId: table.id, + field: { + type: 'singleLineText', + id: newFieldId, + name: 'Created From View B', + }, + order: { + viewId: viewB.id, + orderIndex: 2.5, + }, + }), + }); + + expect(createFieldResponse.status).toBe(200); + const createFieldRaw = await createFieldResponse.json(); + const createFieldParsed = createFieldOkResponseSchema.safeParse(createFieldRaw); + expect(createFieldParsed.success).toBe(true); + if (!createFieldParsed.success || !createFieldParsed.data.ok) return; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('ShareDB view doc did not receive updated columnMeta')); + }, 5000); + + const interval = setInterval(() => { + if (doc.data?.columnMeta?.[newFieldId]?.hidden === true) { + clearInterval(interval); + clearTimeout(timeout); + resolve(); + } + }, 50); + }); + + expect(doc.data?.columnMeta?.[newFieldId]?.hidden).toBe(true); + expect(doc.data?.columnMeta?.[notesFieldId]?.hidden).toBe(false); + } finally { + doc.destroy(); + connection.close(); + socket.close(); + } + }); + it('publishes field updates to ShareDB over websocket', async () => { const createTableResponse = await fetch(`${baseUrl}/tables/create`, { method: 'POST', diff --git a/packages/v2/test-node/src/commands/CreateFieldHandler.db.spec.ts b/packages/v2/test-node/src/commands/CreateFieldHandler.db.spec.ts index 36f9c2fe92..aebcde6738 100644 --- a/packages/v2/test-node/src/commands/CreateFieldHandler.db.spec.ts +++ b/packages/v2/test-node/src/commands/CreateFieldHandler.db.spec.ts @@ -499,6 +499,113 @@ describe('CreateFieldHandler (db)', () => { expect(updatedViewMetaDb).toEqual(updatedViewMeta); }); + it('defaults new field to hidden in grid views with explicit visibility config', async () => { + const { container, baseId } = getV2NodeTestContainer(); + const commandBus = container.resolve(v2CoreTokens.commandBus); + const db = container.resolve>(v2PostgresDbTokens.db); + + const actorIdResult = ActorId.create('system'); + actorIdResult._unsafeUnwrap(); + const context = { actorId: actorIdResult._unsafeUnwrap() }; + + const titleFieldId = FieldId.mustGenerate().toString(); + const notesFieldId = FieldId.mustGenerate().toString(); + const newFieldId = FieldId.mustGenerate().toString(); + + const createTableResult = CreateTableCommand.create({ + baseId: baseId.toString(), + name: 'Hidden View Stability', + fields: [ + { type: 'singleLineText', id: titleFieldId, name: 'Name', isPrimary: true }, + { type: 'singleLineText', id: notesFieldId, name: 'Notes' }, + ], + views: [ + { type: 'grid', name: 'View A' }, + { type: 'grid', name: 'View B' }, + ], + }); + createTableResult._unsafeUnwrap(); + + const createdTable = ( + await commandBus.execute( + context, + createTableResult._unsafeUnwrap() + ) + )._unsafeUnwrap().table; + + const viewA = createdTable.views().find((view) => view.name().toString() === 'View A'); + const viewB = createdTable.views().find((view) => view.name().toString() === 'View B'); + expect(viewA).toBeTruthy(); + expect(viewB).toBeTruthy(); + if (!viewA || !viewB) return; + + const viewARow = await db + .selectFrom('view') + .select(['column_meta']) + .where('id', '=', viewA.id().toString()) + .executeTakeFirst(); + expect(viewARow).toBeTruthy(); + if (!viewARow) return; + + const viewAMeta = JSON.parse(viewARow.column_meta ?? '{}') as Record< + string, + { order?: number; hidden?: boolean } + >; + viewAMeta[notesFieldId] = { + ...(viewAMeta[notesFieldId] ?? {}), + hidden: false, + }; + + await db + .updateTable('view') + .set({ column_meta: JSON.stringify(viewAMeta) }) + .where('id', '=', viewA.id().toString()) + .execute(); + + const createFieldResult = CreateFieldCommand.create({ + baseId: baseId.toString(), + tableId: createdTable.id().toString(), + field: { + id: newFieldId, + type: 'singleLineText', + name: 'Extra', + }, + }); + createFieldResult._unsafeUnwrap(); + + const updatedTable = ( + await commandBus.execute( + context, + createFieldResult._unsafeUnwrap() + ) + )._unsafeUnwrap().table; + + const updatedViewA = updatedTable.getView(viewA.id())._unsafeUnwrap(); + const updatedViewB = updatedTable.getView(viewB.id())._unsafeUnwrap(); + const updatedViewAMeta = updatedViewA.columnMeta()._unsafeUnwrap().toDto(); + const updatedViewBMeta = updatedViewB.columnMeta()._unsafeUnwrap().toDto(); + + expect(updatedViewAMeta[newFieldId]?.hidden).toBe(true); + expect(updatedViewAMeta[notesFieldId]?.hidden).toBe(false); + expect(updatedViewBMeta[newFieldId]?.hidden).toBeUndefined(); + + const persistedRows = await db + .selectFrom('view') + .select(['id', 'column_meta']) + .where('id', 'in', [viewA.id().toString(), viewB.id().toString()]) + .execute(); + const persistedMetaByViewId = new Map( + persistedRows.map((row) => [ + row.id, + JSON.parse(row.column_meta ?? '{}') as Record, + ]) + ); + + expect(persistedMetaByViewId.get(viewA.id().toString())?.[newFieldId]?.hidden).toBe(true); + expect(persistedMetaByViewId.get(viewA.id().toString())?.[notesFieldId]?.hidden).toBe(false); + expect(persistedMetaByViewId.get(viewB.id().toString())?.[newFieldId]?.hidden).toBeUndefined(); + }); + it('persists and rehydrates aiConfig on field create', async () => { const { container, baseId } = getV2NodeTestContainer(); const commandBus = container.resolve(v2CoreTokens.commandBus);