Compare commits

..
Author SHA1 Message Date
Philipinho 83faeefdbc fix(ai): pass AI_VECTOR_DRIVER through the client build config 2026-08-17 20:33:32 +01:00
Philipinho 7b8ade0968 feat(ai): pre-warm the vector namespace 2026-08-17 20:09:53 +01:00
Philipinho 8c4ecfaaf2 sync 2026-08-17 18:58:40 +01:00
Philipinho adbe671656 fix(ai): retry the page moved-to-space vector patch job 2026-08-17 18:52:33 +01:00
Philipinho e0d0fb15c9 fix(ai): filter search hits by the page's current space at query time 2026-08-17 18:50:21 +01:00
Philipinho 66fd84e893 fix(ai): store real embedding dimensions instead of serialized vector length 2026-08-17 18:31:50 +01:00
Philipinho 6c9836925c sync 2026-08-17 18:21:08 +01:00
Philipinho cb373c9c05 fix(ai): skip full re-embed when ai search is re-enabled within the delete grace window 2026-08-17 15:02:21 +01:00
Philipinho 38fddf9b5d fix(ai): collapse blank-line runs in extracted page text 2026-08-17 14:42:43 +01:00
Philipinho 7a2411e147 fix(ai): harden turbopuffer misconfiguration and reset failure paths 2026-08-17 01:48:54 +01:00
Philipinho 157e847550 feat(ai): warm the vector namespace cache on session start 2026-08-17 01:17:31 +01:00
Philipinho 192aadfbb4 feat(ai): rebuild turbopuffer namespaces when the embedding model changes 2026-08-17 01:06:38 +01:00
Philipinho c0e4f4cfb3 feat(ai): add turbopuffer vector driver 2026-08-17 00:53:12 +01:00
Philipinho 708e08037f refactor(ai): route vector reads and writes through the vector driver 2026-08-17 00:44:53 +01:00
Philipinho 7003c392e0 feat(ai): add pgvector driver behind the vector driver interface 2026-08-17 00:03:58 +01:00
Philipinho 738120172d feat(ai): add vector driver interface and turbopuffer request helpers 2026-08-16 23:57:48 +01:00
Philipinho d5ad7d5181 feat(ai): carry workspace and target space ids on vector lifecycle events 2026-08-16 23:37:38 +01:00
Philipinho fc34f1a7f5 feat(ai): add AI_VECTOR_DRIVER and turbopuffer configuration 2026-08-16 23:34:21 +01:00
15 changed files with 345 additions and 348 deletions
+1 -1
View File
@@ -52,7 +52,7 @@
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.16.1",
"mitt": "3.0.1",
"nanoid": "3.3.18",
"nanoid": "3.3.17",
"posthog-js": "1.391.2",
"react": "19.2.7",
"react-clear-modal": "^2.0.18",
@@ -9,7 +9,6 @@ import {
Text,
UnstyledButton,
Button,
MultiSelect,
} from "@mantine/core";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import {
@@ -53,9 +52,6 @@ const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"];
// stored value so a stale shape isn't sent to the engine.
function valueClass(op: FilterOperator, inputKind: string): string {
if (NO_VALUE_OPERATORS.includes(op)) return "none";
if (inputKind === "choices") {
return op === "any" || op === "none" ? "choicesMulti" : "choicesSingle";
}
if (inputKind === "person") {
return op === "any" || op === "none" ? "personMulti" : "personSingle";
}
@@ -74,10 +70,6 @@ function getOperatorsForType(type: string): FilterOperator[] {
DEFAULT_FILTER_OPERATORS) as FilterOperator[];
}
function isMultiChoice(op: FilterCondition["op"]): boolean {
return op === "any" || op === "none";
}
function FilterValueInput({
condition,
property,
@@ -129,32 +121,6 @@ function FilterValueInput({
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
const choices = typeOptions?.choices ?? [];
const choiceOptions = choices.map((c) => ({ value: c.id, label: c.name }));
if (isMultiChoice(condition.op)) {
const { value } = condition;
const selected = (
Array.isArray(value) ? value : value ? [value] : []
).filter((id) => choices.some((c) => c.id === id));
return (
<MultiSelect
size="xs"
data={choiceOptions}
comboboxProps={{ withinPortal: false }}
value={selected}
onChange={(values) => onChange(values)}
w={160}
styles={{
pillsList: {
maxHeight: 70,
overflowY: "auto",
},
}}
maxDropdownHeight={220}
/>
);
}
return (
<Select
size="xs"
@@ -233,18 +199,11 @@ export function ViewFilterConfigPopover({
label: p.name,
}));
const [unSaved, setUnSaved] = useState(false)
const [draft, setDraft] = useState<FilterCondition | null>(null);
const [draftConditions, setDraftConditions] =
useState<FilterCondition[]>(conditions);
useEffect(() => {
if (opened) {
setDraftConditions(conditions);
setDraft(null);
setUnSaved(false)
}
}, [opened, conditions]);
if (!opened) setDraft(null);
}, [opened]);
const handleStartDraft = useCallback(() => {
const firstProperty = properties[0];
@@ -257,21 +216,14 @@ export function ViewFilterConfigPopover({
}, [properties]);
const handleSaveDraft = useCallback(() => {
const nextConditions = draft
? [...draftConditions, draft]
: draftConditions;
onChange(nextConditions);
if (!draft) return;
onChange([...conditions, draft]);
setDraft(null);
setUnSaved(false)
}, [draft, draftConditions, onChange]);
}, [draft, conditions, onChange]);
const handleCancelDraft = useCallback(() => {
setDraftConditions(conditions)
setDraft(null);
setUnSaved(false)
}, [conditions]);
}, []);
const handleDraftPropertyChange = useCallback(
(propertyId: string | null) => {
@@ -320,19 +272,17 @@ export function ViewFilterConfigPopover({
const handleRemove = useCallback(
(index: number) => {
setUnSaved(true);
setDraftConditions((current) => current.filter((_, i) => i !== index));
onChange(conditions.filter((_, i) => i !== index));
},
[],
[conditions, onChange],
);
const handlePropertyChange = useCallback(
(index: number, propertyId: string | null) => {
if (!propertyId) return;
const newProperty = properties.find((p) => p.id === propertyId);
setUnSaved(true)
setDraftConditions((current) =>
current.map((f, i) => {
onChange(
conditions.map((f, i) => {
if (i !== index) return f;
if (newProperty) {
const validOperators = getOperatorsForType(newProperty.type);
@@ -352,16 +302,15 @@ export function ViewFilterConfigPopover({
}),
);
},
[properties],
[conditions, properties, onChange],
);
const handleOperatorChange = useCallback(
(index: number, operator: string | null) => {
if (!operator) return;
const op = operator as FilterOperator;
setUnSaved(true)
setDraftConditions((current) =>
current.map((f, i) => {
onChange(
conditions.map((f, i) => {
if (i !== index) return f;
const kind = inputKindForProperty(
properties.find((p) => p.id === f.propertyId),
@@ -371,17 +320,16 @@ export function ViewFilterConfigPopover({
}),
);
},
[properties],
[conditions, properties, onChange],
);
const handleValueChange = useCallback(
(index: number, value: unknown) => {
setUnSaved(true)
setDraftConditions((current) =>
current.map((f, i) => (i === index ? { ...f, value } : f)),
onChange(
conditions.map((f, i) => (i === index ? { ...f, value } : f)),
);
},
[],
[conditions, onChange],
);
return (
@@ -414,13 +362,13 @@ export function ViewFilterConfigPopover({
{t("Filter by")}
</Text>
{draftConditions.length === 0 && !draft && (
{conditions.length === 0 && !draft && (
<Text size="xs" c="dimmed">
{t("No filters applied")}
</Text>
)}
{draftConditions.map((condition, index) => {
{conditions.map((condition, index) => {
const needsValue = !NO_VALUE_OPERATORS.includes(condition.op);
const property = properties.find(
(p) => p.id === condition.propertyId,
@@ -523,6 +471,14 @@ export function ViewFilterConfigPopover({
/>
)}
</Group>
<Group justify="flex-end" gap="xs">
<Button variant="default" size="xs" onClick={handleCancelDraft}>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft}>
{t("Save")}
</Button>
</Group>
</Stack>
);
})()}
@@ -536,20 +492,6 @@ export function ViewFilterConfigPopover({
{t("Add filter")}
</UnstyledButton>
)}
<Group justify="flex-end" gap="xs">
<Button
variant="default"
size="xs"
onClick={handleCancelDraft}
disabled={!draft && !unSaved}
>
{t("Cancel")}
</Button>
<Button size="xs" onClick={handleSaveDraft} disabled={!draft && !unSaved}>
{t("Save")}
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
+10 -10
View File
@@ -40,30 +40,30 @@
"@clickhouse/client": "1.18.2",
"@docmost/base-formula": "workspace:*",
"@docmost/pdf-inspector": "1.9.6",
"@fastify/cookie": "11.1.2",
"@fastify/multipart": "10.1.1",
"@fastify/static": "10.1.3",
"@fastify/cookie": "11.0.2",
"@fastify/multipart": "10.0.0",
"@fastify/static": "10.1.2",
"@keyv/redis": "5.1.6",
"@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "1.30.0",
"@nest-lab/throttler-storage-redis": "1.2.0",
"@nestjs-labs/nestjs-ioredis": "11.0.4",
"@nestjs/bullmq": "11.0.5",
"@nestjs/bullmq": "11.0.4",
"@nestjs/cache-manager": "3.1.3",
"@nestjs/common": "11.2.1",
"@nestjs/common": "11.1.28",
"@nestjs/config": "4.0.4",
"@nestjs/core": "11.2.1",
"@nestjs/core": "11.1.27",
"@nestjs/event-emitter": "3.1.0",
"@nestjs/jwt": "11.0.2",
"@nestjs/mapped-types": "2.1.1",
"@nestjs/passport": "11.0.5",
"@nestjs/platform-fastify": "11.2.1",
"@nestjs/platform-socket.io": "11.2.1",
"@nestjs/platform-fastify": "11.1.28",
"@nestjs/platform-socket.io": "11.1.28",
"@nestjs/schedule": "6.1.3",
"@nestjs/terminus": "11.1.1",
"@nestjs/throttler": "6.5.0",
"@nestjs/websockets": "11.2.1",
"@nestjs/websockets": "11.1.28",
"@node-saml/passport-saml": "5.1.0",
"@socket.io/redis-adapter": "8.3.0",
"@turbopuffer/turbopuffer": "^2.8.0",
@@ -120,7 +120,7 @@
"tmp-promise": "3.0.3",
"typesense": "3.0.5",
"undici": "7.29.0",
"ws": "8.21.3",
"ws": "8.21.0",
"yauzl": "3.4.0",
"zod": "4.3.6"
},
+2 -13
View File
@@ -22,8 +22,7 @@ import { TelemetryModule } from './integrations/telemetry/telemetry.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from './integrations/redis/redis-config.service';
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis, { defaultReconnectStrategy } from '@keyv/redis';
import { parseRedisUrl } from './common/helpers';
import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
@@ -63,20 +62,10 @@ try {
isGlobal: true,
useFactory: async (environmentService: EnvironmentService) => {
const redisUrl = environmentService.getRedisUrl();
const { family, tls } = parseRedisUrl(redisUrl);
return {
ttl: 5 * 1000,
stores: [
new KeyvRedis({
url: redisUrl,
socket: {
family,
reconnectStrategy: defaultReconnectStrategy,
...tls,
},
}),
],
stores: [new KeyvRedis(redisUrl)],
};
},
inject: [EnvironmentService],
@@ -66,7 +66,6 @@ export class CollaborationGateway {
password: this.redisConfig.password,
db: this.redisConfig.db,
family: this.redisConfig.family,
tls: this.redisConfig.tls,
retryStrategy: createRetryStrategy(),
}),
serverId: `collab-${os?.hostname()}-${nanoid(10)}`,
+4 -12
View File
@@ -30,14 +30,13 @@ export type RedisConfig = {
db: number;
password?: string;
family?: number;
tls?: { rejectUnauthorized?: boolean };
};
export function parseRedisUrl(redisUrl: string): RedisConfig {
// format - redis[s]://[[username][:password]@][host][:port][/db-number][?family=4|6][&rejectUnauthorized=false]
// format - redis[s]://[[username][:password]@][host][:port][/db-number][?family=4|6]
const url = new URL(redisUrl);
const { hostname, port, password, pathname, protocol, searchParams } = url;
const portInt = port ? parseInt(port, 10) : 6379;
const { hostname, port, password, pathname, searchParams } = url;
const portInt = parseInt(port, 10);
let db: number = 0;
// extract db value if present
@@ -55,14 +54,7 @@ export function parseRedisUrl(redisUrl: string): RedisConfig {
family = parseInt(familyParam, 10);
}
const tls =
protocol === 'rediss:'
? searchParams.get('rejectUnauthorized') === 'false'
? { rejectUnauthorized: false }
: {}
: undefined;
return { host: hostname, port: portInt, password: password || undefined, db, family, tls };
return { host: hostname, port: portInt, password, db, family };
}
export function createRetryStrategy() {
@@ -339,25 +339,15 @@ export class SpaceMemberService {
return;
}
await executeTx(this.db, async (trx) => {
await trx
.selectFrom('spaces')
.select('id')
.where('id', '=', dto.spaceId)
.forUpdate()
.executeTakeFirst();
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId);
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId, trx);
}
await this.spaceMemberRepo.updateSpaceMember(
{ role: dto.role },
spaceMember.id,
dto.spaceId,
trx,
);
});
await this.spaceMemberRepo.updateSpaceMember(
{ role: dto.role },
spaceMember.id,
dto.spaceId,
);
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
@@ -378,14 +368,10 @@ export class SpaceMemberService {
});
}
async validateLastAdmin(
spaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
async validateLastAdmin(spaceId: string): Promise<void> {
const spaceOwnerCount = await this.spaceMemberRepo.roleCountBySpaceId(
SpaceRole.ADMIN,
spaceId,
trx,
);
if (spaceOwnerCount === 1) {
throw new BadRequestException(
@@ -46,10 +46,8 @@ export class SpaceMemberRepo {
updatableSpaceMember: UpdatableSpaceMember,
spaceMemberId: string,
spaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
await this.db
.updateTable('spaceMembers')
.set(updatableSpaceMember)
.where('id', '=', spaceMemberId)
@@ -94,13 +92,8 @@ export class SpaceMemberRepo {
.execute();
}
async roleCountBySpaceId(
role: string,
spaceId: string,
trx?: KyselyTransaction,
): Promise<number> {
const db = dbOrTx(this.db, trx);
const { count } = await db
async roleCountBySpaceId(role: string, spaceId: string): Promise<number> {
const { count } = await this.db
.selectFrom('spaceMembers')
.select((eb) => eb.fn.count('role').as('count'))
.where('role', '=', role)
@@ -5,7 +5,6 @@ import {
import { Injectable, Logger } from '@nestjs/common';
import { EnvironmentService } from '../environment/environment.service';
import { Redis } from 'ioredis';
import { parseRedisUrl } from '../../common/helpers';
@Injectable()
export class RedisHealthIndicator {
@@ -20,10 +19,8 @@ export class RedisHealthIndicator {
const indicator = this.healthIndicatorService.check(key);
try {
const redisUrl = this.environmentService.getRedisUrl();
const redis = new Redis(redisUrl, {
const redis = new Redis(this.environmentService.getRedisUrl(), {
maxRetriesPerRequest: 15,
tls: parseRedisUrl(redisUrl).tls,
});
await redis.ping();
@@ -18,7 +18,6 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
password: redisConfig.password,
db: redisConfig.db,
family: redisConfig.family,
tls: redisConfig.tls,
retryStrategy: createRetryStrategy(),
},
defaultJobOptions: {
@@ -19,7 +19,6 @@ export class RedisConfigService implements RedisOptionsFactory {
password: redisConfig.password,
db: redisConfig.db,
family: redisConfig.family,
tls: redisConfig.tls,
retryStrategy: createRetryStrategy(),
},
};
@@ -17,7 +17,6 @@ export class WsRedisIoAdapter extends IoAdapter {
const options: RedisOptions = {
family: this.redisConfig.family,
tls: this.redisConfig.tls,
retryStrategy: createRetryStrategy(),
};
+285 -183
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -6,12 +6,12 @@ patchedDependencies:
overrides:
prosemirror-changeset: 2.4.0
glob: 13.0.6
ws: 8.21.3
ws: 8.21.0
dompurify: 3.4.13
mermaid: 11.16.1
undici: 7.29.0
tmp: 0.2.7
nanoid@^3: 3.3.18
nanoid@^3: 3.3.17
lodash-es: 4.18.1
express-rate-limit: 8.2.2
flatted: 3.4.2