diff --git a/apps/server/src/core/base/query-cache/column-types.spec.ts b/apps/server/src/core/base/query-cache/column-types.spec.ts new file mode 100644 index 00000000..3307c429 --- /dev/null +++ b/apps/server/src/core/base/query-cache/column-types.spec.ts @@ -0,0 +1,76 @@ +import { BasePropertyType } from '../base.schemas'; +import { buildColumnSpecs, SYSTEM_COLUMNS } from './column-types'; + +const p = (type: string, extra: Record = {}) => ({ + id: `prop-${type}`, + type, + typeOptions: extra, +}) as any; + +describe('buildColumnSpecs', () => { + it('includes the fixed system columns first', () => { + const specs = buildColumnSpecs([]); + expect(specs.map((s) => s.column)).toEqual(SYSTEM_COLUMNS.map((s) => s.column)); + }); + + it('maps text / url / email to VARCHAR indexable', () => { + for (const t of [BasePropertyType.TEXT, BasePropertyType.URL, BasePropertyType.EMAIL]) { + const specs = buildColumnSpecs([p(t)]); + const user = specs[specs.length - 1]; + expect(user.ddlType).toBe('VARCHAR'); + expect(user.indexable).toBe(true); + } + }); + + it('maps number to DOUBLE indexable', () => { + const specs = buildColumnSpecs([p(BasePropertyType.NUMBER)]); + const user = specs[specs.length - 1]; + expect(user.ddlType).toBe('DOUBLE'); + expect(user.indexable).toBe(true); + }); + + it('maps date to TIMESTAMPTZ indexable', () => { + const specs = buildColumnSpecs([p(BasePropertyType.DATE)]); + const user = specs[specs.length - 1]; + expect(user.ddlType).toBe('TIMESTAMPTZ'); + expect(user.indexable).toBe(true); + }); + + it('maps checkbox to BOOLEAN indexable', () => { + const specs = buildColumnSpecs([p(BasePropertyType.CHECKBOX)]); + const user = specs[specs.length - 1]; + expect(user.ddlType).toBe('BOOLEAN'); + }); + + it('maps select / status to VARCHAR indexable', () => { + for (const t of [BasePropertyType.SELECT, BasePropertyType.STATUS]) { + const specs = buildColumnSpecs([p(t)]); + const user = specs[specs.length - 1]; + expect(user.ddlType).toBe('VARCHAR'); + expect(user.indexable).toBe(true); + } + }); + + it('maps multiSelect / file / multi-person to JSON non-indexable', () => { + for (const t of [BasePropertyType.MULTI_SELECT, BasePropertyType.FILE]) { + const specs = buildColumnSpecs([p(t)]); + const user = specs[specs.length - 1]; + expect(user.ddlType).toBe('JSON'); + expect(user.indexable).toBe(false); + } + const specs = buildColumnSpecs([p(BasePropertyType.PERSON, { allowMultiple: true })]); + expect(specs[specs.length - 1].ddlType).toBe('JSON'); + }); + + it('maps single-person to VARCHAR indexable when allowMultiple=false', () => { + const specs = buildColumnSpecs([p(BasePropertyType.PERSON, { allowMultiple: false })]); + const user = specs[specs.length - 1]; + expect(user.ddlType).toBe('VARCHAR'); + expect(user.indexable).toBe(true); + }); + + it('skips unknown property types', () => { + const specs = buildColumnSpecs([p('unknown-type-x')]); + expect(specs.length).toBe(SYSTEM_COLUMNS.length); + }); +}); diff --git a/apps/server/src/core/base/query-cache/column-types.ts b/apps/server/src/core/base/query-cache/column-types.ts new file mode 100644 index 00000000..bf84cb19 --- /dev/null +++ b/apps/server/src/core/base/query-cache/column-types.ts @@ -0,0 +1,60 @@ +import { BasePropertyType, BasePropertyTypeValue } from '../base.schemas'; +import { ColumnSpec } from './query-cache.types'; +import type { BaseProperty } from '@docmost/db/types/entity.types'; + +export const SYSTEM_COLUMNS: ColumnSpec[] = [ + { column: 'id', ddlType: 'VARCHAR', indexable: false }, + { column: 'position', ddlType: 'VARCHAR', indexable: true }, + { column: 'created_at', ddlType: 'TIMESTAMPTZ', indexable: true }, + { column: 'updated_at', ddlType: 'TIMESTAMPTZ', indexable: true }, + { column: 'last_updated_by_id', ddlType: 'VARCHAR', indexable: true }, + { column: 'deleted_at', ddlType: 'TIMESTAMPTZ', indexable: false }, + { column: 'search_text', ddlType: 'VARCHAR', indexable: false }, +]; + +type PropertyLike = Pick; + +export function buildColumnSpecs(properties: PropertyLike[]): ColumnSpec[] { + const out: ColumnSpec[] = [...SYSTEM_COLUMNS]; + for (const prop of properties) { + const spec = buildUserColumn(prop); + if (spec) out.push(spec); + } + return out; +} + +function buildUserColumn(prop: PropertyLike): ColumnSpec | null { + const t = prop.type as BasePropertyTypeValue; + switch (t) { + case BasePropertyType.TEXT: + case BasePropertyType.URL: + case BasePropertyType.EMAIL: + return { column: prop.id, ddlType: 'VARCHAR', indexable: true, property: prop }; + case BasePropertyType.NUMBER: + return { column: prop.id, ddlType: 'DOUBLE', indexable: true, property: prop }; + case BasePropertyType.DATE: + return { column: prop.id, ddlType: 'TIMESTAMPTZ', indexable: true, property: prop }; + case BasePropertyType.CHECKBOX: + return { column: prop.id, ddlType: 'BOOLEAN', indexable: true, property: prop }; + case BasePropertyType.SELECT: + case BasePropertyType.STATUS: + return { column: prop.id, ddlType: 'VARCHAR', indexable: true, property: prop }; + case BasePropertyType.MULTI_SELECT: + case BasePropertyType.FILE: + return { column: prop.id, ddlType: 'JSON', indexable: false, property: prop }; + case BasePropertyType.PERSON: { + const allowMultiple = !!(prop.typeOptions as any)?.allowMultiple; + return allowMultiple + ? { column: prop.id, ddlType: 'JSON', indexable: false, property: prop } + : { column: prop.id, ddlType: 'VARCHAR', indexable: true, property: prop }; + } + // System types are modelled as system columns on base_rows — do not add + // a per-property column for them. They're already in SYSTEM_COLUMNS. + case BasePropertyType.CREATED_AT: + case BasePropertyType.LAST_EDITED_AT: + case BasePropertyType.LAST_EDITED_BY: + return null; + default: + return null; + } +} diff --git a/apps/server/src/core/base/query-cache/query-cache.types.ts b/apps/server/src/core/base/query-cache/query-cache.types.ts new file mode 100644 index 00000000..0d785d90 --- /dev/null +++ b/apps/server/src/core/base/query-cache/query-cache.types.ts @@ -0,0 +1,37 @@ +import type { DuckDBConnection, DuckDBInstance } from '@duckdb/node-api'; +import type { BaseProperty } from '@docmost/db/types/entity.types'; + +export type DuckDbColumnType = + | 'VARCHAR' + | 'DOUBLE' + | 'BOOLEAN' + | 'TIMESTAMPTZ' + | 'JSON'; + +export type ColumnSpec = { + // The uuid of the property (user-defined props) or a stable literal + // ('id', 'position', 'created_at', 'updated_at', 'last_updated_by_id', + // 'deleted_at', 'search_text') for system columns. + column: string; + ddlType: DuckDbColumnType; + indexable: boolean; + // For user-defined props we keep the source BaseProperty so callers can + // resolve the extraction rule from JSON. + property?: Pick; +}; + +export type LoadedCollection = { + baseId: string; + schemaVersion: number; + columns: ColumnSpec[]; + instance: DuckDBInstance; + connection: DuckDBConnection; + lastAccessedAt: number; +}; + +export type ChangeEnvelope = + | { kind: 'row-upsert'; baseId: string; row: Record } + | { kind: 'row-delete'; baseId: string; rowId: string } + | { kind: 'rows-delete'; baseId: string; rowIds: string[] } + | { kind: 'row-reorder'; baseId: string; rowId: string; position: string } + | { kind: 'schema-invalidate'; baseId: string; schemaVersion: number };