feat(server): add property-type to DuckDB column-spec mapping

This commit is contained in:
Philipinho
2026-04-19 20:54:59 +01:00
parent f181c6d9e8
commit 3af2db7a8b
3 changed files with 173 additions and 0 deletions
@@ -0,0 +1,76 @@
import { BasePropertyType } from '../base.schemas';
import { buildColumnSpecs, SYSTEM_COLUMNS } from './column-types';
const p = (type: string, extra: Record<string, unknown> = {}) => ({
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);
});
});
@@ -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<BaseProperty, 'id' | 'type' | 'typeOptions'>;
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;
}
}
@@ -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<BaseProperty, 'id' | 'type' | 'typeOptions'>;
};
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<string, unknown> }
| { 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 };