refactor(base): rename baseId to pageId across WS, processors, tasks, formula, events

Renames baseId → pageId in:
- Domain event types (BaseEventBase) and all derived event types
- WS Zod schemas (wire-protocol field names now emit pageId)
- BaseWsService, BaseWsConsumers, BasePresenceService
- FormulaLockService, FormulaService
- BullMQ job interfaces (IBaseTypeConversionJob, IBaseCellGcJob, IBaseFormulaRecomputeJob)
- BaseQueueProcessor and all task functions
- Service emit-payload keys in base.service, base-property.service,
  base-row.service, base-view.service, base-csv-export.service
- Formula spec test fixtures
This commit is contained in:
Philipinho
2026-04-27 01:38:11 +01:00
parent 78d450a238
commit 25dfb44774
17 changed files with 145 additions and 145 deletions
@@ -8,7 +8,7 @@ import { BaseProperty, BaseRow, BaseView } from '@docmost/db/types/entity.types'
*/ */
type BaseEventBase = { type BaseEventBase = {
baseId: string; pageId: string;
workspaceId: string; workspaceId: string;
actorId?: string | null; actorId?: string | null;
requestId?: string | null; requestId?: string | null;
@@ -5,7 +5,7 @@ const mkProp = (
id: string, type: string, typeOptions: any = {}, id: string, type: string, typeOptions: any = {},
name = id, name = id,
): BaseProperty => ({ ): BaseProperty => ({
id, baseId: "base_1", name, type: type as any, position: "a", id, pageId: "base_1", name, type: type as any, position: "a",
typeOptions, isPrimary: false, workspaceId: "ws_1", typeOptions, isPrimary: false, workspaceId: "ws_1",
createdAt: new Date(), updatedAt: new Date(), createdAt: new Date(), updatedAt: new Date(),
schemaVersion: 0, pendingType: null, pendingTypeOptions: null, schemaVersion: 0, pendingType: null, pendingTypeOptions: null,
@@ -18,10 +18,10 @@ export class FormulaLockService {
* Returns a release token on success, or null if the lock is held. Callers * Returns a release token on success, or null if the lock is held. Callers
* must pass the token back to release() to prevent cross-holder releases. * must pass the token back to release() to prevent cross-holder releases.
*/ */
async acquire(baseId: string): Promise<string | null> { async acquire(pageId: string): Promise<string | null> {
const token = `${Date.now()}-${Math.random()}`; const token = `${Date.now()}-${Math.random()}`;
const ok = await this.redis.set( const ok = await this.redis.set(
LOCK_PREFIX + baseId, LOCK_PREFIX + pageId,
token, token,
"PX", "PX",
LOCK_TTL_MS, LOCK_TTL_MS,
@@ -30,7 +30,7 @@ export class FormulaLockService {
return ok === "OK" ? token : null; return ok === "OK" ? token : null;
} }
async release(baseId: string, token: string): Promise<void> { async release(pageId: string, token: string): Promise<void> {
const lua = ` const lua = `
if redis.call("GET", KEYS[1]) == ARGV[1] then if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1]) return redis.call("DEL", KEYS[1])
@@ -38,7 +38,7 @@ export class FormulaLockService {
return 0 return 0
end end
`; `;
await this.redis.eval(lua, 1, LOCK_PREFIX + baseId, token); await this.redis.eval(lua, 1, LOCK_PREFIX + pageId, token);
} }
/* /*
@@ -46,11 +46,11 @@ export class FormulaLockService {
* on timeout. Workers call this at job start — if acquisition times out * on timeout. Workers call this at job start — if acquisition times out
* the job is retried by BullMQ. * the job is retried by BullMQ.
*/ */
async acquireWait(baseId: string, opts: { timeoutMs: number; pollMs?: number }): Promise<string | null> { async acquireWait(pageId: string, opts: { timeoutMs: number; pollMs?: number }): Promise<string | null> {
const deadline = Date.now() + opts.timeoutMs; const deadline = Date.now() + opts.timeoutMs;
const poll = opts.pollMs ?? 500; const poll = opts.pollMs ?? 500;
while (Date.now() < deadline) { while (Date.now() < deadline) {
const t = await this.acquire(baseId); const t = await this.acquire(pageId);
if (t) return t; if (t) return t;
await new Promise((r) => setTimeout(r, poll)); await new Promise((r) => setTimeout(r, poll));
} }
@@ -113,13 +113,13 @@ export class FormulaService {
/* /*
* Enqueue a full recompute for the given formula property IDs on the given * Enqueue a full recompute for the given formula property IDs on the given
* base. Reasons let the worker log why the job ran. Job ID includes baseId * base. Reasons let the worker log why the job ran. Job ID includes pageId
* so BullMQ will dedupe when the same base has multiple edits in flight — * so BullMQ will dedupe when the same base has multiple edits in flight —
* see FormulaLock for the per-base Redis serialization. * see FormulaLock for the per-base Redis serialization.
*/ */
async enqueueRecompute(args: IBaseFormulaRecomputeJob): Promise<void> { async enqueueRecompute(args: IBaseFormulaRecomputeJob): Promise<void> {
await this.queue.add(QueueJob.BASE_FORMULA_RECOMPUTE, args, { await this.queue.add(QueueJob.BASE_FORMULA_RECOMPUTE, args, {
jobId: `formula-recompute:${args.baseId}:${Date.now()}`, jobId: `formula-recompute:${args.pageId}:${Date.now()}`,
removeOnComplete: 1000, removeOnComplete: 1000,
removeOnFail: 1000, removeOnFail: 1000,
}); });
@@ -69,7 +69,7 @@ export class BaseQueueProcessor
trx, trx,
); );
await this.basePropertyRepo.bumpSchemaVersion(data.propertyId, trx); await this.basePropertyRepo.bumpSchemaVersion(data.propertyId, trx);
const v = await this.baseRepo.bumpSchemaVersion(data.baseId, trx); const v = await this.baseRepo.bumpSchemaVersion(data.pageId, trx);
return { summary: s, schemaVersion: v }; return { summary: s, schemaVersion: v };
}, },
); );
@@ -80,7 +80,7 @@ export class BaseQueueProcessor
const updated = await this.basePropertyRepo.findById(data.propertyId); const updated = await this.basePropertyRepo.findById(data.propertyId);
if (updated) { if (updated) {
const event: BasePropertyUpdatedEvent = { const event: BasePropertyUpdatedEvent = {
baseId: data.baseId, pageId: data.pageId,
workspaceId: data.workspaceId, workspaceId: data.workspaceId,
actorId: data.actorId ?? null, actorId: data.actorId ?? null,
requestId: null, requestId: null,
@@ -90,7 +90,7 @@ export class BaseQueueProcessor
this.eventEmitter.emit(EventName.BASE_PROPERTY_UPDATED, event); this.eventEmitter.emit(EventName.BASE_PROPERTY_UPDATED, event);
} }
this.emitSchemaBumped( this.emitSchemaBumped(
data.baseId, data.pageId,
data.workspaceId, data.workspaceId,
schemaVersion, schemaVersion,
data.actorId, data.actorId,
@@ -106,24 +106,24 @@ export class BaseQueueProcessor
data, data,
); );
const schemaVersion = await this.baseRepo.bumpSchemaVersion( const schemaVersion = await this.baseRepo.bumpSchemaVersion(
data.baseId, data.pageId,
); );
this.emitSchemaBumped(data.baseId, data.workspaceId, schemaVersion); this.emitSchemaBumped(data.pageId, data.workspaceId, schemaVersion);
return; return;
} }
case QueueJob.BASE_FORMULA_RECOMPUTE: { case QueueJob.BASE_FORMULA_RECOMPUTE: {
const data = job.data as IBaseFormulaRecomputeJob; const data = job.data as IBaseFormulaRecomputeJob;
const token = await this.formulaLock.acquireWait(data.baseId, { const token = await this.formulaLock.acquireWait(data.pageId, {
timeoutMs: 30_000, timeoutMs: 30_000,
}); });
if (!token) { if (!token) {
throw new Error( throw new Error(
`formula recompute: lock acquire timeout for base ${data.baseId}`, `formula recompute: lock acquire timeout for base ${data.pageId}`,
); );
} }
try { try {
this.eventEmitter.emit(EventName.BASE_FORMULA_RECOMPUTE_STARTED, { this.eventEmitter.emit(EventName.BASE_FORMULA_RECOMPUTE_STARTED, {
baseId: data.baseId, pageId: data.pageId,
workspaceId: data.workspaceId, workspaceId: data.workspaceId,
actorId: data.actorId ?? null, actorId: data.actorId ?? null,
requestId: null, requestId: null,
@@ -140,7 +140,7 @@ export class BaseQueueProcessor
progress: (processed) => job.updateProgress({ processed }), progress: (processed) => job.updateProgress({ processed }),
onBatch: async (batch) => { onBatch: async (batch) => {
this.eventEmitter.emit(EventName.BASE_ROWS_UPDATED, { this.eventEmitter.emit(EventName.BASE_ROWS_UPDATED, {
baseId: data.baseId, pageId: data.pageId,
workspaceId: data.workspaceId, workspaceId: data.workspaceId,
actorId: null, actorId: null,
requestId: null, requestId: null,
@@ -152,10 +152,10 @@ export class BaseQueueProcessor
); );
const schemaVersion = await this.baseRepo.bumpSchemaVersion( const schemaVersion = await this.baseRepo.bumpSchemaVersion(
data.baseId, data.pageId,
); );
this.eventEmitter.emit(EventName.BASE_SCHEMA_BUMPED, { this.eventEmitter.emit(EventName.BASE_SCHEMA_BUMPED, {
baseId: data.baseId, pageId: data.pageId,
workspaceId: data.workspaceId, workspaceId: data.workspaceId,
actorId: data.actorId ?? null, actorId: data.actorId ?? null,
requestId: null, requestId: null,
@@ -163,7 +163,7 @@ export class BaseQueueProcessor
} satisfies BaseSchemaBumpedEvent); } satisfies BaseSchemaBumpedEvent);
this.eventEmitter.emit(EventName.BASE_FORMULA_RECOMPUTE_COMPLETED, { this.eventEmitter.emit(EventName.BASE_FORMULA_RECOMPUTE_COMPLETED, {
baseId: data.baseId, pageId: data.pageId,
workspaceId: data.workspaceId, workspaceId: data.workspaceId,
actorId: data.actorId ?? null, actorId: data.actorId ?? null,
requestId: null, requestId: null,
@@ -175,7 +175,7 @@ export class BaseQueueProcessor
return result; return result;
} finally { } finally {
await this.formulaLock.release(data.baseId, token); await this.formulaLock.release(data.pageId, token);
} }
} }
default: default:
@@ -184,13 +184,13 @@ export class BaseQueueProcessor
} }
private emitSchemaBumped( private emitSchemaBumped(
baseId: string, pageId: string,
workspaceId: string, workspaceId: string,
schemaVersion: number, schemaVersion: number,
actorId?: string, actorId?: string,
): void { ): void {
const event: BaseSchemaBumpedEvent = { const event: BaseSchemaBumpedEvent = {
baseId, pageId,
workspaceId, workspaceId,
actorId: actorId ?? null, actorId: actorId ?? null,
requestId: null, requestId: null,
@@ -220,7 +220,7 @@ export class BaseQueueProcessor
const reverted = await this.basePropertyRepo.findById(data.propertyId); const reverted = await this.basePropertyRepo.findById(data.propertyId);
if (reverted) { if (reverted) {
const event: BasePropertyUpdatedEvent = { const event: BasePropertyUpdatedEvent = {
baseId: data.baseId, pageId: data.pageId,
workspaceId: data.workspaceId, workspaceId: data.workspaceId,
actorId: data.actorId ?? null, actorId: data.actorId ?? null,
requestId: null, requestId: null,
@@ -14,7 +14,7 @@ export type PresenceEntry = {
}; };
/* /*
* Ephemeral per-base presence. No DB. `presence:base:{baseId}` is a Redis * Ephemeral per-base presence. No DB. `presence:base:{pageId}` is a Redis
* HASH keyed by userId with a JSON-serialised entry. Entries older than * HASH keyed by userId with a JSON-serialised entry. Entries older than
* PRESENCE_ENTRY_TTL_MS are filtered on read; the key itself is refreshed * PRESENCE_ENTRY_TTL_MS are filtered on read; the key itself is refreshed
* with a longer Redis EXPIRE on every write so unused rooms drain on * with a longer Redis EXPIRE on every write so unused rooms drain on
@@ -30,10 +30,10 @@ export class BasePresenceService {
} }
async setPresence( async setPresence(
baseId: string, pageId: string,
entry: PresenceEntry, entry: PresenceEntry,
): Promise<void> { ): Promise<void> {
const key = PRESENCE_KEY_PREFIX + baseId; const key = PRESENCE_KEY_PREFIX + pageId;
await this.redis await this.redis
.multi() .multi()
.hset(key, entry.userId, JSON.stringify(entry)) .hset(key, entry.userId, JSON.stringify(entry))
@@ -41,13 +41,13 @@ export class BasePresenceService {
.exec(); .exec();
} }
async leave(baseId: string, userId: string): Promise<void> { async leave(pageId: string, userId: string): Promise<void> {
const key = PRESENCE_KEY_PREFIX + baseId; const key = PRESENCE_KEY_PREFIX + pageId;
await this.redis.hdel(key, userId); await this.redis.hdel(key, userId);
} }
async snapshot(baseId: string): Promise<PresenceEntry[]> { async snapshot(pageId: string): Promise<PresenceEntry[]> {
const key = PRESENCE_KEY_PREFIX + baseId; const key = PRESENCE_KEY_PREFIX + pageId;
const raw = await this.redis.hgetall(key); const raw = await this.redis.hgetall(key);
const now = Date.now(); const now = Date.now();
const out: PresenceEntry[] = []; const out: PresenceEntry[] = [];
@@ -23,7 +23,7 @@ import {
/* /*
* In-process listeners that forward base domain events onto the * In-process listeners that forward base domain events onto the
* `base-{baseId}` socket.io room. Originating clients suppress their own * `base-{pageId}` socket.io room. Originating clients suppress their own
* echoes via `requestId`. * echoes via `requestId`.
*/ */
@Injectable() @Injectable()
@@ -34,9 +34,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_ROW_CREATED) @OnEvent(EventName.BASE_ROW_CREATED)
onRowCreated(e: BaseRowCreatedEvent) { onRowCreated(e: BaseRowCreatedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:row:created', operation: 'base:row:created',
baseId: e.baseId, pageId: e.pageId,
row: e.row, row: e.row,
actorId: e.actorId ?? null, actorId: e.actorId ?? null,
requestId: e.requestId ?? null, requestId: e.requestId ?? null,
@@ -45,9 +45,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_ROW_UPDATED) @OnEvent(EventName.BASE_ROW_UPDATED)
onRowUpdated(e: BaseRowUpdatedEvent) { onRowUpdated(e: BaseRowUpdatedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:row:updated', operation: 'base:row:updated',
baseId: e.baseId, pageId: e.pageId,
rowId: e.rowId, rowId: e.rowId,
patch: e.patch, patch: e.patch,
updatedCells: e.updatedCells, updatedCells: e.updatedCells,
@@ -58,9 +58,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_ROW_DELETED) @OnEvent(EventName.BASE_ROW_DELETED)
onRowDeleted(e: BaseRowDeletedEvent) { onRowDeleted(e: BaseRowDeletedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:row:deleted', operation: 'base:row:deleted',
baseId: e.baseId, pageId: e.pageId,
rowId: e.rowId, rowId: e.rowId,
actorId: e.actorId ?? null, actorId: e.actorId ?? null,
requestId: e.requestId ?? null, requestId: e.requestId ?? null,
@@ -69,9 +69,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_ROWS_DELETED) @OnEvent(EventName.BASE_ROWS_DELETED)
onRowsDeleted(e: BaseRowsDeletedEvent) { onRowsDeleted(e: BaseRowsDeletedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:rows:deleted', operation: 'base:rows:deleted',
baseId: e.baseId, pageId: e.pageId,
rowIds: e.rowIds, rowIds: e.rowIds,
actorId: e.actorId ?? null, actorId: e.actorId ?? null,
requestId: e.requestId ?? null, requestId: e.requestId ?? null,
@@ -80,9 +80,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_ROW_REORDERED) @OnEvent(EventName.BASE_ROW_REORDERED)
onRowReordered(e: BaseRowReorderedEvent) { onRowReordered(e: BaseRowReorderedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:row:reordered', operation: 'base:row:reordered',
baseId: e.baseId, pageId: e.pageId,
rowId: e.rowId, rowId: e.rowId,
position: e.position, position: e.position,
actorId: e.actorId ?? null, actorId: e.actorId ?? null,
@@ -92,9 +92,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_PROPERTY_CREATED) @OnEvent(EventName.BASE_PROPERTY_CREATED)
onPropertyCreated(e: BasePropertyCreatedEvent) { onPropertyCreated(e: BasePropertyCreatedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:property:created', operation: 'base:property:created',
baseId: e.baseId, pageId: e.pageId,
property: e.property, property: e.property,
actorId: e.actorId ?? null, actorId: e.actorId ?? null,
requestId: e.requestId ?? null, requestId: e.requestId ?? null,
@@ -103,9 +103,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_PROPERTY_UPDATED) @OnEvent(EventName.BASE_PROPERTY_UPDATED)
onPropertyUpdated(e: BasePropertyUpdatedEvent) { onPropertyUpdated(e: BasePropertyUpdatedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:property:updated', operation: 'base:property:updated',
baseId: e.baseId, pageId: e.pageId,
property: e.property, property: e.property,
schemaVersion: e.schemaVersion, schemaVersion: e.schemaVersion,
actorId: e.actorId ?? null, actorId: e.actorId ?? null,
@@ -115,9 +115,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_PROPERTY_DELETED) @OnEvent(EventName.BASE_PROPERTY_DELETED)
onPropertyDeleted(e: BasePropertyDeletedEvent) { onPropertyDeleted(e: BasePropertyDeletedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:property:deleted', operation: 'base:property:deleted',
baseId: e.baseId, pageId: e.pageId,
propertyId: e.propertyId, propertyId: e.propertyId,
actorId: e.actorId ?? null, actorId: e.actorId ?? null,
requestId: e.requestId ?? null, requestId: e.requestId ?? null,
@@ -126,9 +126,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_PROPERTY_REORDERED) @OnEvent(EventName.BASE_PROPERTY_REORDERED)
onPropertyReordered(e: BasePropertyReorderedEvent) { onPropertyReordered(e: BasePropertyReorderedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:property:reordered', operation: 'base:property:reordered',
baseId: e.baseId, pageId: e.pageId,
propertyId: e.propertyId, propertyId: e.propertyId,
position: e.position, position: e.position,
actorId: e.actorId ?? null, actorId: e.actorId ?? null,
@@ -138,9 +138,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_VIEW_CREATED) @OnEvent(EventName.BASE_VIEW_CREATED)
onViewCreated(e: BaseViewCreatedEvent) { onViewCreated(e: BaseViewCreatedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:view:created', operation: 'base:view:created',
baseId: e.baseId, pageId: e.pageId,
view: e.view, view: e.view,
actorId: e.actorId ?? null, actorId: e.actorId ?? null,
requestId: e.requestId ?? null, requestId: e.requestId ?? null,
@@ -149,9 +149,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_VIEW_UPDATED) @OnEvent(EventName.BASE_VIEW_UPDATED)
onViewUpdated(e: BaseViewUpdatedEvent) { onViewUpdated(e: BaseViewUpdatedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:view:updated', operation: 'base:view:updated',
baseId: e.baseId, pageId: e.pageId,
view: e.view, view: e.view,
actorId: e.actorId ?? null, actorId: e.actorId ?? null,
requestId: e.requestId ?? null, requestId: e.requestId ?? null,
@@ -160,9 +160,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_VIEW_DELETED) @OnEvent(EventName.BASE_VIEW_DELETED)
onViewDeleted(e: BaseViewDeletedEvent) { onViewDeleted(e: BaseViewDeletedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:view:deleted', operation: 'base:view:deleted',
baseId: e.baseId, pageId: e.pageId,
viewId: e.viewId, viewId: e.viewId,
actorId: e.actorId ?? null, actorId: e.actorId ?? null,
requestId: e.requestId ?? null, requestId: e.requestId ?? null,
@@ -171,18 +171,18 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_SCHEMA_BUMPED) @OnEvent(EventName.BASE_SCHEMA_BUMPED)
onSchemaBumped(e: BaseSchemaBumpedEvent) { onSchemaBumped(e: BaseSchemaBumpedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:schema:bumped', operation: 'base:schema:bumped',
baseId: e.baseId, pageId: e.pageId,
schemaVersion: e.schemaVersion, schemaVersion: e.schemaVersion,
}); });
} }
@OnEvent(EventName.BASE_ROWS_UPDATED) @OnEvent(EventName.BASE_ROWS_UPDATED)
onRowsUpdated(e: BaseRowsUpdatedEvent) { onRowsUpdated(e: BaseRowsUpdatedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:rows:updated', operation: 'base:rows:updated',
baseId: e.baseId, pageId: e.pageId,
rowIds: e.rowIds, rowIds: e.rowIds,
propertyIds: e.propertyIds, propertyIds: e.propertyIds,
actorId: e.actorId ?? null, actorId: e.actorId ?? null,
@@ -192,9 +192,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_FORMULA_RECOMPUTE_STARTED) @OnEvent(EventName.BASE_FORMULA_RECOMPUTE_STARTED)
onFormulaRecomputeStarted(e: BaseFormulaRecomputeStartedEvent) { onFormulaRecomputeStarted(e: BaseFormulaRecomputeStartedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:formula:recompute:started', operation: 'base:formula:recompute:started',
baseId: e.baseId, pageId: e.pageId,
propertyIds: e.propertyIds, propertyIds: e.propertyIds,
jobId: e.jobId, jobId: e.jobId,
actorId: e.actorId ?? null, actorId: e.actorId ?? null,
@@ -203,9 +203,9 @@ export class BaseWsConsumers {
@OnEvent(EventName.BASE_FORMULA_RECOMPUTE_COMPLETED) @OnEvent(EventName.BASE_FORMULA_RECOMPUTE_COMPLETED)
onFormulaRecomputeCompleted(e: BaseFormulaRecomputeCompletedEvent) { onFormulaRecomputeCompleted(e: BaseFormulaRecomputeCompletedEvent) {
this.ws.emitToBase(e.baseId, { this.ws.emitToBase(e.pageId, {
operation: 'base:formula:recompute:completed', operation: 'base:formula:recompute:completed',
baseId: e.baseId, pageId: e.pageId,
propertyIds: e.propertyIds, propertyIds: e.propertyIds,
jobId: e.jobId, jobId: e.jobId,
processed: e.processed, processed: e.processed,
@@ -9,29 +9,29 @@ import { BasePresenceService, PresenceEntry } from './base-presence.service';
/* /*
* Inbound shapes from untrusted socket clients. Zod-validated at the * Inbound shapes from untrusted socket clients. Zod-validated at the
* boundary so malformed payloads (non-uuid baseId, missing fields, * boundary so malformed payloads (non-uuid pageId, missing fields,
* oversized selection blobs) never reach the permission check or Redis. * oversized selection blobs) never reach the permission check or Redis.
*/ */
const baseSubscribeSchema = z.object({ const baseSubscribeSchema = z.object({
operation: z.literal('base:subscribe'), operation: z.literal('base:subscribe'),
baseId: z.uuid(), pageId: z.uuid(),
}); });
const baseUnsubscribeSchema = z.object({ const baseUnsubscribeSchema = z.object({
operation: z.literal('base:unsubscribe'), operation: z.literal('base:unsubscribe'),
baseId: z.uuid(), pageId: z.uuid(),
}); });
const basePresenceSchema = z.object({ const basePresenceSchema = z.object({
operation: z.literal('base:presence'), operation: z.literal('base:presence'),
baseId: z.uuid(), pageId: z.uuid(),
cellId: z.string().max(200).optional().nullable(), cellId: z.string().max(200).optional().nullable(),
selection: z.unknown().optional(), selection: z.unknown().optional(),
}); });
const basePresenceLeaveSchema = z.object({ const basePresenceLeaveSchema = z.object({
operation: z.literal('base:presence:leave'), operation: z.literal('base:presence:leave'),
baseId: z.uuid(), pageId: z.uuid(),
}); });
const inboundSchema = z.union([ const inboundSchema = z.union([
@@ -77,23 +77,23 @@ export class BaseWsService {
const data = parsed.data; const data = parsed.data;
switch (data.operation) { switch (data.operation) {
case 'base:subscribe': case 'base:subscribe':
await this.subscribe(client, data.baseId); await this.subscribe(client, data.pageId);
return; return;
case 'base:unsubscribe': case 'base:unsubscribe':
await this.unsubscribe(client, data.baseId); await this.unsubscribe(client, data.pageId);
return; return;
case 'base:presence': case 'base:presence':
await this.handlePresence(client, data); await this.handlePresence(client, data);
return; return;
case 'base:presence:leave': case 'base:presence:leave':
await this.handlePresenceLeave(client, data.baseId); await this.handlePresenceLeave(client, data.pageId);
return; return;
} }
} }
emitToBase(baseId: string, payload: BaseOutbound): void { emitToBase(pageId: string, payload: BaseOutbound): void {
if (!this.server) return; if (!this.server) return;
this.server.to(getBaseRoomName(baseId)).emit('message', payload); this.server.to(getBaseRoomName(pageId)).emit('message', payload);
} }
/* /*
@@ -106,11 +106,11 @@ export class BaseWsService {
const userId = client.data?.userId as string | undefined; const userId = client.data?.userId as string | undefined;
const subs = this.subscriptionsFor(client); const subs = this.subscriptionsFor(client);
if (!userId || subs.size === 0) return; if (!userId || subs.size === 0) return;
for (const baseId of subs) { for (const pageId of subs) {
await this.presence.leave(baseId, userId); await this.presence.leave(pageId, userId);
this.emitToBase(baseId, { this.emitToBase(pageId, {
operation: 'base:presence:leave', operation: 'base:presence:leave',
baseId, pageId,
userId, userId,
}); });
} }
@@ -119,22 +119,22 @@ export class BaseWsService {
// --- private ------------------------------------------------------- // --- private -------------------------------------------------------
private async subscribe(client: Socket, baseId: string): Promise<void> { private async subscribe(client: Socket, pageId: string): Promise<void> {
const userId = client.data?.userId as string | undefined; const userId = client.data?.userId as string | undefined;
if (!userId) { if (!userId) {
client.emit('message', { client.emit('message', {
operation: 'base:subscribe:error', operation: 'base:subscribe:error',
baseId, pageId,
reason: 'unauthenticated', reason: 'unauthenticated',
}); });
return; return;
} }
const base = await this.baseRepo.findById(baseId); const base = await this.baseRepo.findById(pageId);
if (!base) { if (!base) {
client.emit('message', { client.emit('message', {
operation: 'base:subscribe:error', operation: 'base:subscribe:error',
baseId, pageId,
reason: 'not_found', reason: 'not_found',
}); });
return; return;
@@ -144,36 +144,36 @@ export class BaseWsService {
if (!canRead) { if (!canRead) {
client.emit('message', { client.emit('message', {
operation: 'base:subscribe:error', operation: 'base:subscribe:error',
baseId, pageId,
reason: 'forbidden', reason: 'forbidden',
}); });
return; return;
} }
client.join(getBaseRoomName(baseId)); client.join(getBaseRoomName(pageId));
this.subscriptionsFor(client).add(baseId); this.subscriptionsFor(client).add(pageId);
// Send the current presence snapshot to just this client so their UI // Send the current presence snapshot to just this client so their UI
// can paint who's already editing what. // can paint who's already editing what.
const snapshot = await this.presence.snapshot(baseId); const snapshot = await this.presence.snapshot(pageId);
client.emit('message', { client.emit('message', {
operation: 'base:presence:snapshot', operation: 'base:presence:snapshot',
baseId, pageId,
entries: snapshot, entries: snapshot,
}); });
} }
private async unsubscribe(client: Socket, baseId: string): Promise<void> { private async unsubscribe(client: Socket, pageId: string): Promise<void> {
const userId = client.data?.userId as string | undefined; const userId = client.data?.userId as string | undefined;
if (!userId) return; if (!userId) return;
client.leave(getBaseRoomName(baseId)); client.leave(getBaseRoomName(pageId));
this.subscriptionsFor(client).delete(baseId); this.subscriptionsFor(client).delete(pageId);
await this.presence.leave(baseId, userId); await this.presence.leave(pageId, userId);
this.emitToBase(baseId, { this.emitToBase(pageId, {
operation: 'base:presence:leave', operation: 'base:presence:leave',
baseId, pageId,
userId, userId,
}); });
} }
@@ -184,7 +184,7 @@ export class BaseWsService {
): Promise<void> { ): Promise<void> {
const userId = client.data?.userId as string | undefined; const userId = client.data?.userId as string | undefined;
if (!userId) return; if (!userId) return;
if (!client.rooms.has(getBaseRoomName(data.baseId))) return; if (!client.rooms.has(getBaseRoomName(data.pageId))) return;
const entry: PresenceEntry = { const entry: PresenceEntry = {
userId, userId,
@@ -192,25 +192,25 @@ export class BaseWsService {
selection: data.selection ?? null, selection: data.selection ?? null,
ts: Date.now(), ts: Date.now(),
}; };
await this.presence.setPresence(data.baseId, entry); await this.presence.setPresence(data.pageId, entry);
this.emitToBase(data.baseId, { this.emitToBase(data.pageId, {
operation: 'base:presence', operation: 'base:presence',
baseId: data.baseId, pageId: data.pageId,
...entry, ...entry,
}); });
} }
private async handlePresenceLeave( private async handlePresenceLeave(
client: Socket, client: Socket,
baseId: string, pageId: string,
): Promise<void> { ): Promise<void> {
const userId = client.data?.userId as string | undefined; const userId = client.data?.userId as string | undefined;
if (!userId) return; if (!userId) return;
await this.presence.leave(baseId, userId); await this.presence.leave(pageId, userId);
this.emitToBase(baseId, { this.emitToBase(pageId, {
operation: 'base:presence:leave', operation: 'base:presence:leave',
baseId, pageId,
userId, userId,
}); });
} }
@@ -31,16 +31,16 @@ export class BaseCsvExportService {
) {} ) {}
async streamBaseAsCsv( async streamBaseAsCsv(
baseId: string, pageId: string,
workspaceId: string, workspaceId: string,
reply: FastifyReply, reply: FastifyReply,
): Promise<void> { ): Promise<void> {
const base = await this.baseRepo.findById(baseId); const base = await this.baseRepo.findById(pageId);
if (!base || base.workspaceId !== workspaceId) { if (!base || base.workspaceId !== workspaceId) {
throw new NotFoundException('Base not found'); throw new NotFoundException('Base not found');
} }
const properties = await this.basePropertyRepo.findByPageId(baseId); const properties = await this.basePropertyRepo.findByPageId(pageId);
const fileName = sanitize(base.title || 'base') + '.csv'; const fileName = sanitize(base.title || 'base') + '.csv';
@@ -85,7 +85,7 @@ export class BaseCsvExportService {
}); });
try { try {
for await (const chunk of this.baseRowRepo.streamByPageId(baseId, { for await (const chunk of this.baseRowRepo.streamByPageId(pageId, {
workspaceId, workspaceId,
chunkSize: CHUNK_SIZE, chunkSize: CHUNK_SIZE,
})) { })) {
@@ -129,7 +129,7 @@ export class BaseCsvExportService {
// trigger Nest's exception filter to try to send another // trigger Nest's exception filter to try to send another
// response, which Fastify rejects. Destroying the stringifier // response, which Fastify rejects. Destroying the stringifier
// cascades to `out` and signals EOF to the client. // cascades to `out` and signals EOF to the client.
this.logger.error(`csv export failed base=${baseId}`, err); this.logger.error(`csv export failed base=${pageId}`, err);
stringifier.destroy(err as Error); stringifier.destroy(err as Error);
} }
} }
@@ -134,7 +134,7 @@ export class BasePropertyService {
}); });
const event: BasePropertyCreatedEvent = { const event: BasePropertyCreatedEvent = {
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
actorId: actorId ?? null, actorId: actorId ?? null,
requestId: null, requestId: null,
@@ -144,7 +144,7 @@ export class BasePropertyService {
if (created.type === 'formula') { if (created.type === 'formula') {
await this.formulaService.enqueueRecompute({ await this.formulaService.enqueueRecompute({
baseId: created.pageId, pageId: created.pageId,
workspaceId, workspaceId,
propertyIds: [created.id], propertyIds: [created.id],
reason: 'formula_created', reason: 'formula_created',
@@ -278,7 +278,7 @@ export class BasePropertyService {
if (newType === 'formula' && (isTypeChange || sourceChanged)) { if (newType === 'formula' && (isTypeChange || sourceChanged)) {
await this.formulaService.enqueueRecompute({ await this.formulaService.enqueueRecompute({
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
propertyIds: [dto.propertyId], propertyIds: [dto.propertyId],
reason: isTypeChange ? 'formula_created' : 'formula_edited', reason: isTypeChange ? 'formula_created' : 'formula_edited',
@@ -292,7 +292,7 @@ export class BasePropertyService {
const affected = graph.affectedFormulas([dto.propertyId]); const affected = graph.affectedFormulas([dto.propertyId]);
if (affected.length > 0) { if (affected.length > 0) {
await this.formulaService.enqueueRecompute({ await this.formulaService.enqueueRecompute({
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
propertyIds: affected, propertyIds: affected,
reason: 'dep_type_changed', reason: 'dep_type_changed',
@@ -306,7 +306,7 @@ export class BasePropertyService {
// --- Path 2 or 3: cell rewrite needed ------------------------------- // --- Path 2 or 3: cell rewrite needed -------------------------------
const conversionPayload: IBaseTypeConversionJob = { const conversionPayload: IBaseTypeConversionJob = {
baseId: dto.pageId, pageId: dto.pageId,
propertyId: dto.propertyId, propertyId: dto.propertyId,
workspaceId, workspaceId,
fromType: oldType, fromType: oldType,
@@ -360,7 +360,7 @@ export class BasePropertyService {
}); });
tick('inline-tx-done'); tick('inline-tx-done');
const bumpEvent: BaseSchemaBumpedEvent = { const bumpEvent: BaseSchemaBumpedEvent = {
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
actorId: actorId ?? null, actorId: actorId ?? null,
requestId: null, requestId: null,
@@ -454,7 +454,7 @@ export class BasePropertyService {
const updated = await this.basePropertyRepo.findById(dto.propertyId); const updated = await this.basePropertyRepo.findById(dto.propertyId);
if (updated) { if (updated) {
const event: BasePropertyUpdatedEvent = { const event: BasePropertyUpdatedEvent = {
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
actorId: actorId ?? null, actorId: actorId ?? null,
requestId: dto.requestId ?? null, requestId: dto.requestId ?? null,
@@ -515,7 +515,7 @@ export class BasePropertyService {
}); });
const payload: IBaseCellGcJob = { const payload: IBaseCellGcJob = {
baseId: dto.pageId, pageId: dto.pageId,
propertyId: dto.propertyId, propertyId: dto.propertyId,
workspaceId, workspaceId,
}; };
@@ -542,7 +542,7 @@ export class BasePropertyService {
} }
const event: BasePropertyDeletedEvent = { const event: BasePropertyDeletedEvent = {
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
actorId: actorId ?? null, actorId: actorId ?? null,
requestId: dto.requestId ?? null, requestId: dto.requestId ?? null,
@@ -552,7 +552,7 @@ export class BasePropertyService {
if (affected.length > 0) { if (affected.length > 0) {
await this.formulaService.enqueueRecompute({ await this.formulaService.enqueueRecompute({
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
propertyIds: affected, propertyIds: affected,
reason: 'dep_deleted', reason: 'dep_deleted',
@@ -580,7 +580,7 @@ export class BasePropertyService {
}); });
const event: BasePropertyReorderedEvent = { const event: BasePropertyReorderedEvent = {
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
actorId: actorId ?? null, actorId: actorId ?? null,
requestId: dto.requestId ?? null, requestId: dto.requestId ?? null,
@@ -112,7 +112,7 @@ export class BaseRowService {
}); });
const event: BaseRowCreatedEvent = { const event: BaseRowCreatedEvent = {
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
actorId: userId, actorId: userId,
requestId: dto.requestId ?? null, requestId: dto.requestId ?? null,
@@ -162,7 +162,7 @@ export class BaseRowService {
} }
const event: BaseRowUpdatedEvent = { const event: BaseRowUpdatedEvent = {
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
actorId: userId ?? null, actorId: userId ?? null,
requestId: dto.requestId ?? null, requestId: dto.requestId ?? null,
@@ -187,7 +187,7 @@ export class BaseRowService {
}); });
const event: BaseRowDeletedEvent = { const event: BaseRowDeletedEvent = {
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
actorId: userId ?? null, actorId: userId ?? null,
requestId: dto.requestId ?? null, requestId: dto.requestId ?? null,
@@ -215,7 +215,7 @@ export class BaseRowService {
}); });
const event: BaseRowsDeletedEvent = { const event: BaseRowsDeletedEvent = {
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
actorId: userId ?? null, actorId: userId ?? null,
requestId: dto.requestId ?? null, requestId: dto.requestId ?? null,
@@ -310,7 +310,7 @@ export class BaseRowService {
}); });
const event: BaseRowReorderedEvent = { const event: BaseRowReorderedEvent = {
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
actorId: userId ?? null, actorId: userId ?? null,
requestId: dto.requestId ?? null, requestId: dto.requestId ?? null,
@@ -52,7 +52,7 @@ export class BaseViewService {
}); });
const event: BaseViewCreatedEvent = { const event: BaseViewCreatedEvent = {
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
actorId: userId, actorId: userId,
requestId: null, requestId: null,
@@ -101,7 +101,7 @@ export class BaseViewService {
if (updated) { if (updated) {
const event: BaseViewUpdatedEvent = { const event: BaseViewUpdatedEvent = {
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
actorId: userId ?? null, actorId: userId ?? null,
requestId: null, requestId: null,
@@ -133,7 +133,7 @@ export class BaseViewService {
await this.baseViewRepo.deleteView(dto.viewId, { workspaceId }); await this.baseViewRepo.deleteView(dto.viewId, { workspaceId });
const event: BaseViewDeletedEvent = { const event: BaseViewDeletedEvent = {
baseId: dto.pageId, pageId: dto.pageId,
workspaceId, workspaceId,
actorId: userId ?? null, actorId: userId ?? null,
requestId: null, requestId: null,
@@ -107,13 +107,13 @@ export class BaseService {
return this.baseRepo.findById(dto.pageId); return this.baseRepo.findById(dto.pageId);
} }
async delete(baseId: string) { async delete(pageId: string) {
const base = await this.baseRepo.findById(baseId); const base = await this.baseRepo.findById(pageId);
if (!base) { if (!base) {
throw new NotFoundException('Base not found'); throw new NotFoundException('Base not found');
} }
await this.baseRepo.softDelete(baseId); await this.baseRepo.softDelete(pageId);
} }
async listBySpaceId(spaceId: string, pagination: PaginationOptions) { async listBySpaceId(spaceId: string, pagination: PaginationOptions) {
@@ -21,15 +21,15 @@ export async function processBaseCellGc(
basePropertyRepo: BasePropertyRepo, basePropertyRepo: BasePropertyRepo,
data: IBaseCellGcJob, data: IBaseCellGcJob,
): Promise<void> { ): Promise<void> {
const { baseId, propertyId, workspaceId } = data; const { pageId, propertyId, workspaceId } = data;
await executeTx(db, async (trx) => { await executeTx(db, async (trx) => {
await baseRowRepo.removeCellKey(baseId, propertyId, { await baseRowRepo.removeCellKey(pageId, propertyId, {
workspaceId, workspaceId,
trx, trx,
}); });
await basePropertyRepo.hardDelete(propertyId, trx); await basePropertyRepo.hardDelete(propertyId, trx);
}); });
logger.log(`cell-gc complete base=${baseId} prop=${propertyId}`); logger.log(`cell-gc complete base=${pageId} prop=${propertyId}`);
} }
@@ -30,8 +30,8 @@ export async function processBaseFormulaRecompute(
trx?: KyselyTransaction; trx?: KyselyTransaction;
}, },
): Promise<{ processed: number; errored: number }> { ): Promise<{ processed: number; errored: number }> {
const { baseId, workspaceId, propertyIds, rowIds } = data; const { pageId, workspaceId, propertyIds, rowIds } = data;
const properties = await basePropertyRepo.findByPageId(baseId); const properties = await basePropertyRepo.findByPageId(pageId);
const targets = properties.filter( const targets = properties.filter(
(p) => p.type === "formula" && propertyIds.includes(p.id), (p) => p.type === "formula" && propertyIds.includes(p.id),
); );
@@ -46,7 +46,7 @@ export async function processBaseFormulaRecompute(
let processed = 0; let processed = 0;
let errored = 0; let errored = 0;
for await (const chunk of baseRowRepo.streamByPageId(baseId, { for await (const chunk of baseRowRepo.streamByPageId(pageId, {
workspaceId, workspaceId,
chunkSize: CHUNK_SIZE, chunkSize: CHUNK_SIZE,
trx: opts?.trx, trx: opts?.trx,
@@ -90,7 +90,7 @@ export async function processBaseFormulaRecompute(
// so passing actorId: undefined preserves last_updated_by_id while still // so passing actorId: undefined preserves last_updated_by_id while still
// bumping updated_at — matches spec "only lastEditedAt moves". // bumping updated_at — matches spec "only lastEditedAt moves".
await baseRowRepo.batchUpdateCells(updates, { await baseRowRepo.batchUpdateCells(updates, {
pageId: baseId, pageId,
workspaceId, workspaceId,
actorId: undefined, actorId: undefined,
trx: opts?.trx, trx: opts?.trx,
@@ -102,7 +102,7 @@ export async function processBaseFormulaRecompute(
} }
logger.log( logger.log(
`formula-recompute base=${baseId} props=${propertyIds.join(",")} processed=${processed} errored=${errored}`, `formula-recompute base=${pageId} props=${propertyIds.join(",")} processed=${processed} errored=${errored}`,
); );
return { processed, errored }; return { processed, errored };
} }
@@ -37,7 +37,7 @@ export async function processBaseTypeConversion(
}, },
): Promise<{ converted: number; cleared: number; total: number }> { ): Promise<{ converted: number; cleared: number; total: number }> {
const { const {
baseId, pageId,
propertyId, propertyId,
workspaceId, workspaceId,
fromType, fromType,
@@ -59,7 +59,7 @@ export async function processBaseTypeConversion(
// rewriting — everything else is already consistent with the new type // rewriting — everything else is already consistent with the new type
// (empty value → empty value). Skips the full-table scan on bases // (empty value → empty value). Skips the full-table scan on bases
// where the property was only ever set on a few rows. // where the property was only ever set on a few rows.
for await (const chunk of baseRowRepo.streamByPageId(baseId, { for await (const chunk of baseRowRepo.streamByPageId(pageId, {
workspaceId, workspaceId,
chunkSize: CHUNK_SIZE, chunkSize: CHUNK_SIZE,
trx, trx,
@@ -105,7 +105,7 @@ export async function processBaseTypeConversion(
if (updates.length > 0) { if (updates.length > 0) {
await baseRowRepo.batchUpdateCells(updates, { await baseRowRepo.batchUpdateCells(updates, {
pageId: baseId, pageId,
workspaceId, workspaceId,
actorId, actorId,
trx, trx,
@@ -116,7 +116,7 @@ export async function processBaseTypeConversion(
} }
logger.log( logger.log(
`type-conversion ${fromType}${toType} base=${baseId} prop=${propertyId} total=${total} converted=${converted} cleared=${cleared}`, `type-conversion ${fromType}${toType} base=${pageId} prop=${propertyId} total=${total} converted=${converted} cleared=${cleared}`,
); );
return { converted, cleared, total }; return { converted, cleared, total };
@@ -115,7 +115,7 @@ export interface IApprovalRejectedNotificationJob {
} }
export interface IBaseTypeConversionJob { export interface IBaseTypeConversionJob {
baseId: string; pageId: string;
propertyId: string; propertyId: string;
workspaceId: string; workspaceId: string;
fromType: string; fromType: string;
@@ -133,13 +133,13 @@ export interface IBaseTypeConversionJob {
} }
export interface IBaseCellGcJob { export interface IBaseCellGcJob {
baseId: string; pageId: string;
propertyId: string; propertyId: string;
workspaceId: string; workspaceId: string;
} }
export interface IBaseFormulaRecomputeJob { export interface IBaseFormulaRecomputeJob {
baseId: string; pageId: string;
workspaceId: string; workspaceId: string;
propertyIds: string[]; // formula properties to recompute propertyIds: string[]; // formula properties to recompute
reason: reason: