mirror of
https://github.com/docmost/docmost.git
synced 2026-07-14 14:36:46 +10:00
feat(ee): bases (#2295)
* feat(ee): bases Table and kanban UI, formula engine package, and the base-embed editor extension. * - default status - type fix - error helper * fix: base trash list handling * feat: base nodeview menu * feat: translation * fix number precision * feat(base): add focused-cell atom and cell coordinate types * feat(base): add cell focus-ring style * feat(base): add pure next-cell navigation helper * feat(base): keyboard navigation controller and grid wiring * update offerings * feat(base): cell focus ring, click-to-focus, and gridcell ARIA * feat(base): row ARIA index and selected state * feat(base): seed editor value on type-to-edit for free-text cells * feat(base): make column headers keyboard-focusable as tab stops * fix(base): remove focus outline on grid container * fix(base): show cell focus ring only while the grid is focused * feat(base): keyboard-navigate the row-number column for selection * fix(base): sync header/body horizontal scroll on header focus; expand row via Space, drop expander from tab order * fix(base): tab from long-text editor moves to next cell instead of leaving the table * fix(base): close view popovers on Escape regardless of focus; drop redundant property switch tab stop * fix(base): show cell focus ring only while the grid body itself is focused * fix(base): render view-tab rename as an inline pill so the tab band height stays put * fix(base): refer to the feature as 'base' rather than 'database' * fix: change permissions object shape * license file * fix tsconfig * fix base cache * fix: preserve sidebar title/icon on partial page updates * fix: skip duplicate row fetch when opening new kanban card * fix refetch * fix focus * fix spacing * fix(base): select grid cell on mousedown to avoid stale focus ring flash The focus ring is gated on the grid having DOM focus (.bodyGrid:focus .cellFocused), but the focusedCell atom is never cleared when the grid blurs. Clicking outside hides the ring via the :focus gate while the atom still points at the old cell. Selection was committed on click (mouseup), while the grid receives focus on mousedown. Clicking a new cell re-focused the grid before the atom updated, briefly painting the ring on the previously selected cell. Commit selection on mousedown so the atom updates in the same event that grants focus, before the browser paints. * fix: activate New row button via keyboard (Enter/Space) The New row control is a role=button div with no keydown handler, so Enter/Space never triggered it. It also lives inside the grid element, whose native keydown listener caught the Enter and ran cell navigation against the previously focused cell. Add Enter/Space activation to the button, and make the grid keyboard handler ignore keydowns that originate from a focusable child rather than the grid element itself, so in-grid controls handle their own keys. * fix(base): keep add-property popover within viewport on mobile Opened from the row detail modal, the create-property popover anchors to the bottom Add property button and flips upward on small screens, clipping its top (name field, formula editor) off-screen with no way to scroll to it. Bound the dropdown to the available height with the floating-ui size middleware and give it an internal scroll container. Disable react-remove-scroll isolation on the modal so the body-portaled popover can scroll on touch while the modal scroll lock stays active. * fix(base): enable grid cell editing on touch devices Cells could only enter edit mode via double-click or a physical keyboard, so touch devices had no way to edit a cell. Treat a touch/pen tap as the edit gesture, distinguishing a tap from a scroll by movement and branching per pointer type so mouse double-click stays unchanged. Also reveal the row expand button on hover-less devices so the row detail view stays reachable. * feat(editor): add base and kanban inserts to the toolbar * feat(base): insert row below via Shift+Enter on the primary cell * fix(base): place caret at end instead of selecting all when editing cells * fix(base): prevent popover inputs from losing focus on mobile in row detail modal * fix grid cells on mobile * sync * fix: read-only export * feat(base): add prefixed nanoid id schemas and generators * feat(base): enforce strict property/choice id validation * feat(base): make property id varchar with per-base composite pk * feat(base): pass property id as text to cell extractors * feat(base): scope property lookups per base and generate property ids in repo * feat(base): generate status template choice ids as nanoid * feat(base): generate choice ids as nanoid on the client * chore(base): seed choice ids with nanoid * fix(base): mint kanban choice ids as nanoid * sync * sync * sync
This commit is contained in:
@@ -44,6 +44,7 @@ import {
|
||||
htmlToMarkdown,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
BaseEmbed,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
@@ -109,6 +110,7 @@ export const tiptapExtensions = [
|
||||
Status,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
BaseEmbed
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
|
||||
@@ -15,4 +15,29 @@ export enum EventName {
|
||||
WORKSPACE_CREATED = 'workspace.created',
|
||||
WORKSPACE_UPDATED = 'workspace.updated',
|
||||
WORKSPACE_DELETED = 'workspace.deleted',
|
||||
|
||||
BASE_CREATED = 'base.created',
|
||||
BASE_UPDATED = 'base.updated',
|
||||
BASE_DELETED = 'base.deleted',
|
||||
|
||||
BASE_ROW_CREATED = 'base.row.created',
|
||||
BASE_ROW_UPDATED = 'base.row.updated',
|
||||
BASE_ROW_DELETED = 'base.row.deleted',
|
||||
BASE_ROWS_DELETED = 'base.rows.deleted',
|
||||
BASE_ROW_RESTORED = 'base.row.restored',
|
||||
BASE_ROW_REORDERED = 'base.row.reordered',
|
||||
|
||||
BASE_PROPERTY_CREATED = 'base.property.created',
|
||||
BASE_PROPERTY_UPDATED = 'base.property.updated',
|
||||
BASE_PROPERTY_DELETED = 'base.property.deleted',
|
||||
BASE_PROPERTY_REORDERED = 'base.property.reordered',
|
||||
|
||||
BASE_VIEW_CREATED = 'base.view.created',
|
||||
BASE_VIEW_UPDATED = 'base.view.updated',
|
||||
BASE_VIEW_DELETED = 'base.view.deleted',
|
||||
|
||||
BASE_SCHEMA_BUMPED = 'base.schema.bumped',
|
||||
BASE_ROWS_UPDATED = 'base.rows.updated',
|
||||
BASE_FORMULA_RECOMPUTE_STARTED = 'base.formula.recompute.started',
|
||||
BASE_FORMULA_RECOMPUTE_COMPLETED = 'base.formula.recompute.completed',
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export const Feature = {
|
||||
PDF_EXPORT: 'export:pdf',
|
||||
PERSONAL_SPACES: 'spaces:personal',
|
||||
DOCX_EXPORT: 'export:docx',
|
||||
BASES: 'bases',
|
||||
} as const;
|
||||
|
||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||
|
||||
@@ -10,6 +10,9 @@ export const LOCAL_STORAGE_PATH = path.resolve(
|
||||
LOCAL_STORAGE_DIR,
|
||||
);
|
||||
|
||||
export function getPageTitle(title: string | null | undefined): string {
|
||||
return title || 'untitled';
|
||||
export function getPageTitle(
|
||||
title: string | null | undefined,
|
||||
isBase?: boolean,
|
||||
): string {
|
||||
return title || (isBase ? 'Untitled base' : 'untitled');
|
||||
}
|
||||
|
||||
@@ -5,4 +5,9 @@ export const nanoIdGen = customAlphabet(alphabet, 10);
|
||||
|
||||
const slugIdAlphabet =
|
||||
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
export const generateSlugId = customAlphabet(slugIdAlphabet, 10);
|
||||
export const generateSlugId = customAlphabet(slugIdAlphabet, 10);
|
||||
|
||||
const baseIdSuffix = customAlphabet(alphabet, 9);
|
||||
|
||||
export const generateBasePropertyId = (): string => `prp${baseIdSuffix()}`;
|
||||
export const generateBaseChoiceId = (): string => `opt${baseIdSuffix()}`;
|
||||
|
||||
@@ -43,7 +43,7 @@ export class AttachmentService {
|
||||
|
||||
async uploadFile(opts: {
|
||||
filePromise: Promise<MultipartFile>;
|
||||
pageId: string;
|
||||
pageId?: string;
|
||||
userId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
executeWithCursorPagination,
|
||||
} from '@docmost/db/pagination/cursor-pagination';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { MovePageDto } from '../dto/move-page.dto';
|
||||
import { generateSlugId } from '../../../common/helpers';
|
||||
@@ -92,6 +92,8 @@ export class PageService {
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
createPageDto: CreatePageDto,
|
||||
trx?: KyselyTransaction,
|
||||
isBase: boolean = false,
|
||||
): Promise<Page> {
|
||||
let parentPageId = undefined;
|
||||
|
||||
@@ -140,21 +142,34 @@ export class PageService {
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
lastUpdatedById: userId,
|
||||
isBase,
|
||||
content,
|
||||
textContent,
|
||||
ydoc,
|
||||
});
|
||||
}, trx);
|
||||
|
||||
this.generalQueue
|
||||
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||
userIds: [userId],
|
||||
pageId: page.id,
|
||||
spaceId: createPageDto.spaceId,
|
||||
if (trx) {
|
||||
// Add the watcher inside the caller's transaction so the async worker
|
||||
// never inserts against an uncommitted page (FK violation on bases).
|
||||
await this.watcherService.addPageWatchers(
|
||||
[userId],
|
||||
page.id,
|
||||
createPageDto.spaceId,
|
||||
workspaceId,
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
|
||||
trx,
|
||||
);
|
||||
} else {
|
||||
this.generalQueue
|
||||
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||
userIds: [userId],
|
||||
pageId: page.id,
|
||||
spaceId: createPageDto.spaceId,
|
||||
workspaceId,
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
|
||||
);
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
@@ -289,6 +304,7 @@ export class PageService {
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'creatorId',
|
||||
'isBase',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.pageRepo.withHasChildren(eb))
|
||||
@@ -832,6 +848,7 @@ export class PageService {
|
||||
'slugId',
|
||||
'title',
|
||||
'icon',
|
||||
'isBase',
|
||||
'position',
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
@@ -847,6 +864,7 @@ export class PageService {
|
||||
'p.slugId',
|
||||
'p.title',
|
||||
'p.icon',
|
||||
'p.isBase',
|
||||
'p.position',
|
||||
'p.parentPageId',
|
||||
'p.spaceId',
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('pages')
|
||||
.addColumn('is_base', 'boolean', (col) =>
|
||||
col.ifNotExists().notNull().defaultTo(false),
|
||||
)
|
||||
.addColumn('base_schema_version', 'integer', (col) =>
|
||||
col.ifNotExists().notNull().defaultTo(0),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_is_base
|
||||
ON pages (space_id, position COLLATE "C")
|
||||
WHERE is_base = true AND deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await db.schema
|
||||
.createTable('base_properties')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'varchar', (col) => col.notNull())
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type', 'varchar', (col) => col.notNull())
|
||||
.addColumn('position', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type_options', 'jsonb')
|
||||
.addColumn('pending_type', 'varchar')
|
||||
.addColumn('pending_type_options', 'jsonb')
|
||||
.addColumn('pending_token', 'uuid')
|
||||
.addColumn('is_primary', 'boolean', (col) => col.notNull().defaultTo(false))
|
||||
.addColumn('schema_version', 'integer', (col) => col.notNull().defaultTo(1))
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.addPrimaryKeyConstraint('base_properties_pkey', ['page_id', 'id'])
|
||||
.execute();
|
||||
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_base_properties_page_id ON base_properties (page_id)`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_properties_page_alive
|
||||
ON base_properties (page_id, position COLLATE "C", id)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
// Match the service-layer name check (name.trim().toLowerCase()) so
|
||||
// whitespace-padded duplicates also collide. Formulas look properties up by
|
||||
// name, so the names have to stay unique.
|
||||
await sql`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS base_properties_page_name_alive_unique
|
||||
ON base_properties (page_id, lower(trim(name)))
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await db.schema
|
||||
.createTable('base_rows')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('cells', 'jsonb', (col) =>
|
||||
col.notNull().defaultTo(sql`'{}'::jsonb`),
|
||||
)
|
||||
.addColumn('position', 'varchar', (col) => col.notNull())
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('last_updated_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.execute();
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_page_alive
|
||||
ON base_rows (page_id, position COLLATE "C", id)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_page_updated
|
||||
ON base_rows (page_id, updated_at DESC)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_page_created
|
||||
ON base_rows (page_id, created_at DESC)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await db.schema
|
||||
.createTable('base_views')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('page_id', 'uuid', (col) =>
|
||||
col.references('pages.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('name', 'varchar', (col) => col.notNull())
|
||||
.addColumn('type', 'varchar', (col) => col.notNull().defaultTo('table'))
|
||||
.addColumn('position', 'varchar', (col) => col.notNull())
|
||||
.addColumn('config', 'jsonb', (col) =>
|
||||
col.notNull().defaultTo(sql`'{}'::jsonb`),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('creator_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_base_views_page_id ON base_views (page_id)`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
// Cell extraction helpers for filters and sorts. Return NULL for absent or
|
||||
// non-castable values.
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_cell_text(cells jsonb, prop text)
|
||||
RETURNS text LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
|
||||
AS $$ SELECT cells->>prop::text $$
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_cell_numeric(cells jsonb, prop text)
|
||||
RETURNS numeric LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
|
||||
AS $$
|
||||
SELECT CASE jsonb_typeof(cells->prop::text)
|
||||
WHEN 'number' THEN (cells->>prop::text)::numeric
|
||||
WHEN 'string' THEN
|
||||
CASE
|
||||
WHEN (cells->>prop::text) ~
|
||||
'^[[:space:]]*[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)([eE][+-]?[0-9]+)?[[:space:]]*$'
|
||||
THEN (cells->>prop::text)::numeric
|
||||
END
|
||||
END
|
||||
$$
|
||||
`.execute(db);
|
||||
|
||||
// A DATE cell stores an arbitrary string (cell schema is z.string()), so the
|
||||
// cast can fail on values no regex can pre-validate (e.g. '2024-13-45'). This
|
||||
// helper uses plpgsql with an EXCEPTION handler to return NULL on failure.
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_cell_timestamptz(cells jsonb, prop text)
|
||||
RETURNS timestamptz LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE
|
||||
AS $$
|
||||
BEGIN RETURN (cells->>prop::text)::timestamptz;
|
||||
EXCEPTION WHEN others THEN RETURN NULL; END;
|
||||
$$
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_cell_bool(cells jsonb, prop text)
|
||||
RETURNS boolean LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
|
||||
AS $$
|
||||
SELECT CASE jsonb_typeof(cells->prop::text)
|
||||
WHEN 'boolean' THEN (cells->>prop::text)::boolean
|
||||
WHEN 'string' THEN
|
||||
CASE
|
||||
WHEN lower(btrim(cells->>prop::text)) IN
|
||||
('true','t','yes','y','on','1','false','f','no','n','off','0')
|
||||
THEN (cells->>prop::text)::boolean
|
||||
END
|
||||
END
|
||||
$$
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_cell_array(cells jsonb, prop text)
|
||||
RETURNS jsonb LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
|
||||
AS $$ SELECT cells->prop::text $$
|
||||
`.execute(db);
|
||||
|
||||
// A null patch value deletes the key rather than storing a JSON null.
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION jsonb_set_many(target jsonb, patches jsonb)
|
||||
RETURNS jsonb LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
|
||||
AS $$
|
||||
DECLARE k text; v jsonb; result jsonb := coalesce(target, '{}'::jsonb);
|
||||
BEGIN
|
||||
IF patches IS NULL OR jsonb_typeof(patches) <> 'object' THEN
|
||||
RETURN result;
|
||||
END IF;
|
||||
FOR k, v IN SELECT * FROM jsonb_each(patches) LOOP
|
||||
IF v = 'null'::jsonb THEN
|
||||
result := result - k;
|
||||
ELSE
|
||||
result := jsonb_set(result, ARRAY[k], v, true);
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN result;
|
||||
END;
|
||||
$$
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('base_views').execute();
|
||||
await db.schema.dropTable('base_rows').execute();
|
||||
await db.schema.dropTable('base_properties').execute();
|
||||
|
||||
await sql`DROP FUNCTION jsonb_set_many(jsonb, jsonb)`.execute(db);
|
||||
await sql`DROP FUNCTION base_cell_array(jsonb, text)`.execute(db);
|
||||
await sql`DROP FUNCTION base_cell_bool(jsonb, text)`.execute(db);
|
||||
await sql`DROP FUNCTION base_cell_timestamptz(jsonb, text)`.execute(db);
|
||||
await sql`DROP FUNCTION base_cell_numeric(jsonb, text)`.execute(db);
|
||||
await sql`DROP FUNCTION base_cell_text(jsonb, text)`.execute(db);
|
||||
|
||||
await sql`DROP INDEX idx_pages_is_base`.execute(db);
|
||||
await db.schema
|
||||
.alterTable('pages')
|
||||
.dropColumn('base_schema_version')
|
||||
.dropColumn('is_base')
|
||||
.execute();
|
||||
}
|
||||
@@ -236,6 +236,7 @@ export class FavoriteRepo {
|
||||
'pages.slugId',
|
||||
'pages.title',
|
||||
'pages.icon',
|
||||
'pages.isBase',
|
||||
'pages.spaceId',
|
||||
])
|
||||
.whereRef('pages.id', '=', 'favorites.pageId'),
|
||||
|
||||
@@ -38,6 +38,7 @@ export class PageRepo {
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
'isLocked',
|
||||
'isBase',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
|
||||
+49
@@ -126,6 +126,50 @@ export interface Backlinks {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface BaseProperties {
|
||||
createdAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
isPrimary: Generated<boolean>;
|
||||
name: string;
|
||||
pageId: string;
|
||||
pendingType: string | null;
|
||||
pendingTypeOptions: Json | null;
|
||||
pendingToken: string | null;
|
||||
position: string;
|
||||
schemaVersion: Generated<number>;
|
||||
type: string;
|
||||
typeOptions: Json | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface BaseRows {
|
||||
cells: Generated<Json>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
deletedAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
lastUpdatedById: string | null;
|
||||
pageId: string;
|
||||
position: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface BaseViews {
|
||||
config: Generated<Json>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
pageId: string;
|
||||
position: string;
|
||||
type: Generated<string>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Billing {
|
||||
amount: Int8 | null;
|
||||
billingScheme: string | null;
|
||||
@@ -275,6 +319,8 @@ export interface Pages {
|
||||
deletedById: string | null;
|
||||
icon: string | null;
|
||||
id: Generated<string>;
|
||||
isBase: Generated<boolean>;
|
||||
baseSchemaVersion: Generated<number>;
|
||||
isLocked: Generated<boolean>;
|
||||
lastUpdatedById: string | null;
|
||||
parentPageId: string | null;
|
||||
@@ -599,6 +645,9 @@ export interface DB {
|
||||
authAccounts: AuthAccounts;
|
||||
authProviders: AuthProviders;
|
||||
backlinks: Backlinks;
|
||||
baseProperties: BaseProperties;
|
||||
baseRows: BaseRows;
|
||||
baseViews: BaseViews;
|
||||
billing: Billing;
|
||||
comments: Comments;
|
||||
favorites: Favorites;
|
||||
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
AiChats,
|
||||
AiChatMessages,
|
||||
Attachments,
|
||||
BaseProperties,
|
||||
BaseRows,
|
||||
BaseViews,
|
||||
Comments,
|
||||
Groups,
|
||||
Labels,
|
||||
@@ -238,3 +241,18 @@ export type UpdatableAudit = Updateable<Omit<_Audit, 'id'>>;
|
||||
export type Template = Selectable<Templates>;
|
||||
export type InsertableTemplate = Insertable<Templates>;
|
||||
export type UpdatableTemplate = Updateable<Omit<Templates, 'id'>>;
|
||||
|
||||
// Base Property
|
||||
export type BaseProperty = Selectable<BaseProperties>;
|
||||
export type InsertableBaseProperty = Insertable<BaseProperties>;
|
||||
export type UpdatableBaseProperty = Updateable<Omit<BaseProperties, 'id'>>;
|
||||
|
||||
// Base Row
|
||||
export type BaseRow = Selectable<BaseRows>;
|
||||
export type InsertableBaseRow = Insertable<BaseRows>;
|
||||
export type UpdatableBaseRow = Updateable<Omit<BaseRows, 'id'>>;
|
||||
|
||||
// Base View
|
||||
export type BaseView = Selectable<BaseViews>;
|
||||
export type InsertableBaseView = Insertable<BaseViews>;
|
||||
export type UpdatableBaseView = Updateable<Omit<BaseViews, 'id'>>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 9947b06dba...3ce75a3fed
@@ -9,6 +9,7 @@ export enum QueueName {
|
||||
HISTORY_QUEUE = '{history-queue}',
|
||||
NOTIFICATION_QUEUE = '{notification-queue}',
|
||||
AUDIT_QUEUE = '{audit-queue}',
|
||||
BASE_QUEUE = '{base-queue}',
|
||||
}
|
||||
|
||||
export enum QueueJob {
|
||||
@@ -83,4 +84,8 @@ export enum QueueJob {
|
||||
|
||||
PDF_EXPORT_TASK = 'pdf-export-task',
|
||||
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
|
||||
|
||||
BASE_TYPE_CONVERSION = 'base-type-conversion',
|
||||
BASE_CELL_GC = 'base-cell-gc',
|
||||
BASE_FORMULA_RECOMPUTE = 'base-formula-recompute',
|
||||
}
|
||||
|
||||
@@ -113,3 +113,47 @@ export interface IApprovalRejectedNotificationJob {
|
||||
requestedById: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface IBaseTypeConversionJob {
|
||||
pageId: string;
|
||||
propertyId: string;
|
||||
workspaceId: string;
|
||||
fromType: string;
|
||||
toType: string;
|
||||
// Snapshots taken at enqueue time so the job stays correct even if the
|
||||
// property's current typeOptions drift while the job waits in the queue.
|
||||
fromTypeOptions: unknown;
|
||||
toTypeOptions: unknown;
|
||||
// When true, the job nulls the cell values for that property instead of
|
||||
// attempting a value conversion. Used for any conversion where the new
|
||||
// type has no meaningful representation of the old value (e.g. involving
|
||||
// a system type).
|
||||
clearMode: boolean;
|
||||
// Staging identity: guards redelivery and failure cleanup against a
|
||||
// same-type re-stage made after this job was enqueued.
|
||||
pendingToken: string;
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export interface IBaseCellGcJob {
|
||||
pageId: string;
|
||||
propertyId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface IBaseFormulaRecomputeJob {
|
||||
pageId: string;
|
||||
workspaceId: string;
|
||||
propertyIds: string[]; // formula properties to recompute
|
||||
reason:
|
||||
| 'formula_created'
|
||||
| 'formula_edited'
|
||||
| 'dep_type_changed'
|
||||
| 'dep_deleted'
|
||||
| 'bulk_import'
|
||||
| 'manual';
|
||||
actorId?: string | null;
|
||||
// When set, scope recompute to these row IDs instead of the whole base.
|
||||
// Used by the bulk-write path (> FORMULA_INLINE_ROW_THRESHOLD).
|
||||
rowIds?: string[];
|
||||
}
|
||||
|
||||
@@ -92,6 +92,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
||||
attempts: 3,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.BASE_QUEUE,
|
||||
defaultJobOptions: {
|
||||
attempts: 2,
|
||||
removeOnComplete: { count: 200 },
|
||||
removeOnFail: { count: 100 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
exports: [BullModule],
|
||||
providers: [GeneralQueueProcessor],
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
@Injectable()
|
||||
export class BaseRealtimeBridge {
|
||||
private readonly logger = new Logger(BaseRealtimeBridge.name);
|
||||
private resolved = false;
|
||||
private svc: any = null;
|
||||
|
||||
constructor(private readonly moduleRef: ModuleRef) {}
|
||||
|
||||
protected loadServiceClass(): any {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return require('../ee/base/realtime/base-ws.service').BaseWsService;
|
||||
} catch {
|
||||
this.logger.debug(
|
||||
'Base realtime requested but enterprise module not bundled in this build',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolve(): any {
|
||||
if (this.resolved) return this.svc;
|
||||
this.resolved = true;
|
||||
const ServiceClass = this.loadServiceClass();
|
||||
if (!ServiceClass) return null;
|
||||
this.svc = this.moduleRef.get(ServiceClass, { strict: false });
|
||||
return this.svc;
|
||||
}
|
||||
|
||||
setServer(server: Server): void {
|
||||
this.resolve()?.setServer(server);
|
||||
}
|
||||
|
||||
isBaseEvent(data: any): boolean {
|
||||
return this.resolve()?.isBaseEvent(data) ?? false;
|
||||
}
|
||||
|
||||
async handleInbound(client: Socket, data: any): Promise<void> {
|
||||
await this.resolve()?.handleInbound(client, data);
|
||||
}
|
||||
|
||||
async handleDisconnect(client: Socket): Promise<void> {
|
||||
await this.resolve()?.handleDisconnect(client);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
OnGatewayInit,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
@@ -13,6 +14,7 @@ import { OnModuleDestroy } from '@nestjs/common';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { WsService } from './ws.service';
|
||||
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
|
||||
import { BaseRealtimeBridge } from './base-realtime.bridge';
|
||||
import * as cookie from 'cookie';
|
||||
|
||||
@WebSocketGateway({
|
||||
@@ -20,7 +22,11 @@ import * as cookie from 'cookie';
|
||||
transports: ['websocket'],
|
||||
})
|
||||
export class WsGateway
|
||||
implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy
|
||||
implements
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
OnGatewayInit,
|
||||
OnModuleDestroy
|
||||
{
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
@@ -29,10 +35,12 @@ export class WsGateway
|
||||
private tokenService: TokenService,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
private wsService: WsService,
|
||||
private baseRealtime: BaseRealtimeBridge,
|
||||
) {}
|
||||
|
||||
afterInit(server: Server): void {
|
||||
this.wsService.setServer(server);
|
||||
this.baseRealtime.setServer(server);
|
||||
}
|
||||
|
||||
async handleConnection(client: Socket, ...args: any[]): Promise<void> {
|
||||
@@ -47,6 +55,7 @@ export class WsGateway
|
||||
const workspaceId = token.workspaceId;
|
||||
|
||||
client.data.userId = userId;
|
||||
client.data.workspaceId = workspaceId;
|
||||
|
||||
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
|
||||
|
||||
@@ -61,10 +70,19 @@ export class WsGateway
|
||||
}
|
||||
}
|
||||
|
||||
async handleDisconnect(client: Socket): Promise<void> {
|
||||
await this.baseRealtime.handleDisconnect(client);
|
||||
}
|
||||
|
||||
@SubscribeMessage('message')
|
||||
async handleMessage(client: Socket, data: any): Promise<void> {
|
||||
if (this.wsService.isTreeEvent(data)) {
|
||||
await this.wsService.handleTreeEvent(client, data);
|
||||
return;
|
||||
}
|
||||
if (this.baseRealtime.isBaseEvent(data)) {
|
||||
await this.baseRealtime.handleInbound(client, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import { WsGateway } from './ws.gateway';
|
||||
import { WsService } from './ws.service';
|
||||
import { WsTreeService } from './ws-tree.service';
|
||||
import { TokenModule } from '../core/auth/token.module';
|
||||
import { BaseRealtimeBridge } from './base-realtime.bridge';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TokenModule],
|
||||
providers: [WsGateway, WsService, WsTreeService],
|
||||
providers: [WsGateway, WsService, WsTreeService, BaseRealtimeBridge],
|
||||
exports: [WsGateway, WsService, WsTreeService],
|
||||
})
|
||||
export class WsModule {}
|
||||
|
||||
@@ -16,3 +16,14 @@ export const TREE_EVENTS = new Set([
|
||||
'deleteTreeNode',
|
||||
'refetchRootTreeNodeEvent',
|
||||
]);
|
||||
|
||||
export function getBaseRoomName(pageId: string): string {
|
||||
return `base-${pageId}`;
|
||||
}
|
||||
|
||||
export const BASE_INBOUND_EVENTS = new Set([
|
||||
'base:subscribe',
|
||||
'base:unsubscribe',
|
||||
'base:presence',
|
||||
'base:presence:leave',
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user