Skip to content

Commit 182dfac

Browse files
[sync] [T2191] fix ai auto update after conditional lookup recompute (#1389) (#2738)
Synced from teableio/teable-ee@f34fca8 Co-authored-by: nichenqin <nichenqin@hotmail.com>
1 parent b13ebbb commit 182dfac

17 files changed

Lines changed: 2293 additions & 445 deletions

‎apps/nestjs-backend/src/features/v2/v2-action-trigger.service.spec.ts‎

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
FieldCreated,
55
FieldId,
66
FieldUpdated,
7+
TableActionTriggerRequested,
78
TableId,
89
type IExecutionContext,
910
type IEventHandler,
@@ -15,6 +16,16 @@ import { V2ActionTriggerService } from './v2-action-trigger.service';
1516

1617
type PresencePayload = Array<{ actionKey: string; payload?: Record<string, unknown> }>;
1718

19+
const waitForPresenceFlush = async () => {
20+
await new Promise<void>((resolve) => {
21+
if (typeof setImmediate === 'function') {
22+
setImmediate(() => resolve());
23+
return;
24+
}
25+
setTimeout(() => resolve(), 0);
26+
});
27+
};
28+
1829
const fieldUpdateSemantics = {
1930
type: {
2031
realtimePath: ['type'],
@@ -102,6 +113,7 @@ describe('V2ActionTriggerService', () => {
102113

103114
const result = await projection?.handle({} as IExecutionContext, event);
104115
expect(result?.isOk()).toBe(true);
116+
await waitForPresenceFlush();
105117

106118
expect(channelSubmitted).toBe(getActionTriggerChannel(tableId.toString()));
107119
expect(submitted).toEqual([
@@ -170,6 +182,7 @@ describe('V2ActionTriggerService', () => {
170182

171183
const result = await projection?.handle({} as IExecutionContext, event);
172184
expect(result?.isOk()).toBe(true);
185+
await waitForPresenceFlush();
173186

174187
expect(channelSubmitted).toBe(getActionTriggerChannel(tableId.toString()));
175188
expect(submitted).toEqual([
@@ -250,6 +263,7 @@ describe('V2ActionTriggerService', () => {
250263

251264
const result = await projection?.handle({} as IExecutionContext, event);
252265
expect(result?.isOk()).toBe(true);
266+
await waitForPresenceFlush();
253267
expect(submitted).toEqual([
254268
{
255269
actionKey: 'setField',
@@ -319,6 +333,182 @@ describe('V2ActionTriggerService', () => {
319333

320334
const result = await projection?.handle({} as IExecutionContext, event);
321335
expect(result?.isOk()).toBe(true);
336+
await waitForPresenceFlush();
322337
expect(submitted).toBeUndefined();
323338
});
339+
340+
it('emits requested action trigger payload for schema-driven presence events', async () => {
341+
let channelSubmitted: string | undefined;
342+
let submitted: PresencePayload | undefined;
343+
344+
const shareDbService = {
345+
connect: () => ({
346+
getPresence: (channel: string) => {
347+
channelSubmitted = channel;
348+
return {
349+
create: () => ({
350+
submit: (data: PresencePayload, cb?: (error?: unknown) => void) => {
351+
submitted = data;
352+
cb?.();
353+
},
354+
}),
355+
};
356+
},
357+
}),
358+
} as unknown as ShareDbService;
359+
360+
const registered: Array<{ instance: unknown }> = [];
361+
const container = {
362+
registerInstance: (_token: unknown, instance: unknown) => {
363+
registered.push({ instance });
364+
return container;
365+
},
366+
} as unknown as DependencyContainer;
367+
368+
const service = new V2ActionTriggerService(shareDbService);
369+
service.registerProjections(container);
370+
371+
const projection = registered.find(
372+
(item) =>
373+
(item.instance as { constructor?: { name?: string } }).constructor?.name ===
374+
'V2TableActionTriggerRequestedProjection'
375+
)?.instance as IEventHandler<TableActionTriggerRequested> | undefined;
376+
377+
expect(projection).toBeDefined();
378+
379+
const { baseId, tableId } = createIds();
380+
const event = TableActionTriggerRequested.create({
381+
baseId,
382+
tableId,
383+
actionKey: 'setRecord',
384+
payload: {
385+
tableId: tableId.toString(),
386+
fieldIds: ['fldSource0000000001', 'fldComputed00000002'],
387+
},
388+
});
389+
390+
const result = await projection?.handle({} as IExecutionContext, event);
391+
expect(result?.isOk()).toBe(true);
392+
await waitForPresenceFlush();
393+
394+
expect(channelSubmitted).toBe(getActionTriggerChannel(tableId.toString()));
395+
expect(submitted).toEqual([
396+
{
397+
actionKey: 'setRecord',
398+
payload: {
399+
tableId: tableId.toString(),
400+
fieldIds: ['fldSource0000000001', 'fldComputed00000002'],
401+
},
402+
},
403+
]);
404+
});
405+
406+
it('batches setField and setRecord into one presence submit for schema-driven updates', async () => {
407+
const submissions: PresencePayload[] = [];
408+
409+
const shareDbService = {
410+
connect: () => ({
411+
getPresence: () => ({
412+
create: () => ({
413+
submit: (data: PresencePayload, cb?: (error?: unknown) => void) => {
414+
submissions.push(data);
415+
cb?.();
416+
},
417+
}),
418+
}),
419+
}),
420+
} as unknown as ShareDbService;
421+
422+
const registered: Array<{ instance: unknown }> = [];
423+
const container = {
424+
registerInstance: (_token: unknown, instance: unknown) => {
425+
registered.push({ instance });
426+
return container;
427+
},
428+
} as unknown as DependencyContainer;
429+
430+
const service = new V2ActionTriggerService(shareDbService);
431+
service.registerProjections(container);
432+
433+
const fieldUpdatedProjection = registered.find(
434+
(item) =>
435+
(item.instance as { constructor?: { name?: string } }).constructor?.name ===
436+
'V2FieldUpdatedActionTriggerProjection'
437+
)?.instance as IEventHandler<FieldUpdated> | undefined;
438+
const actionTriggerProjection = registered.find(
439+
(item) =>
440+
(item.instance as { constructor?: { name?: string } }).constructor?.name ===
441+
'V2TableActionTriggerRequestedProjection'
442+
)?.instance as IEventHandler<TableActionTriggerRequested> | undefined;
443+
444+
expect(fieldUpdatedProjection).toBeDefined();
445+
expect(actionTriggerProjection).toBeDefined();
446+
447+
const { baseId, tableId, fieldId } = createIds();
448+
const fieldUpdatedEvent = FieldUpdated.create({
449+
baseId,
450+
tableId,
451+
fieldId,
452+
updatedProperties: ['type', 'options'],
453+
changes: {
454+
type: { oldValue: 'singleLineText', newValue: 'number' },
455+
options: {
456+
oldValue: { showAs: { type: 'number' } },
457+
newValue: { formatting: { decimal: 0 } },
458+
},
459+
},
460+
propertySemantics: {
461+
type: fieldUpdateSemantics.type,
462+
options: fieldUpdateSemantics.options,
463+
},
464+
});
465+
const setRecordEvent = TableActionTriggerRequested.create({
466+
baseId,
467+
tableId,
468+
actionKey: 'setRecord',
469+
payload: {
470+
tableId: tableId.toString(),
471+
fieldIds: [fieldId.toString()],
472+
},
473+
});
474+
475+
const fieldResult = await fieldUpdatedProjection?.handle(
476+
{} as IExecutionContext,
477+
fieldUpdatedEvent
478+
);
479+
const actionResult = await actionTriggerProjection?.handle(
480+
{} as IExecutionContext,
481+
setRecordEvent
482+
);
483+
expect(fieldResult?.isOk()).toBe(true);
484+
expect(actionResult?.isOk()).toBe(true);
485+
486+
await waitForPresenceFlush();
487+
488+
expect(submissions).toEqual([
489+
[
490+
{
491+
actionKey: 'setField',
492+
payload: {
493+
tableId: tableId.toString(),
494+
field: {
495+
id: fieldId.toString(),
496+
updatedProperties: ['type', 'options'],
497+
type: 'number',
498+
options: {
499+
formatting: { decimal: 0 },
500+
},
501+
},
502+
},
503+
},
504+
{
505+
actionKey: 'setRecord',
506+
payload: {
507+
tableId: tableId.toString(),
508+
fieldIds: [fieldId.toString()],
509+
},
510+
},
511+
],
512+
]);
513+
});
324514
});

‎apps/nestjs-backend/src/features/v2/v2-action-trigger.service.ts‎

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
RecordsBatchCreated,
1212
RecordsBatchUpdated,
1313
RecordsDeleted,
14+
TableActionTriggerRequested,
1415
ProjectionHandler,
1516
ok,
1617
serializeFieldUpdatedValue,
@@ -24,6 +25,12 @@ export interface IActionTriggerData {
2425
payload?: Record<string, unknown>;
2526
}
2627

28+
type PendingActionTriggerBatch = {
29+
shareDbService: ShareDbService;
30+
tableId: string;
31+
data: IActionTriggerData[];
32+
};
33+
2734
const isRecord = (value: unknown): value is Record<string, unknown> =>
2835
value instanceof Object && !Array.isArray(value);
2936

@@ -72,18 +79,52 @@ const buildUpdatedFieldPatch = (event: FieldUpdated): Record<string, unknown> =>
7279

7380
/**
7481
* Helper to emit action triggers via ShareDB presence.
82+
* Batches actions per table to avoid later submits overwriting earlier ones
83+
* within the same schema update turn.
7584
*/
85+
const pendingActionTriggerBatches = new Map<string, PendingActionTriggerBatch>();
86+
let flushScheduled = false;
87+
88+
const deferFlush = (flush: () => void) => {
89+
if (typeof setImmediate === 'function') {
90+
setImmediate(flush);
91+
return;
92+
}
93+
setTimeout(flush, 0);
94+
};
95+
96+
const flushPendingActionTriggers = () => {
97+
flushScheduled = false;
98+
const batches = [...pendingActionTriggerBatches.values()];
99+
pendingActionTriggerBatches.clear();
100+
101+
for (const batch of batches) {
102+
const channel = getActionTriggerChannel(batch.tableId);
103+
const presence = batch.shareDbService.connect().getPresence(channel);
104+
const localPresence = presence.create(batch.tableId);
105+
localPresence.submit(batch.data, (error) => {
106+
if (error) console.error('Action trigger error:', error);
107+
});
108+
}
109+
};
110+
76111
const emitActionTrigger = (
77112
shareDbService: ShareDbService,
78113
tableId: string,
79114
data: IActionTriggerData[]
80115
) => {
81-
const channel = getActionTriggerChannel(tableId);
82-
const presence = shareDbService.connect().getPresence(channel);
83-
const localPresence = presence.create(tableId);
84-
localPresence.submit(data, (error) => {
85-
if (error) console.error('Action trigger error:', error);
86-
});
116+
const pending = pendingActionTriggerBatches.get(tableId) ?? {
117+
shareDbService,
118+
tableId,
119+
data: [],
120+
};
121+
pending.data.push(...data);
122+
pendingActionTriggerBatches.set(tableId, pending);
123+
124+
if (!flushScheduled) {
125+
flushScheduled = true;
126+
deferFlush(flushPendingActionTriggers);
127+
}
87128
};
88129

89130
/**
@@ -265,6 +306,26 @@ class V2FieldUpdatedActionTriggerProjection implements IEventHandler<FieldUpdate
265306
}
266307
}
267308

309+
@ProjectionHandler(TableActionTriggerRequested)
310+
class V2TableActionTriggerRequestedProjection
311+
implements IEventHandler<TableActionTriggerRequested>
312+
{
313+
constructor(private readonly shareDbService: ShareDbService) {}
314+
315+
async handle(
316+
_context: IExecutionContext,
317+
event: TableActionTriggerRequested
318+
): Promise<Result<void, DomainError>> {
319+
emitActionTrigger(this.shareDbService, event.tableId.toString(), [
320+
{
321+
actionKey: event.actionKey,
322+
...(event.payload ? { payload: event.payload } : {}),
323+
},
324+
]);
325+
return ok(undefined);
326+
}
327+
}
328+
268329
/**
269330
* Service that registers V2 action trigger projections with the V2 container.
270331
* These projections emit ShareDB presence events for V1 frontend compatibility.
@@ -329,5 +390,10 @@ export class V2ActionTriggerService {
329390
V2FieldUpdatedActionTriggerProjection,
330391
new V2FieldUpdatedActionTriggerProjection(shareDbService)
331392
);
393+
394+
container.registerInstance(
395+
V2TableActionTriggerRequestedProjection,
396+
new V2TableActionTriggerRequestedProjection(shareDbService)
397+
);
332398
}
333399
}

0 commit comments

Comments
 (0)