• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

teableio / teable / 20135492339

11 Dec 2025 01:55PM UTC coverage: 71.773% (-0.02%) from 71.792%
20135492339

Pull #2236

github

web-flow
Merge 085ca6f8f into 600755edc
Pull Request #2236: feat: space layout

23046 of 25710 branches covered (89.64%)

54 of 92 new or added lines in 5 files covered. (58.7%)

3 existing lines in 1 file now uncovered.

58134 of 80997 relevant lines covered (71.77%)

4253.25 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.46
/apps/nestjs-backend/src/features/base-node/base-node.listener.ts
1
import { Injectable, Logger } from '@nestjs/common';
5✔
2
import { OnEvent } from '@nestjs/event-emitter';
3
import { generateBaseNodeId, ANONYMOUS_USER_ID, getBaseNodeChannel } from '@teable/core';
4
import { PrismaService } from '@teable/db-main-prisma';
5
import type { Prisma } from '@teable/db-main-prisma';
6
import type {
7
  IBaseNodePresenceCreatePayload,
8
  IBaseNodePresenceDeletePayload,
9
  IBaseNodePresenceFlushPayload,
10
  IBaseNodePresenceUpdatePayload,
11
} from '@teable/openapi';
12
import { BaseNodeResourceType } from '@teable/openapi';
13
import { Knex } from 'knex';
14
import { InjectModel } from 'nest-knexjs';
15
import type { LocalPresence } from 'sharedb/lib/client';
16
import type {
17
  BaseFolderUpdateEvent,
18
  BaseFolderDeleteEvent,
19
  TableDeleteEvent,
20
  TableUpdateEvent,
21
  TableCreateEvent,
22
  BaseFolderCreateEvent,
23
} from '../../event-emitter/events';
24
import type {
25
  AppCreateEvent,
26
  AppDeleteEvent,
27
  AppUpdateEvent,
28
} from '../../event-emitter/events/app/app.event';
29
import type { BaseDeleteEvent } from '../../event-emitter/events/base/base.event';
30
import type {
31
  DashboardCreateEvent,
32
  DashboardDeleteEvent,
33
  DashboardUpdateEvent,
34
} from '../../event-emitter/events/dashboard/dashboard.event';
35
import { Events } from '../../event-emitter/events/event.enum';
36
import type {
37
  WorkflowCreateEvent,
38
  WorkflowDeleteEvent,
39
  WorkflowUpdateEvent,
40
} from '../../event-emitter/events/workflow/workflow.event';
41
import { generateBaseNodeListCacheKey } from '../../performance-cache/generate-keys';
42
import { PerformanceCacheService } from '../../performance-cache/service';
43
import type { IPerformanceCacheStore } from '../../performance-cache/types';
44
import { ShareDbService } from '../../share-db/share-db.service';
45
import { buildBatchUpdateSql } from './helper';
46

47
type IResourceCreateEvent =
48
  | BaseFolderCreateEvent
49
  | TableCreateEvent
50
  | WorkflowCreateEvent
51
  | DashboardCreateEvent
52
  | AppCreateEvent;
53

54
type IResourceDeleteEvent =
55
  | BaseDeleteEvent
56
  | BaseFolderDeleteEvent
57
  | TableDeleteEvent
58
  | WorkflowDeleteEvent
59
  | DashboardDeleteEvent
60
  | AppDeleteEvent;
61

62
type IResourceUpdateEvent =
63
  | BaseFolderUpdateEvent
64
  | TableUpdateEvent
65
  | WorkflowUpdateEvent
66
  | DashboardUpdateEvent
67
  | AppUpdateEvent;
68

