mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 09:17:06 +08:00
Base WIP
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// --- Columns -----------------------------------------------------------
|
||||
|
||||
await sql`
|
||||
ALTER TABLE base_rows
|
||||
ADD COLUMN IF NOT EXISTS search_text text,
|
||||
ADD COLUMN IF NOT EXISTS search_tsv tsvector
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
ALTER TABLE base_properties
|
||||
ADD COLUMN IF NOT EXISTS schema_version integer NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS deleted_at timestamptz
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
ALTER TABLE bases
|
||||
ADD COLUMN IF NOT EXISTS schema_version integer NOT NULL DEFAULT 1
|
||||
`.execute(db);
|
||||
|
||||
// --- Schema-on-read extractors ----------------------------------------
|
||||
// Coercion-safe: uncoercible values return NULL, never raise.
|
||||
// IMMUTABLE so the planner can inline them into expression indexes later.
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_cell_text(cells jsonb, prop uuid)
|
||||
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 uuid)
|
||||
RETURNS numeric
|
||||
LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (cells->>prop::text)::numeric;
|
||||
EXCEPTION WHEN others THEN
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_cell_timestamptz(cells jsonb, prop uuid)
|
||||
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 uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (cells->>prop::text)::boolean;
|
||||
EXCEPTION WHEN others THEN
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_cell_array(cells jsonb, prop uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE
|
||||
AS $$ SELECT cells->prop::text $$
|
||||
`.execute(db);
|
||||
|
||||
// --- Surgical JSONB patch (vs. whole-blob `||`) -----------------------
|
||||
|
||||
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);
|
||||
|
||||
// --- Search text builder (select/multiSelect resolved to choice names) --
|
||||
// STABLE (not IMMUTABLE) because it reads base_properties.
|
||||
//
|
||||
// Transaction-scoped cache: the property list for a given base_id is
|
||||
// read once per transaction and cached via a local GUC. Bulk writes
|
||||
// (CSV import, batch cell update, trigger on N rows) share one lookup
|
||||
// instead of subquerying base_properties per row.
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION build_base_row_search_text(
|
||||
_cells jsonb,
|
||||
_base_id uuid
|
||||
) RETURNS text
|
||||
LANGUAGE plpgsql STABLE PARALLEL SAFE
|
||||
AS $$
|
||||
DECLARE
|
||||
_parts text[] := ARRAY[]::text[];
|
||||
_prop jsonb;
|
||||
_value text;
|
||||
_arr jsonb;
|
||||
_elem jsonb;
|
||||
_resolved text;
|
||||
_cache_key text;
|
||||
_cached text;
|
||||
_props jsonb;
|
||||
BEGIN
|
||||
IF _cells IS NULL OR _cells = '{}'::jsonb OR _base_id IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- Transaction-scoped cache of the base's property list.
|
||||
_cache_key := 'bases.prop_cache_' || replace(_base_id::text, '-', '_');
|
||||
_cached := current_setting(_cache_key, true);
|
||||
IF _cached IS NULL OR _cached = '' THEN
|
||||
SELECT coalesce(
|
||||
jsonb_agg(jsonb_build_object(
|
||||
'id', id,
|
||||
'type', type,
|
||||
'type_options', type_options
|
||||
)),
|
||||
'[]'::jsonb
|
||||
)
|
||||
INTO _props
|
||||
FROM base_properties
|
||||
WHERE base_id = _base_id AND deleted_at IS NULL;
|
||||
PERFORM set_config(_cache_key, _props::text, true);
|
||||
ELSE
|
||||
_props := _cached::jsonb;
|
||||
END IF;
|
||||
|
||||
FOR _prop IN SELECT * FROM jsonb_array_elements(_props)
|
||||
LOOP
|
||||
IF (_prop->>'type') IN ('text', 'url', 'email') THEN
|
||||
_value := _cells->>(_prop->>'id');
|
||||
IF _value IS NOT NULL AND _value <> '' THEN
|
||||
_parts := array_append(_parts, _value);
|
||||
END IF;
|
||||
|
||||
ELSIF (_prop->>'type') IN ('select', 'status') THEN
|
||||
_value := _cells->>(_prop->>'id');
|
||||
IF _value IS NOT NULL AND _value <> '' THEN
|
||||
SELECT c->>'name' INTO _resolved
|
||||
FROM jsonb_array_elements(coalesce(_prop->'type_options'->'choices', '[]'::jsonb)) AS c
|
||||
WHERE c->>'id' = _value
|
||||
LIMIT 1;
|
||||
IF _resolved IS NOT NULL AND _resolved <> '' THEN
|
||||
_parts := array_append(_parts, _resolved);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
ELSIF (_prop->>'type') = 'multiSelect' THEN
|
||||
_arr := _cells->(_prop->>'id');
|
||||
IF jsonb_typeof(_arr) = 'array' THEN
|
||||
FOR _elem IN SELECT * FROM jsonb_array_elements(_arr)
|
||||
LOOP
|
||||
SELECT c->>'name' INTO _resolved
|
||||
FROM jsonb_array_elements(coalesce(_prop->'type_options'->'choices', '[]'::jsonb)) AS c
|
||||
WHERE c->>'id' = _elem#>>'{}'
|
||||
LIMIT 1;
|
||||
IF _resolved IS NOT NULL AND _resolved <> '' THEN
|
||||
_parts := array_append(_parts, _resolved);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
IF array_length(_parts, 1) IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
RETURN f_unaccent(array_to_string(_parts, ' '));
|
||||
END;
|
||||
$$
|
||||
`.execute(db);
|
||||
|
||||
// --- Row search trigger -----------------------------------------------
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE FUNCTION base_rows_search_trigger() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.search_text := build_base_row_search_text(NEW.cells, NEW.base_id);
|
||||
NEW.search_tsv := to_tsvector('english', coalesce(NEW.search_text, ''));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE OR REPLACE TRIGGER base_rows_search_update
|
||||
BEFORE INSERT OR UPDATE ON base_rows
|
||||
FOR EACH ROW EXECUTE FUNCTION base_rows_search_trigger()
|
||||
`.execute(db);
|
||||
|
||||
// --- Indexes ----------------------------------------------------------
|
||||
|
||||
// Replace the default-opclass GIN created by the initial bases migration
|
||||
// with the smaller/faster jsonb_path_ops variant. No row-data backfill:
|
||||
// this branch is dev-only; the trigger populates search_text /
|
||||
// search_tsv on the next write to each row.
|
||||
await sql`DROP INDEX IF EXISTS idx_base_rows_cells_gin`.execute(db);
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_cells_gin_path_ops
|
||||
ON base_rows USING gin (cells jsonb_path_ops)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
// Complementary default-opclass GIN so the `?` / `?|` / `?&` key-existence
|
||||
// operators are index-satisfiable — `jsonb_path_ops` above only covers
|
||||
// `@>`. Type-conversion and cell-GC paths filter `cells ? propertyId`;
|
||||
// without this the planner falls back to SEQ SCAN (~900ms on 100k rows).
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_cells_gin_keys
|
||||
ON base_rows USING gin (cells)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
// Workhorse for paginated list: (base_id, position, id) on live rows.
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_base_alive
|
||||
ON base_rows (base_id, position COLLATE "C", id)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
// Common "most recently edited" sort.
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_base_updated
|
||||
ON base_rows (base_id, updated_at DESC)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_base_created
|
||||
ON base_rows (base_id, created_at DESC)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
// Fulltext + trigram search indexes.
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_search_tsv
|
||||
ON base_rows USING gin (search_tsv)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_search_trgm
|
||||
ON base_rows USING gin (search_text gin_trgm_ops)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
// Tenant-scoped scans defense-in-depth.
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_workspace
|
||||
ON base_rows (workspace_id, base_id)
|
||||
`.execute(db);
|
||||
|
||||
// Live properties per base (deleted_at partial).
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_properties_base_alive
|
||||
ON base_properties (base_id, position COLLATE "C", id)
|
||||
WHERE deleted_at IS NULL
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
// --- Drop new indexes -------------------------------------------------
|
||||
await sql`DROP INDEX IF EXISTS idx_base_properties_base_alive`.execute(db);
|
||||
await sql`DROP INDEX IF EXISTS idx_base_rows_workspace`.execute(db);
|
||||
await sql`DROP INDEX IF EXISTS idx_base_rows_search_trgm`.execute(db);
|
||||
await sql`DROP INDEX IF EXISTS idx_base_rows_search_tsv`.execute(db);
|
||||
await sql`DROP INDEX IF EXISTS idx_base_rows_base_created`.execute(db);
|
||||
await sql`DROP INDEX IF EXISTS idx_base_rows_base_updated`.execute(db);
|
||||
await sql`DROP INDEX IF EXISTS idx_base_rows_base_alive`.execute(db);
|
||||
await sql`DROP INDEX IF EXISTS idx_base_rows_cells_gin_keys`.execute(db);
|
||||
await sql`DROP INDEX IF EXISTS idx_base_rows_cells_gin_path_ops`.execute(db);
|
||||
|
||||
// Restore the original GIN that the initial bases migration created.
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_base_rows_cells_gin
|
||||
ON base_rows USING gin (cells)
|
||||
`.execute(db);
|
||||
|
||||
// --- Drop trigger, trigger fn, helpers --------------------------------
|
||||
await sql`DROP TRIGGER IF EXISTS base_rows_search_update ON base_rows`.execute(db);
|
||||
await sql`DROP FUNCTION IF EXISTS base_rows_search_trigger()`.execute(db);
|
||||
await sql`DROP FUNCTION IF EXISTS build_base_row_search_text(jsonb, uuid)`.execute(db);
|
||||
await sql`DROP FUNCTION IF EXISTS jsonb_set_many(jsonb, jsonb)`.execute(db);
|
||||
await sql`DROP FUNCTION IF EXISTS base_cell_array(jsonb, uuid)`.execute(db);
|
||||
await sql`DROP FUNCTION IF EXISTS base_cell_bool(jsonb, uuid)`.execute(db);
|
||||
await sql`DROP FUNCTION IF EXISTS base_cell_timestamptz(jsonb, uuid)`.execute(db);
|
||||
await sql`DROP FUNCTION IF EXISTS base_cell_numeric(jsonb, uuid)`.execute(db);
|
||||
await sql`DROP FUNCTION IF EXISTS base_cell_text(jsonb, uuid)`.execute(db);
|
||||
|
||||
// --- Drop columns -----------------------------------------------------
|
||||
await sql`ALTER TABLE bases DROP COLUMN IF EXISTS schema_version`.execute(db);
|
||||
await sql`
|
||||
ALTER TABLE base_properties
|
||||
DROP COLUMN IF EXISTS deleted_at,
|
||||
DROP COLUMN IF EXISTS schema_version
|
||||
`.execute(db);
|
||||
await sql`
|
||||
ALTER TABLE base_rows
|
||||
DROP COLUMN IF EXISTS search_tsv,
|
||||
DROP COLUMN IF EXISTS search_text
|
||||
`.execute(db);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
/*
|
||||
* Adds `pending_type` / `pending_type_options` to `base_properties` so
|
||||
* async type conversions can run without flipping the live type prematurely.
|
||||
* The worker swaps them onto `type` / `type_options` in the same
|
||||
* transaction that bumps schema_version, so clients never observe raw IDs
|
||||
* under a post-conversion type.
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
ALTER TABLE base_properties
|
||||
ADD COLUMN IF NOT EXISTS pending_type varchar,
|
||||
ADD COLUMN IF NOT EXISTS pending_type_options jsonb
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
ALTER TABLE base_properties
|
||||
DROP COLUMN IF EXISTS pending_type_options,
|
||||
DROP COLUMN IF EXISTS pending_type
|
||||
`.execute(db);
|
||||
}
|
||||
@@ -15,14 +15,15 @@ export class BasePropertyRepo {
|
||||
|
||||
async findById(
|
||||
propertyId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
opts?: { trx?: KyselyTransaction; includeDeleted?: boolean },
|
||||
): Promise<BaseProperty | undefined> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
let qb = db
|
||||
.selectFrom('baseProperties')
|
||||
.selectAll()
|
||||
.where('id', '=', propertyId)
|
||||
.executeTakeFirst() as Promise<BaseProperty | undefined>;
|
||||
.where('id', '=', propertyId);
|
||||
if (!opts?.includeDeleted) qb = qb.where('deletedAt', 'is', null);
|
||||
return qb.executeTakeFirst() as Promise<BaseProperty | undefined>;
|
||||
}
|
||||
|
||||
async findByBaseId(
|
||||
@@ -34,6 +35,7 @@ export class BasePropertyRepo {
|
||||
.selectFrom('baseProperties')
|
||||
.selectAll()
|
||||
.where('baseId', '=', baseId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('position', 'asc')
|
||||
.execute() as Promise<BaseProperty[]>;
|
||||
}
|
||||
@@ -78,7 +80,19 @@ export class BasePropertyRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteProperty(
|
||||
async softDelete(
|
||||
propertyId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseProperties')
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where('id', '=', propertyId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async hardDelete(
|
||||
propertyId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
@@ -88,4 +102,60 @@ export class BasePropertyRepo {
|
||||
.where('id', '=', propertyId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async bumpSchemaVersion(
|
||||
propertyId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseProperties')
|
||||
.set({
|
||||
schemaVersion: sql`schema_version + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', propertyId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/*
|
||||
* Promotes `pending_type` / `pending_type_options` onto the live `type` /
|
||||
* `type_options` columns and clears the pending pair. No-op if no
|
||||
* conversion was pending. Caller is responsible for doing this inside the
|
||||
* same transaction as the cell rewrite so readers never see a
|
||||
* half-converted state.
|
||||
*/
|
||||
async commitPendingTypeChange(
|
||||
propertyId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseProperties')
|
||||
.set({
|
||||
type: sql`coalesce(pending_type, type)`,
|
||||
typeOptions: sql`coalesce(pending_type_options, type_options)`,
|
||||
pendingType: null,
|
||||
pendingTypeOptions: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', propertyId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async clearPendingTypeChange(
|
||||
propertyId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('baseProperties')
|
||||
.set({
|
||||
pendingType: null,
|
||||
pendingTypeOptions: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', propertyId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,39 @@ import {
|
||||
InsertableBaseRow,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { sql, SelectQueryBuilder, SqlBool } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import {
|
||||
CursorPaginationResult,
|
||||
executeWithCursorPagination,
|
||||
} from '@docmost/db/pagination/cursor-pagination';
|
||||
import { sql, SqlBool } from 'kysely';
|
||||
import {
|
||||
FilterNode,
|
||||
PropertySchema,
|
||||
SearchSpec,
|
||||
SortSpec,
|
||||
runListQuery,
|
||||
} from '../../../core/base/engine';
|
||||
|
||||
const SYSTEM_COLUMN_MAP: Record<string, string> = {
|
||||
createdAt: 'createdAt',
|
||||
lastEditedAt: 'updatedAt',
|
||||
lastEditedBy: 'lastUpdatedById',
|
||||
};
|
||||
type RepoOpts = { trx?: KyselyTransaction };
|
||||
type WorkspaceOpts = { workspaceId: string } & RepoOpts;
|
||||
|
||||
const ARRAY_TYPES = new Set(['multiSelect', 'person', 'file']);
|
||||
|
||||
function escapeIlike(value: string): string {
|
||||
return value.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
// Columns that make up the public `BaseRow` shape.
|
||||
// `search_text` and `search_tsv` are internal fulltext-index columns
|
||||
// maintained by a trigger — they must never leak into API responses or
|
||||
// socket payloads. Every SELECT/RETURNING path in this repo references
|
||||
// this constant.
|
||||
const BASE_ROW_COLUMNS = [
|
||||
'id',
|
||||
'baseId',
|
||||
'cells',
|
||||
'position',
|
||||
'creatorId',
|
||||
'lastUpdatedById',
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class BaseRowRepo {
|
||||
@@ -29,54 +47,82 @@ export class BaseRowRepo {
|
||||
|
||||
async findById(
|
||||
rowId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
opts: WorkspaceOpts,
|
||||
): Promise<BaseRow | undefined> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
return (await db
|
||||
.selectFrom('baseRows')
|
||||
.selectAll()
|
||||
.select(BASE_ROW_COLUMNS)
|
||||
.where('id', '=', rowId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.executeTakeFirst() as Promise<BaseRow | undefined>;
|
||||
.executeTakeFirst()) as BaseRow | undefined;
|
||||
}
|
||||
|
||||
async findByBaseId(
|
||||
baseId: string,
|
||||
pagination: PaginationOptions,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
) {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
async list(opts: {
|
||||
baseId: string;
|
||||
workspaceId: string;
|
||||
filter?: FilterNode;
|
||||
sorts?: SortSpec[];
|
||||
search?: SearchSpec;
|
||||
schema: PropertySchema;
|
||||
pagination: PaginationOptions;
|
||||
trx?: KyselyTransaction;
|
||||
}): Promise<CursorPaginationResult<BaseRow>> {
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
|
||||
const query = db
|
||||
const base = db
|
||||
.selectFrom('baseRows')
|
||||
.selectAll()
|
||||
.where('baseId', '=', baseId)
|
||||
.select(BASE_ROW_COLUMNS)
|
||||
.where('baseId', '=', opts.baseId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.where('deletedAt', 'is', null);
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [
|
||||
{ expression: 'position', direction: 'asc' },
|
||||
{ expression: 'id', direction: 'asc' },
|
||||
],
|
||||
parseCursor: (cursor) => ({
|
||||
position: cursor.position,
|
||||
id: cursor.id,
|
||||
}),
|
||||
const hasFilterSortSearch =
|
||||
!!opts.filter || (opts.sorts && opts.sorts.length > 0) || !!opts.search;
|
||||
|
||||
if (!hasFilterSortSearch) {
|
||||
// Fast path: keyset-paginated list ordered by (position COLLATE "C", id)
|
||||
// to match idx_base_rows_base_alive. Without the collation hint the
|
||||
// planner falls back to a Sort node on every page.
|
||||
return executeWithCursorPagination(base as any, {
|
||||
perPage: opts.pagination.limit,
|
||||
cursor: opts.pagination.cursor,
|
||||
beforeCursor: opts.pagination.beforeCursor,
|
||||
fields: [
|
||||
{
|
||||
expression: sql`position COLLATE "C"`,
|
||||
direction: 'asc',
|
||||
key: 'position',
|
||||
},
|
||||
{ expression: 'id', direction: 'asc', key: 'id' },
|
||||
],
|
||||
parseCursor: (c) => ({
|
||||
position: c.position,
|
||||
id: c.id,
|
||||
}),
|
||||
} as any) as unknown as Promise<CursorPaginationResult<BaseRow>>;
|
||||
}
|
||||
|
||||
return runListQuery(base as any, {
|
||||
filter: opts.filter,
|
||||
sorts: opts.sorts,
|
||||
search: opts.search,
|
||||
schema: opts.schema,
|
||||
pagination: opts.pagination,
|
||||
});
|
||||
}
|
||||
|
||||
async getLastPosition(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
opts: WorkspaceOpts,
|
||||
): Promise<string | null> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
const result = await db
|
||||
.selectFrom('baseRows')
|
||||
.select('position')
|
||||
.where('baseId', '=', baseId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy(sql`position COLLATE "C"`, sql`DESC`)
|
||||
.limit(1)
|
||||
@@ -86,425 +132,199 @@ export class BaseRowRepo {
|
||||
|
||||
async insertRow(
|
||||
row: InsertableBaseRow,
|
||||
trx?: KyselyTransaction,
|
||||
opts?: RepoOpts,
|
||||
): Promise<BaseRow> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return (await db
|
||||
.insertInto('baseRows')
|
||||
.values(row)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow() as Promise<BaseRow>;
|
||||
.returning(BASE_ROW_COLUMNS)
|
||||
.executeTakeFirstOrThrow()) as BaseRow;
|
||||
}
|
||||
|
||||
/*
|
||||
* Merges `patch` into the row's cells via `jsonb_set_many` and returns
|
||||
* the updated row (public columns only — search_text/search_tsv are
|
||||
* excluded from RETURNING). Single round-trip; replaces the old
|
||||
* "updateCells + findById" two-query dance.
|
||||
*/
|
||||
async updateCells(
|
||||
rowId: string,
|
||||
cells: Record<string, unknown>,
|
||||
userId?: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
patch: Record<string, unknown>,
|
||||
opts: {
|
||||
baseId: string;
|
||||
workspaceId: string;
|
||||
actorId?: string;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<BaseRow | undefined> {
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
// Cast through text because postgres.js auto-detects a JSON-shaped
|
||||
// string as jsonb and re-encodes it, producing a jsonb *string* instead
|
||||
// of an object — which `jsonb_set_many` then treats as a no-op.
|
||||
const patchJson = JSON.stringify(patch);
|
||||
return (await db
|
||||
.updateTable('baseRows')
|
||||
.set({
|
||||
cells: sql`cells || ${cells}`,
|
||||
cells: sql`jsonb_set_many(cells, ${patchJson}::text::jsonb)`,
|
||||
updatedAt: new Date(),
|
||||
lastUpdatedById: userId ?? null,
|
||||
lastUpdatedById: opts.actorId ?? null,
|
||||
})
|
||||
.where('id', '=', rowId)
|
||||
.where('baseId', '=', opts.baseId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
.returning(BASE_ROW_COLUMNS)
|
||||
.executeTakeFirst()) as BaseRow | undefined;
|
||||
}
|
||||
|
||||
async updatePosition(
|
||||
rowId: string,
|
||||
position: string,
|
||||
trx?: KyselyTransaction,
|
||||
opts: {
|
||||
baseId: string;
|
||||
workspaceId: string;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({ position, updatedAt: new Date() })
|
||||
.where('id', '=', rowId)
|
||||
.where('baseId', '=', opts.baseId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async softDelete(rowId: string, trx?: KyselyTransaction): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
async softDelete(
|
||||
rowId: string,
|
||||
opts: {
|
||||
baseId: string;
|
||||
workspaceId: string;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({ deletedAt: new Date() })
|
||||
.where('id', '=', rowId)
|
||||
.where('baseId', '=', opts.baseId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async removeCellKey(
|
||||
baseId: string,
|
||||
propertyId: string,
|
||||
trx?: KyselyTransaction,
|
||||
opts: WorkspaceOpts,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({
|
||||
cells: sql`cells - ${propertyId}`,
|
||||
cells: sql`cells - ${propertyId}::text`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('baseId', '=', baseId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findAllByBaseId(
|
||||
/*
|
||||
* Streams every live row of a base in deterministic order via keyset
|
||||
* pagination so async jobs (type-conversion, cell-gc, export) can process
|
||||
* large bases without loading the full set into memory.
|
||||
*
|
||||
* `withCellKey` restricts the scan to rows whose cell jsonb contains
|
||||
* that top-level key. Type-conversion callers pass the property ID so
|
||||
* we don't drag 100k empty rows through Node just to rewrite a dozen.
|
||||
*/
|
||||
async *streamByBaseId(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<BaseRow[]> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('baseRows')
|
||||
.selectAll()
|
||||
.where('baseId', '=', baseId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute() as Promise<BaseRow[]>;
|
||||
opts: {
|
||||
workspaceId: string;
|
||||
chunkSize?: number;
|
||||
trx?: KyselyTransaction;
|
||||
withCellKey?: string;
|
||||
},
|
||||
): AsyncGenerator<BaseRow[], void, void> {
|
||||
const chunkSize = opts.chunkSize ?? 1000;
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
let afterPosition: string | null = null;
|
||||
let afterId: string | null = null;
|
||||
|
||||
while (true) {
|
||||
let qb = db
|
||||
.selectFrom('baseRows')
|
||||
.select(BASE_ROW_COLUMNS)
|
||||
.where('baseId', '=', baseId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy(sql`position COLLATE "C"`, 'asc')
|
||||
.orderBy('id', 'asc')
|
||||
.limit(chunkSize);
|
||||
|
||||
if (opts.withCellKey) {
|
||||
qb = qb.where(sql<SqlBool>`cells ? ${opts.withCellKey}`);
|
||||
}
|
||||
|
||||
if (afterPosition !== null && afterId !== null) {
|
||||
qb = qb.where((eb) =>
|
||||
eb.or([
|
||||
eb(sql`position COLLATE "C"`, '>', afterPosition!),
|
||||
eb.and([
|
||||
eb(sql`position COLLATE "C"`, '=', afterPosition!),
|
||||
eb('id', '>', afterId!),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const chunk = (await qb.execute()) as BaseRow[];
|
||||
if (chunk.length === 0) return;
|
||||
yield chunk;
|
||||
if (chunk.length < chunkSize) return;
|
||||
const last = chunk[chunk.length - 1];
|
||||
afterPosition = last.position;
|
||||
afterId = last.id;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Real batch: one `UPDATE ... FROM (SELECT unnest($ids), unnest($patches))`
|
||||
* per call. Callers chunk (typically 1000 per call) from inside a BullMQ
|
||||
* job. `cells` is merged via `jsonb_set_many` so only touched subtrees
|
||||
* rewrite.
|
||||
*/
|
||||
async batchUpdateCells(
|
||||
updates: Array<{ id: string; cells: Record<string, unknown> }>,
|
||||
trx?: KyselyTransaction,
|
||||
updates: Array<{ id: string; patch: Record<string, unknown> }>,
|
||||
opts: {
|
||||
baseId: string;
|
||||
workspaceId: string;
|
||||
actorId?: string;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
for (const update of updates) {
|
||||
await db
|
||||
.updateTable('baseRows')
|
||||
.set({
|
||||
cells: sql`cells || ${update.cells}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', update.id)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
if (updates.length === 0) return;
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
|
||||
async findByBaseIdFiltered(
|
||||
baseId: string,
|
||||
filters: Array<{ propertyId: string; operator: string; value?: unknown }>,
|
||||
sorts: Array<{ propertyId: string; direction: string }>,
|
||||
propertyTypeMap: Map<string, string>,
|
||||
pagination: PaginationOptions,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
) {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
const ids = updates.map((u) => u.id);
|
||||
const patches = updates.map((u) => JSON.stringify(u.patch));
|
||||
|
||||
let query = db
|
||||
.selectFrom('baseRows')
|
||||
.selectAll()
|
||||
.where('baseId', '=', baseId)
|
||||
.where('deletedAt', 'is', null) as SelectQueryBuilder<DB, 'baseRows', any>;
|
||||
|
||||
// Apply filters
|
||||
for (const filter of filters) {
|
||||
query = this.applyFilter(query, filter, propertyTypeMap);
|
||||
}
|
||||
|
||||
// Build cursor-compatible sort fields.
|
||||
// COALESCE sort expressions so NULLs never reach the cursor encoder/comparator.
|
||||
// ASC NULLS LAST → COALESCE(expr, <high sentinel>)
|
||||
// DESC NULLS LAST → COALESCE(expr, <low sentinel>)
|
||||
const sortMeta: Array<{
|
||||
alias: string;
|
||||
expression: ReturnType<typeof sql>;
|
||||
direction: 'asc' | 'desc';
|
||||
isNumeric: boolean;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < sorts.length; i++) {
|
||||
const sort = sorts[i];
|
||||
const type = propertyTypeMap.get(sort.propertyId);
|
||||
if (!type) continue;
|
||||
|
||||
const dir = (sort.direction === 'desc' ? 'desc' : 'asc') as 'asc' | 'desc';
|
||||
const alias = `s${i}`;
|
||||
let expression: ReturnType<typeof sql>;
|
||||
let isNumeric = false;
|
||||
|
||||
const systemCol = SYSTEM_COLUMN_MAP[type];
|
||||
if (systemCol) {
|
||||
// System columns (createdAt, updatedAt) are NOT NULL — no COALESCE needed
|
||||
expression = sql`"${sql.raw(systemCol)}"`;
|
||||
} else if (type === 'number') {
|
||||
isNumeric = true;
|
||||
const sentinel = dir === 'asc' ? "'Infinity'::numeric" : "'-Infinity'::numeric";
|
||||
expression = sql`COALESCE((cells->>'${sql.raw(sort.propertyId)}')::numeric, ${sql.raw(sentinel)})`;
|
||||
} else {
|
||||
// Text, date, select, etc.
|
||||
const sentinel = dir === 'asc' ? 'chr(1114111)' : "''";
|
||||
expression = sql`COALESCE(cells->>'${sql.raw(sort.propertyId)}', ${sql.raw(sentinel)})`;
|
||||
}
|
||||
|
||||
sortMeta.push({ alias, expression, direction: dir, isNumeric });
|
||||
query = query.select(expression.as(alias)) as any;
|
||||
}
|
||||
|
||||
// Cursor pagination fields: sort aliases + position + id tiebreakers.
|
||||
// executeWithCursorPagination applies ORDER BY and builds the keyset WHERE from these.
|
||||
const fields = [
|
||||
...sortMeta.map(({ alias, expression, direction }) => ({
|
||||
expression,
|
||||
direction,
|
||||
key: alias,
|
||||
})),
|
||||
{ expression: 'position' as any, direction: 'asc' as const, key: 'position' },
|
||||
{ expression: 'id' as any, direction: 'asc' as const, key: 'id' },
|
||||
];
|
||||
|
||||
return executeWithCursorPagination(query as any, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: fields as any,
|
||||
encodeCursor: (values: Array<[string, unknown]>) => {
|
||||
const cursor = new URLSearchParams();
|
||||
for (const [key, value] of values) {
|
||||
if (value === null || value === undefined) {
|
||||
cursor.set(key, '__null__');
|
||||
} else if (value instanceof Date) {
|
||||
cursor.set(key, value.toISOString());
|
||||
} else {
|
||||
cursor.set(key, String(value));
|
||||
}
|
||||
}
|
||||
return Buffer.from(cursor.toString(), 'utf8').toString('base64url');
|
||||
},
|
||||
decodeCursor: (cursorStr: string, fieldNames: string[]) => {
|
||||
const parsed = new URLSearchParams(
|
||||
Buffer.from(cursorStr, 'base64url').toString('utf8'),
|
||||
);
|
||||
const result: Record<string, string> = {};
|
||||
for (const name of fieldNames) {
|
||||
result[name] = parsed.get(name) ?? '';
|
||||
}
|
||||
return result;
|
||||
},
|
||||
parseCursor: (decoded: any) => {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const { alias, isNumeric } of sortMeta) {
|
||||
const val = decoded[alias];
|
||||
if (val === '__null__') {
|
||||
result[alias] = null;
|
||||
} else {
|
||||
result[alias] = isNumeric ? parseFloat(val) : val;
|
||||
}
|
||||
}
|
||||
result.position = decoded.position;
|
||||
result.id = decoded.id;
|
||||
return result;
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
|
||||
private applyFilter(
|
||||
query: SelectQueryBuilder<DB, 'baseRows', any>,
|
||||
filter: { propertyId: string; operator: string; value?: unknown },
|
||||
propertyTypeMap: Map<string, string>,
|
||||
): SelectQueryBuilder<DB, 'baseRows', any> {
|
||||
const { propertyId, operator, value } = filter;
|
||||
const propertyType = propertyTypeMap.get(propertyId);
|
||||
if (!propertyType) return query;
|
||||
|
||||
// System property -> use actual column
|
||||
const systemCol = SYSTEM_COLUMN_MAP[propertyType];
|
||||
if (systemCol) {
|
||||
return this.applyColumnFilter(query, systemCol, operator, value, propertyType);
|
||||
}
|
||||
|
||||
const isArray = ARRAY_TYPES.has(propertyType);
|
||||
|
||||
// isEmpty / isNotEmpty don't need a value
|
||||
if (operator === 'isEmpty') {
|
||||
if (isArray) {
|
||||
return query.where(({ or, eb }) =>
|
||||
or([
|
||||
eb(sql.raw(`cells->'${propertyId}'`), 'is', null),
|
||||
eb(sql`jsonb_array_length(cells->'${sql.raw(propertyId)}')`, '=', 0),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return query.where(({ or, eb }) =>
|
||||
or([
|
||||
eb(sql.raw(`cells->>'${propertyId}'`), 'is', null),
|
||||
eb(sql.raw(`cells->>'${propertyId}'`), '=', ''),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if (operator === 'isNotEmpty') {
|
||||
if (isArray) {
|
||||
return query
|
||||
.where(sql.raw(`cells->'${propertyId}'`), 'is not', null)
|
||||
.where(sql`jsonb_array_length(cells->'${sql.raw(propertyId)}')`, '>', 0);
|
||||
}
|
||||
return query
|
||||
.where(sql.raw(`cells->>'${propertyId}'`), 'is not', null)
|
||||
.where(sql.raw(`cells->>'${propertyId}'`), '!=', '');
|
||||
}
|
||||
|
||||
if (value === undefined || value === null) return query;
|
||||
|
||||
// contains / notContains - text search
|
||||
if (operator === 'contains') {
|
||||
return query.where(
|
||||
sql.raw(`cells->>'${propertyId}'`),
|
||||
'ilike',
|
||||
`%${escapeIlike(String(value))}%`,
|
||||
);
|
||||
}
|
||||
if (operator === 'notContains') {
|
||||
return query.where(({ or, eb }) =>
|
||||
or([
|
||||
eb(sql.raw(`cells->>'${propertyId}'`), 'is', null),
|
||||
eb(
|
||||
sql.raw(`cells->>'${propertyId}'`),
|
||||
'not ilike',
|
||||
`%${escapeIlike(String(value))}%`,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// equals / notEquals
|
||||
if (operator === 'equals') {
|
||||
if (isArray) {
|
||||
return query.where(
|
||||
sql<SqlBool>`cells->'${sql.raw(propertyId)}' @> ${JSON.stringify([value])}::jsonb`,
|
||||
);
|
||||
}
|
||||
if (propertyType === 'number') {
|
||||
return query.where(
|
||||
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::numeric = ${Number(value)}`,
|
||||
);
|
||||
}
|
||||
if (propertyType === 'checkbox') {
|
||||
return query.where(
|
||||
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::boolean = ${Boolean(value)}`,
|
||||
);
|
||||
}
|
||||
return query.where(sql.raw(`cells->>'${propertyId}'`), '=', String(value));
|
||||
}
|
||||
|
||||
if (operator === 'notEquals') {
|
||||
if (isArray) {
|
||||
return query.where(({ or, eb }) =>
|
||||
or([
|
||||
eb(sql.raw(`cells->'${propertyId}'`), 'is', null),
|
||||
sql<SqlBool>`NOT (cells->'${sql.raw(propertyId)}' @> ${JSON.stringify([value])}::jsonb)`,
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (propertyType === 'number') {
|
||||
return query.where(
|
||||
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::numeric != ${Number(value)}`,
|
||||
);
|
||||
}
|
||||
if (propertyType === 'checkbox') {
|
||||
return query.where(
|
||||
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::boolean != ${Boolean(value)}`,
|
||||
);
|
||||
}
|
||||
return query.where(({ or, eb }) =>
|
||||
or([
|
||||
eb(sql.raw(`cells->>'${propertyId}'`), 'is', null),
|
||||
eb(sql.raw(`cells->>'${propertyId}'`), '!=', String(value)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// greaterThan / lessThan - number
|
||||
if (operator === 'greaterThan') {
|
||||
return query.where(
|
||||
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::numeric > ${Number(value)}`,
|
||||
);
|
||||
}
|
||||
if (operator === 'lessThan') {
|
||||
return query.where(
|
||||
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::numeric < ${Number(value)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// before / after - date
|
||||
if (operator === 'before') {
|
||||
return query.where(sql.raw(`cells->>'${propertyId}'`), '<', String(value));
|
||||
}
|
||||
if (operator === 'after') {
|
||||
return query.where(sql.raw(`cells->>'${propertyId}'`), '>', String(value));
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private applyColumnFilter(
|
||||
query: SelectQueryBuilder<DB, 'baseRows', any>,
|
||||
column: string,
|
||||
operator: string,
|
||||
value: unknown,
|
||||
propertyType: string,
|
||||
): SelectQueryBuilder<DB, 'baseRows', any> {
|
||||
if (operator === 'isEmpty') {
|
||||
return query.where(sql.raw(`"${column}"`), 'is', null);
|
||||
}
|
||||
if (operator === 'isNotEmpty') {
|
||||
return query.where(sql.raw(`"${column}"`), 'is not', null);
|
||||
}
|
||||
|
||||
if (value === undefined || value === null) return query;
|
||||
|
||||
if (operator === 'equals') {
|
||||
return query.where(sql.raw(`"${column}"`), '=', value);
|
||||
}
|
||||
if (operator === 'notEquals') {
|
||||
return query.where(({ or, eb }) =>
|
||||
or([
|
||||
eb(sql.raw(`"${column}"`), 'is', null),
|
||||
eb(sql.raw(`"${column}"`), '!=', value),
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (operator === 'before') {
|
||||
return query.where(sql.raw(`"${column}"`), '<', value);
|
||||
}
|
||||
if (operator === 'after') {
|
||||
return query.where(sql.raw(`"${column}"`), '>', value);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private applySort(
|
||||
query: SelectQueryBuilder<DB, 'baseRows', any>,
|
||||
sort: { propertyId: string; direction: string },
|
||||
propertyTypeMap: Map<string, string>,
|
||||
): SelectQueryBuilder<DB, 'baseRows', any> {
|
||||
const { propertyId, direction } = sort;
|
||||
const propertyType = propertyTypeMap.get(propertyId);
|
||||
if (!propertyType) return query;
|
||||
|
||||
const dir = direction === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
// System property -> use actual column
|
||||
const systemCol = SYSTEM_COLUMN_MAP[propertyType];
|
||||
if (systemCol) {
|
||||
return query.orderBy(sql.raw(`"${systemCol}"`), sql`${sql.raw(dir)} NULLS LAST`);
|
||||
}
|
||||
|
||||
// Number properties: cast to numeric for proper numeric ordering
|
||||
if (propertyType === 'number') {
|
||||
return query.orderBy(
|
||||
sql`(cells->>'${sql.raw(propertyId)}')::numeric`,
|
||||
sql`${sql.raw(dir)} NULLS LAST`,
|
||||
);
|
||||
}
|
||||
|
||||
// All other properties: use text extraction
|
||||
return query.orderBy(
|
||||
sql.raw(`cells->>'${propertyId}'`),
|
||||
sql`${sql.raw(dir)} NULLS LAST`,
|
||||
);
|
||||
await sql`
|
||||
UPDATE base_rows AS r
|
||||
SET cells = jsonb_set_many(r.cells, u.patch::jsonb),
|
||||
updated_at = now(),
|
||||
last_updated_by_id = coalesce(${opts.actorId ?? null}, r.last_updated_by_id)
|
||||
FROM unnest(${ids}::uuid[], ${patches}::text[]) AS u(row_id, patch)
|
||||
WHERE r.id = u.row_id
|
||||
AND r.base_id = ${opts.baseId}
|
||||
AND r.workspace_id = ${opts.workspaceId}
|
||||
AND r.deleted_at IS NULL
|
||||
`.execute(db);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,57 +9,64 @@ import {
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
type RepoOpts = { trx?: KyselyTransaction };
|
||||
type WorkspaceOpts = { workspaceId: string } & RepoOpts;
|
||||
|
||||
@Injectable()
|
||||
export class BaseViewRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
viewId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
opts: WorkspaceOpts,
|
||||
): Promise<BaseView | undefined> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
return db
|
||||
.selectFrom('baseViews')
|
||||
.selectAll()
|
||||
.where('id', '=', viewId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.executeTakeFirst() as Promise<BaseView | undefined>;
|
||||
}
|
||||
|
||||
async findByBaseId(
|
||||
baseId: string,
|
||||
opts?: { trx?: KyselyTransaction },
|
||||
opts: WorkspaceOpts,
|
||||
): Promise<BaseView[]> {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
return db
|
||||
.selectFrom('baseViews')
|
||||
.selectAll()
|
||||
.where('baseId', '=', baseId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.orderBy('position', 'asc')
|
||||
.execute() as Promise<BaseView[]>;
|
||||
}
|
||||
|
||||
async countByBaseId(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
opts: WorkspaceOpts,
|
||||
): Promise<number> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
const result = await db
|
||||
.selectFrom('baseViews')
|
||||
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||
.where('baseId', '=', baseId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.executeTakeFirstOrThrow();
|
||||
return Number(result.count);
|
||||
}
|
||||
|
||||
async getLastPosition(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
opts: WorkspaceOpts,
|
||||
): Promise<string | null> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
const result = await db
|
||||
.selectFrom('baseViews')
|
||||
.select('position')
|
||||
.where('baseId', '=', baseId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.orderBy(sql`position COLLATE "C"`, sql`DESC`)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
@@ -68,9 +75,9 @@ export class BaseViewRepo {
|
||||
|
||||
async insertView(
|
||||
view: InsertableBaseView,
|
||||
trx?: KyselyTransaction,
|
||||
opts?: RepoOpts,
|
||||
): Promise<BaseView> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
return db
|
||||
.insertInto('baseViews')
|
||||
.values(view)
|
||||
@@ -81,24 +88,26 @@ export class BaseViewRepo {
|
||||
async updateView(
|
||||
viewId: string,
|
||||
data: UpdatableBaseView,
|
||||
trx?: KyselyTransaction,
|
||||
opts: WorkspaceOpts,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
await db
|
||||
.updateTable('baseViews')
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where('id', '=', viewId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteView(
|
||||
viewId: string,
|
||||
trx?: KyselyTransaction,
|
||||
opts: WorkspaceOpts,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const db = dbOrTx(this.db, opts.trx);
|
||||
await db
|
||||
.deleteFrom('baseViews')
|
||||
.where('id', '=', viewId)
|
||||
.where('workspaceId', '=', opts.workspaceId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ExpressionBuilder } from 'kysely';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
|
||||
@@ -120,6 +120,23 @@ export class BaseRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async bumpSchemaVersion(
|
||||
baseId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<number> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
const result = await db
|
||||
.updateTable('bases')
|
||||
.set({
|
||||
schemaVersion: sql`schema_version + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', baseId)
|
||||
.returning('schemaVersion')
|
||||
.executeTakeFirst();
|
||||
return result?.schemaVersion ?? 0;
|
||||
}
|
||||
|
||||
private withProperties(eb: ExpressionBuilder<DB, 'bases'>) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
|
||||
+7
@@ -434,6 +434,7 @@ export interface Bases {
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
schemaVersion: Generated<number>;
|
||||
}
|
||||
|
||||
export interface BaseProperties {
|
||||
@@ -443,10 +444,14 @@ export interface BaseProperties {
|
||||
type: string;
|
||||
position: string;
|
||||
typeOptions: Json | null;
|
||||
pendingType: string | null;
|
||||
pendingTypeOptions: Json | null;
|
||||
isPrimary: Generated<boolean>;
|
||||
workspaceId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
schemaVersion: Generated<number>;
|
||||
deletedAt: Timestamp | null;
|
||||
}
|
||||
|
||||
export interface BaseRows {
|
||||
@@ -460,6 +465,8 @@ export interface BaseRows {
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
searchText: string | null;
|
||||
searchTsv: string | null;
|
||||
}
|
||||
|
||||
export interface BaseViews {
|
||||
|
||||
@@ -223,9 +223,18 @@ 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'>>;
|
||||
// `searchText` and `searchTsv` are internal fulltext-index columns maintained
|
||||
// by a trigger. They are omitted from the public types so they never leak into
|
||||
// HTTP responses or write payloads.
|
||||
export type BaseRow = Omit<Selectable<BaseRows>, 'searchText' | 'searchTsv'>;
|
||||
export type InsertableBaseRow = Omit<
|
||||
Insertable<BaseRows>,
|
||||
'searchText' | 'searchTsv'
|
||||
>;
|
||||
export type UpdatableBaseRow = Omit<
|
||||
Updateable<Omit<BaseRows, 'id'>>,
|
||||
'searchText' | 'searchTsv'
|
||||
>;
|
||||
|
||||
// Base View
|
||||
export type BaseView = Selectable<BaseViews>;
|
||||
|
||||
Reference in New Issue
Block a user