69
@Injectable()
70
export class BaseNodeListener {
5✔
71
  private readonly logger = new Logger(BaseNodeListener.name);
127✔
72

73
  constructor(
127✔
74
    private readonly prismaService: PrismaService,
127✔
75
    @InjectModel('CUSTOM_KNEX') private readonly knex: Knex,
127✔
76
    private readonly performanceCacheService: PerformanceCacheService<IPerformanceCacheStore>,
127✔
77
    private readonly shareDbService: ShareDbService
127✔
78
  ) {}
127✔
79

80
  @OnEvent(Events.BASE_FOLDER_CREATE)
127✔
81
  @OnEvent(Events.TABLE_CREATE)
2,584✔
82
  @OnEvent(Events.DASHBOARD_CREATE)
2,584✔
83
  @OnEvent(Events.WORKFLOW_CREATE)
2,584✔
84
  @OnEvent(Events.APP_CREATE)
2,584✔
85
  async onResourceCreate(event: IResourceCreateEvent) {
2,584✔
86
    const { baseId, resourceType, resourceId, userId } = this.prepareResourceCreate(event);
2,584✔
87

88
    if (!baseId || !resourceType || !resourceId) {
2,584✔
89
      this.logger.error('Invalid resource create event', event);
×
90
      return;
×
91
    }
✔
92

93
    const createNode = async (prisma: PrismaService) => {
2,568✔
94
      const findNode = await prisma.baseNode.findFirst({
2,568✔
95
        where: { baseId, resourceType, resourceId },
2,568✔
96
      });
2,568✔
97
      if (findNode) {
2,568✔
98
        return;
131✔
99
      }
131✔
100
      const maxOrder = await this.getMaxOrder(baseId);
2,437✔
101
      await prisma.baseNode.create({
2,437✔
102
        data: {
2,437✔
103
          id: generateBaseNodeId(),
2,437✔
104
          baseId,
2,437✔
105
          resourceType,
2,437✔
106
          resourceId,
2,437✔
107
          parentId: null,
2,437✔
108
          order: maxOrder + 1,
2,437✔
109
          createdBy: userId || ANONYMOUS_USER_ID,
2,469!
110
        },
2,568✔
111
      });
2,568✔
112
    };
2,437✔
113
    await createNode(this.prismaService);
2,568✔
114

115
    this.presenceHandler(baseId, (presence) => {
2,568✔
116
      presence.submit({
2,568✔
117
        event: 'flush',
2,568✔
118
      });
2,568✔
119
    });
2,568✔
120
  }
2,568✔
121

122
  private prepareResourceCreate(event: IResourceCreateEvent) {
127✔
123
    let baseId: string;
2,584✔
124
    let resourceType: BaseNodeResourceType | undefined;
2,584✔
125
    let resourceId: string | undefined;
2,584✔
126
    let name: string | undefined;
2,584✔
127
    let icon: string | undefined;
2,584✔
128
    switch (event.name) {
2,584✔
129
      case Events.BASE_FOLDER_CREATE:
2,584✔
130
        baseId = event.payload.baseId;
70✔
131
        resourceType = BaseNodeResourceType.Folder;
70✔
132
        resourceId = event.payload.folder.id;
70✔
133
        name = event.payload.folder.name;
70✔
134
        break;
70✔
135
      case Events.TABLE_CREATE:
2,584✔
136
        baseId = event.payload.baseId;
2,464✔
137
        resourceType = BaseNodeResourceType.Table;
2,464✔
138
        // get the table id from the table op
2,464✔
139
        resourceId = (event.payload.table as unknown as { id: string }).id;
2,464✔
140
        name = event.payload.table.name;
2,464✔
141
        icon = event.payload.table.icon;
2,464✔
142
        break;
2,464✔
143
      case Events.WORKFLOW_CREATE:
2,584!
144
        baseId = event.payload.baseId;
×
145
        resourceType = BaseNodeResourceType.Workflow;
×
146
        resourceId = event.payload.workflow.id;
×
147
        name = event.payload.workflow.name;
×
148
        break;
×
149
      case Events.DASHBOARD_CREATE:
2,584!
150
        baseId = event.payload.baseId;
35✔
151
        resourceType = BaseNodeResourceType.Dashboard;
35✔
152
        resourceId = event.payload.dashboard.id;
35✔
153
        name = event.payload.dashboard.name;
35✔
154
        break;
35✔
155
      case Events.APP_CREATE:
2,584!
156
        baseId = event.payload.baseId;
×
157
        resourceType = BaseNodeResourceType.App;
×
158
        resourceId = event.payload.app.id;
×
159
        name = event.payload.app.name;
×
160
        break;
×
161
    }
2,584✔
162
    return {
2,583✔
163
      baseId,
2,583✔
164
      resourceType,
2,583✔
165
      resourceId,
2,583✔
166
      name,
2,583✔
167
      icon,
2,583✔
168
      userId: event.context.user?.id,
2,583✔
169
    };
2,584✔
170
  }
2,584✔
171

172
  @OnEvent(Events.BASE_FOLDER_UPDATE)
127✔
173
  @OnEvent(Events.TABLE_UPDATE)
20✔
174
  @OnEvent(Events.DASHBOARD_UPDATE)
20✔
175
  @OnEvent(Events.WORKFLOW_UPDATE)
20✔
176
  @OnEvent(Events.APP_UPDATE)
20✔
177
  async onResourceUpdate(event: IResourceUpdateEvent) {
20✔
178
    const { baseId, resourceType, resourceId } = this.prepareResourceUpdate(event);
20✔
179
    if (baseId && resourceType && resourceId) {
20✔
180
      this.presenceHandler(baseId, (presence) => {
16✔
181
        presence.submit({
16✔
182
          event: 'flush',
16✔
183
        });
16✔
184
      });
16✔
185
    }
16✔
186
  }
20✔
187

188
  private prepareResourceUpdate(event: IResourceUpdateEvent) {
127✔
189
    let baseId: string;
20✔
190
    let resourceType: BaseNodeResourceType | undefined;
20✔
191
    let resourceId: string | undefined;
20✔
192
    let name: string | undefined;
20✔
193
    let icon: string | undefined;
20✔
194
    switch (event.name) {
20✔
195
      case Events.TABLE_UPDATE:
20✔
196
        baseId = event.payload.baseId;
15✔
197
        resourceType = BaseNodeResourceType.Table;
15✔
198
        resourceId = event.payload.table.id;
15✔
199
        name = event.payload.table?.name?.newValue as string;
15!
200
        icon = event.payload.table?.icon?.newValue as string;
15!
201
        break;
15✔
202
      case Events.WORKFLOW_UPDATE:
20!
203
        baseId = event.payload.baseId;
×
204
        resourceType = BaseNodeResourceType.Workflow;
×
205
        resourceId = event.payload.workflow.id;
×
206
        name = event.payload.workflow.name;
×
207
        break;
×
208
      case Events.DASHBOARD_UPDATE:
20!
209
        baseId = event.payload.baseId;
1✔
210
        resourceType = BaseNodeResourceType.Dashboard;
1✔
211
        resourceId = event.payload.dashboard.id;
1✔
212
        name = event.payload.dashboard.name;
1✔
213
        break;
1✔
214
      case Events.APP_UPDATE:
20!
215
        baseId = event.payload.baseId;
×
216
        resourceType = BaseNodeResourceType.App;
×
217
        resourceId = event.payload.app.id;
×
218
        name = event.payload.app.name;
×
219
        break;
×
220
      case Events.BASE_FOLDER_UPDATE:
20!
221
        baseId = event.payload.baseId;
×
222
        resourceType = BaseNodeResourceType.Folder;
×
223
        resourceId = event.payload.folder.id;
×
224
        name = event.payload.folder.name;
×
225
        break;
×
226
    }
20✔
227
    return {
20✔
228
      baseId,
20✔
229
      resourceType,
20✔
230
      resourceId,
20✔
231
      name,
20✔
232
      icon,
20✔
233
    };
20✔
234
  }
20✔
235

236
  @OnEvent(Events.BASE_DELETE)
127✔
237
  @OnEvent(Events.BASE_FOLDER_DELETE)
2,627✔
238
  @OnEvent(Events.TABLE_DELETE)
2,627✔
239
  @OnEvent(Events.DASHBOARD_DELETE)
2,627✔
240
  @OnEvent(Events.WORKFLOW_DELETE)
2,627✔
241
  @OnEvent(Events.APP_DELETE)
2,627✔
242
  async onResourceDelete(event: IResourceDeleteEvent) {
2,627✔
243
    const { baseId, resourceType, resourceId } = this.prepareResourceDelete(event);
2,627✔
244
    if (!baseId) {
2,627!
245
      return;
15✔
246
    }
15✔
247
    if (event.name === Events.BASE_DELETE) {
2,627✔
248
      await this.prismaService.baseNode.deleteMany({
169✔
249
        where: { baseId },
169✔
250
      });
169✔
251
      return;
169✔
252
    }
169✔
253
    if (!resourceType || !resourceId) {
2,627✔
254
      this.logger.error('Invalid resource delete event', event);
16✔
255
      return;
16✔
256
    }
16✔
257

258
    const deleteNode = async (prisma: Prisma.TransactionClient) => {
2,427✔
259
      const toDeleteNode = await prisma.baseNode.findFirst({
2,427✔
260
        where: { baseId, resourceType, resourceId },
2,427✔
261
      });
2,427✔
262
      if (!toDeleteNode) {
2,427!
263
        return;
97✔
264
      }
97✔
265
      await prisma.baseNode.deleteMany({
2,330✔
266
        where: { id: toDeleteNode.id },
2,330✔
267
      });
2,330✔
268
      const maxOrder = await this.getMaxOrder(baseId);
2,326✔
269
      const orphans = await prisma.baseNode.findMany({
2,258✔
270
        where: { baseId, parentId: toDeleteNode.parentId },
2,258✔
271
        select: { id: true, order: true },
2,258✔
272
      });
2,258✔
273
      if (orphans.length > 0) {
2,311✔
274
        await this.batchUpdateBaseNodes(
2,108✔
275
          orphans.map((orphan) => ({
2,108✔
276
            id: orphan.id,
13,398✔
277
            values: {
13,398✔
278
              parentId: null,
13,398✔
279
              order: maxOrder + orphan.order + 1,
13,398✔
280
            },
13,398✔
281
          }))
13,398✔
282
        );
283
      }
2,104✔
284
    };
2,427✔
285
    await deleteNode(this.prismaService);
2,427✔
286

287
    this.presenceHandler(baseId, (presence) => {
2,347✔
288
      presence.submit({
2,347✔
289
        event: 'flush',
2,347✔
290
      });
2,347✔
291
    });
2,347✔
292
  }
2,347✔
293

294
  private prepareResourceDelete(event: IResourceDeleteEvent) {
127✔
295
    let baseId: string;
2,627✔
296
    let resourceType: BaseNodeResourceType | undefined;
2,627✔
297
    let resourceId: string | undefined;
2,627✔
298
    switch (event.name) {
2,627✔
299
      case Events.BASE_DELETE:
2,627✔
300
        baseId = event.payload.baseId;
169✔
301
        break;
169✔
302
      case Events.TABLE_DELETE:
2,627✔
303
        baseId = event.payload.baseId;
2,355✔
304
        resourceType = BaseNodeResourceType.Table;
2,355✔
305
        resourceId = event.payload.tableId;
2,355✔
306
        break;
2,355✔
307
      case Events.WORKFLOW_DELETE:
2,627!
308
        baseId = event.payload.baseId;
×
309
        resourceType = BaseNodeResourceType.Workflow;
×
310
        resourceId = event.payload.workflowId;
×
311
        break;
×
312
      case Events.DASHBOARD_DELETE:
2,627!
313
        baseId = event.payload.baseId;
24✔
314
        resourceType = BaseNodeResourceType.Dashboard;
24✔
315
        resourceId = event.payload.dashboardId;
24✔
316
        break;
24✔
317
      case Events.APP_DELETE:
2,627!
318
        baseId = event.payload.baseId;
×
319
        resourceType = BaseNodeResourceType.App;
×
320
        resourceId = event.payload.appId;
×
321
        break;
×
322
      case Events.BASE_FOLDER_DELETE:
2,627!
323
        baseId = event.payload.baseId;
64✔
324
        resourceType = BaseNodeResourceType.Folder;
64✔
325
        resourceId = event.payload.folderId;
64✔
326
        break;
64✔
327
    }
2,627✔
328
    return {
2,627✔
329
      baseId,
2,627✔
330
      resourceType,
2,627✔
331
      resourceId,
2,627✔
332
    };
2,627✔
333
  }
2,627✔
334

335
  presenceHandler<
127✔
336
    T =
337
      | IBaseNodePresenceFlushPayload
338
      | IBaseNodePresenceCreatePayload
339
      | IBaseNodePresenceUpdatePayload
340
      | IBaseNodePresenceDeletePayload,
341
  >(baseId: string, handler: (presence: LocalPresence<T>) => void) {
4,945✔
342
    this.performanceCacheService.del(generateBaseNodeListCacheKey(baseId));
4,945✔
343
    // Skip if ShareDB connection is already closed (e.g., during shutdown)
4,945✔
344
    if (this.shareDbService.shareDbAdapter.closed) {
4,945!
UNCOV
345
      this.logger.error('ShareDB connection is already closed, presence handler skipped');
×
UNCOV
346
      return;
×
UNCOV
347
    }
×
348
    const channel = getBaseNodeChannel(baseId);
4,945✔
349
    const presence = this.shareDbService.connect().getPresence(channel);
4,945✔
350
    const localPresence = presence.create(channel);
4,945✔
351
    handler(localPresence);
4,945✔
352
    localPresence.destroy();
4,945✔
353
  }
4,945✔
354

355
  async getMaxOrder(baseId: string, parentId?: string | null) {
127✔
356
    const prisma = this.prismaService.txClient();
4,763✔
357
    const aggregate = await prisma.baseNode.aggregate({
4,763✔
358
      where: { baseId, parentId },
4,763✔
359
      _max: { order: true },
4,763✔
360
    });
4,763✔
361

362
    return aggregate._max.order ?? 0;
4,761✔
363
  }
4,763✔
364

365
  async batchUpdateBaseNodes(data: { id: string; values: { [key: string]: unknown } }[]) {
127✔
366
    const sql = buildBatchUpdateSql(this.knex, data);
2,108✔
367
    if (!sql) {
2,108!
368
      return;
×
369
    }
×
370
    await this.prismaService.$executeRawUnsafe(sql);
2,108✔
371
  }
2,104✔
372
}
127✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc