Compare commits

...
20 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
Philip Okugbe 911c1057d6 feat(server): add global encryption module (AES-256-GCM) (#2400)
Provides an injectable EncryptionService that encrypts/decrypts strings
with AES-256-GCM using a key derived from APP_SECRET with domain
separation.
2026-08-16 12:35:37 +01:00
Philipinho d136864ef1 sync 2026-08-16 11:46:50 +01:00
24 changed files with 487 additions and 8 deletions
@@ -15,6 +15,14 @@ export interface IAiSearchResponse {
}>;
}
export async function hintVectorCache(): Promise<void> {
try {
await api.post("/ai/vector-cache-hint");
} catch {
// best-effort cache hint
}
}
export async function aiAnswers(
params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
@@ -13,11 +13,16 @@ import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { hintVectorCache } from "@/ee/ai/services/ai-search-service.ts";
import { getAiVectorDriver } from "@/lib/config.ts";
interface SearchSpotlightProps {
spaceId?: string;
}
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const workspace = useAtomValue(workspaceAtom);
const { t } = useTranslation();
const hasAiFeature = useHasFeature(Feature.AI);
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
@@ -96,6 +101,15 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
/>
));
const handleSpotlightOpen = () => {
if (
workspace?.settings?.ai?.search === true &&
getAiVectorDriver() === "turbopuffer"
) {
hintVectorCache();
}
};
const handleFiltersChange = (newFilters: any) => {
setFilters(newFilters);
};
@@ -115,6 +129,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
<Spotlight.Root
size="xl"
maxHeight={600}
onSpotlightOpen={handleSpotlightOpen}
store={searchSpotlightStore}
query={query}
onQueryChange={setQuery}
+4
View File
@@ -43,6 +43,10 @@ export function isCloud(): boolean {
return castToBoolean(getConfigValue("CLOUD"));
}
export function getAiVectorDriver(): string {
return getConfigValue("AI_VECTOR_DRIVER");
}
export function getAvatarUrl(
avatarUrl: string,
type: AvatarIconType = AvatarIconType.AVATAR,
+2
View File
@@ -16,6 +16,7 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
AI_VECTOR_DRIVER,
} = loadEnv(mode, envPath, "");
return {
@@ -31,6 +32,7 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
AI_VECTOR_DRIVER,
},
APP_VERSION: JSON.stringify(process.env.npm_package_version),
},
+1
View File
@@ -66,6 +66,7 @@
"@nestjs/websockets": "11.1.28",
"@node-saml/passport-saml": "5.1.0",
"@socket.io/redis-adapter": "8.3.0",
"@turbopuffer/turbopuffer": "^2.8.0",
"ai": "6.0.134",
"ai-sdk-ollama": "3.8.1",
"bcrypt": "6.0.0",
+2
View File
@@ -27,6 +27,7 @@ import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
import { EncryptionModule } from './integrations/encryption/encryption.module';
const enterpriseModules = [];
try {
@@ -53,6 +54,7 @@ try {
CoreModule,
DatabaseModule,
EnvironmentModule,
EncryptionModule,
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
@@ -57,6 +57,7 @@ import {
JSONContent,
} from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
import { collapseBlankLines } from '../common/helpers';
// @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089
@@ -146,7 +147,7 @@ export function htmlToJson(html: string) {
}
export function jsonToText(tiptapJson: JSONContent) {
return generateText(tiptapJson, tiptapExtensions);
return collapseBlankLines(generateText(tiptapJson, tiptapExtensions));
}
export function jsonToNode(tiptapJson: JSONContent) {
+1
View File
@@ -1,4 +1,5 @@
export * from './utils';
export * from './text.utils';
export * from './nanoid.utils';
export * from './file.helper';
export * from './constants';
@@ -0,0 +1,14 @@
import { collapseBlankLines } from './text.utils';
describe('collapseBlankLines', () => {
it.each([
['a\n\n\n\nb', 'a\n\nb'],
['a\n\nb', 'a\n\nb'],
['a\nb', 'a\nb'],
['\n\n\n\na\n\n\n', '\n\na\n\n'],
['no newlines', 'no newlines'],
['', ''],
])('collapses %j to %j', (input, expected) => {
expect(collapseBlankLines(input)).toBe(expected);
});
});
@@ -0,0 +1,3 @@
export function collapseBlankLines(text: string): string {
return text.replace(/\n{2,}/g, '\n\n');
}
@@ -496,10 +496,21 @@ export class PageService {
},
);
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageIds: pageIdsToMove,
workspaceId: rootPage.workspaceId,
});
await this.aiQueue.add(
QueueJob.PAGE_MOVED_TO_SPACE,
{
pageIds: pageIdsToMove,
spaceId,
workspaceId: rootPage.workspaceId,
},
{
attempts: 2,
backoff: {
type: 'fixed',
delay: 2 * 60 * 1000,
},
},
);
}
});
@@ -8,6 +8,7 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
export class SpaceEvent {
spaceId: string;
workspaceId: string;
}
@Injectable()
@@ -22,12 +23,12 @@ export class SpaceListener {
@OnEvent(EventName.SPACE_DELETED)
async handleSpaceDeleted(event: SpaceEvent) {
const { spaceId } = event;
const { spaceId, workspaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId });
}
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId, workspaceId });
}
isTypesense(): boolean {
@@ -230,6 +230,7 @@ export class SpaceRepo {
this.eventEmitter.emit(EventName.SPACE_DELETED, {
spaceId,
workspaceId,
});
}
}
@@ -211,6 +211,24 @@ export class WorkspaceRepo {
.executeTakeFirst();
}
async updateAiEmbeddingFingerprint(
workspaceId: string,
fingerprint: { driver: string; model: string; dimensions: number },
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|| jsonb_build_object('embedding', ${JSON.stringify(fingerprint)}::text::jsonb))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.execute();
}
async updateSharingSettings(
workspaceId: string,
prefKey: string,
@@ -0,0 +1,13 @@
export class UnableToInitialize extends Error {
constructor(message: string) {
super(`Unable to initialize the encryption service: ${message}`);
this.name = 'UnableToInitialize';
}
}
export class UnableToDecrypt extends Error {
constructor(reason: string) {
super(`Unable to decrypt the ciphertext: ${reason}`);
this.name = 'UnableToDecrypt';
}
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { EncryptionService } from './encryption.service';
@Global()
@Module({
providers: [EncryptionService],
exports: [EncryptionService],
})
export class EncryptionModule {}
@@ -0,0 +1,184 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EncryptionService } from './encryption.service';
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
import { EnvironmentService } from '../environment/environment.service';
const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890';
const buildService = (appSecret: string | undefined) => {
const env = { getAppSecret: () => appSecret } as EnvironmentService;
return new EncryptionService(env);
};
const decodeEnvelope = (encrypted: string) =>
JSON.parse(Buffer.from(encrypted, 'base64').toString()) as {
iv: string;
authTag: string;
cipherText: string;
};
const encodeEnvelope = (envelope: {
iv: string;
authTag: string;
cipherText: string;
}) => Buffer.from(JSON.stringify(envelope)).toString('base64');
describe('EncryptionService', () => {
let service: EncryptionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EncryptionService,
{
provide: EnvironmentService,
useValue: { getAppSecret: () => APP_SECRET },
},
],
}).compile();
service = module.get<EncryptionService>(EncryptionService);
});
describe('initialization', () => {
it('compiles via Nest DI', () => {
expect(service).toBeDefined();
});
it('throws UnableToInitialize when APP_SECRET is missing', () => {
expect(() => buildService(undefined)).toThrow(UnableToInitialize);
expect(() => buildService('')).toThrow(UnableToInitialize);
});
});
describe('encrypt + decrypt round-trip', () => {
it('decrypts back to the original plaintext', () => {
const plaintext = 'hello world';
const encrypted = service.encrypt(plaintext);
expect(service.decrypt(encrypted)).toBe(plaintext);
});
it('handles empty string', () => {
const encrypted = service.encrypt('');
expect(service.decrypt(encrypted)).toBe('');
});
it('handles unicode (multi-byte UTF-8)', () => {
const plaintext = 'héllo 🔐 世界';
const encrypted = service.encrypt(plaintext);
expect(service.decrypt(encrypted)).toBe(plaintext);
});
it('handles long plaintext (>1 block)', () => {
const plaintext = 'a'.repeat(10_000);
const encrypted = service.encrypt(plaintext);
expect(service.decrypt(encrypted)).toBe(plaintext);
});
it('produces distinct ciphertexts for the same plaintext (random IV)', () => {
const plaintext = 'same input';
const a = service.encrypt(plaintext);
const b = service.encrypt(plaintext);
expect(a).not.toBe(b);
expect(service.decrypt(a)).toBe(plaintext);
expect(service.decrypt(b)).toBe(plaintext);
});
});
describe('cross-key isolation', () => {
it('cannot decrypt ciphertext produced under a different APP_SECRET', () => {
const other = buildService('totally-different-secret-value-9876543210');
const encrypted = service.encrypt('secret');
expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt);
});
});
describe('tamper detection', () => {
it('rejects modified ciphertext', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const tamperedCipher = Buffer.from(env.cipherText, 'base64');
tamperedCipher[0] ^= 0x01;
const tampered = encodeEnvelope({
...env,
cipherText: tamperedCipher.toString('base64'),
});
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
});
it('rejects modified auth tag', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const tamperedTag = Buffer.from(env.authTag, 'base64');
tamperedTag[0] ^= 0x01;
const tampered = encodeEnvelope({
...env,
authTag: tamperedTag.toString('base64'),
});
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
});
it('rejects modified IV', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const tamperedIV = Buffer.from(env.iv, 'base64');
tamperedIV[0] ^= 0x01;
const tampered = encodeEnvelope({
...env,
iv: tamperedIV.toString('base64'),
});
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
});
});
describe('malformed payloads', () => {
it('rejects non-base64 garbage', () => {
expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow(
UnableToDecrypt,
);
});
it('rejects base64 of non-JSON', () => {
const garbage = Buffer.from('not json at all').toString('base64');
expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt);
});
it('rejects JSON missing required fields', () => {
const partial = encodeEnvelope({
iv: Buffer.alloc(12).toString('base64'),
authTag: Buffer.alloc(16).toString('base64'),
} as never);
expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt);
});
it('rejects wrong-length IV', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const bad = encodeEnvelope({
...env,
iv: Buffer.alloc(8).toString('base64'),
});
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
});
it('rejects wrong-length auth tag', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const bad = encodeEnvelope({
...env,
authTag: Buffer.alloc(8).toString('base64'),
});
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
});
});
describe('envelope format', () => {
it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
expect(Buffer.from(env.iv, 'base64')).toHaveLength(12);
expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16);
expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0);
});
});
});
@@ -0,0 +1,108 @@
// https://github.com/nhedger/nestjs-encryption - MIT
import { Injectable } from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto';
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
import { EnvironmentService } from '../environment/environment.service';
const ALGORITHM = 'aes-256-gcm';
const KEY_DOMAIN = 'docmost:encryption:v1';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
type AEADPayload<TFormat = string | Buffer> = {
iv: TFormat;
authTag: TFormat;
cipherText: TFormat;
};
@Injectable()
export class EncryptionService {
private readonly key: Buffer;
constructor(environmentService: EnvironmentService) {
const appSecret = environmentService.getAppSecret();
if (!appSecret) {
throw new UnableToInitialize('APP_SECRET is not set.');
}
this.key = createHash('sha256')
.update(KEY_DOMAIN)
.update(appSecret)
.digest();
}
public encrypt(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, this.key, iv);
const cipherText = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
const aead: AEADPayload<string> = {
iv: iv.toString('base64'),
authTag: authTag.toString('base64'),
cipherText: cipherText.toString('base64'),
};
return Buffer.from(JSON.stringify(aead)).toString('base64');
}
public decrypt(encrypted: string): string {
try {
const { iv, authTag, cipherText } = this.decodeAEADPayload(encrypted);
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(cipherText),
decipher.final(),
]);
return decrypted.toString('utf8');
} catch (e: unknown) {
throw new UnableToDecrypt((e as Error).message);
}
}
private decodeAEADPayload(encodedPayload: string): AEADPayload<Buffer> {
const payload = Buffer.from(encodedPayload, 'base64');
let deserializedPkg: Record<string, unknown>;
try {
deserializedPkg = JSON.parse(payload.toString());
} catch {
throw new Error('The decoded AEAD payload is not a valid JSON string.');
}
for (const field of ['iv', 'authTag', 'cipherText']) {
if (!Object.prototype.hasOwnProperty.call(deserializedPkg, field)) {
throw new Error(`The AEAD payload is missing the ${field} field.`);
}
}
const iv = Buffer.from(deserializedPkg.iv as string, 'base64');
if (iv.length !== IV_LENGTH) {
throw new Error(
`The decoded IV is not the correct length. Expected ${IV_LENGTH} bytes, got ${iv.length} bytes.`,
);
}
const authTag = Buffer.from(deserializedPkg.authTag as string, 'base64');
if (authTag.length !== AUTH_TAG_LENGTH) {
throw new Error(
`The decoded auth tag is not the correct length. Expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length} bytes.`,
);
}
const cipherText = Buffer.from(
deserializedPkg.cipherText as string,
'base64',
);
return { iv, authTag, cipherText };
}
}
@@ -310,6 +310,31 @@ export class EnvironmentService {
return val === 'true';
}
getAiVectorDriver(): string {
return this.configService
.get<string>('AI_VECTOR_DRIVER', 'pgvector')
.toLowerCase();
}
getTurbopufferApiKey(): string {
return this.configService.get<string>('TURBOPUFFER_API_KEY');
}
getTurbopufferRegion(): string {
return this.configService.get<string>('TURBOPUFFER_REGION');
}
getTurbopufferBaseUrl(): string {
return this.configService.get<string>('TURBOPUFFER_BASE_URL');
}
getTurbopufferNamespacePrefix(): string {
return this.configService.get<string>(
'TURBOPUFFER_NAMESPACE_PREFIX',
'docmost',
);
}
getOpenAiApiKey(): string {
return this.configService.get<string>('OPENAI_API_KEY');
}
@@ -5,6 +5,7 @@ import {
IsOptional,
IsString,
IsUrl,
Matches,
MinLength,
ValidateIf,
validateSync,
@@ -108,6 +109,41 @@ export class EnvironmentVariables {
@IsString()
AI_DRIVER: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_VECTOR_DRIVER)
@IsIn(['pgvector', 'turbopuffer'])
@IsString()
AI_VECTOR_DRIVER: string;
@ValidateIf((obj) => obj.AI_VECTOR_DRIVER === 'turbopuffer')
@IsNotEmpty()
@IsString()
TURBOPUFFER_API_KEY: string;
@ValidateIf(
(obj) =>
obj.AI_VECTOR_DRIVER === 'turbopuffer' && !obj.TURBOPUFFER_BASE_URL,
)
@IsNotEmpty({
message:
'TURBOPUFFER_REGION is required when AI_VECTOR_DRIVER is turbopuffer, unless TURBOPUFFER_BASE_URL is set',
})
@IsString()
TURBOPUFFER_REGION: string;
@IsOptional()
@ValidateIf((obj) => obj.TURBOPUFFER_BASE_URL != '' && obj.TURBOPUFFER_BASE_URL != null)
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
TURBOPUFFER_BASE_URL: string;
@IsOptional()
@IsString()
@Matches(/^[A-Za-z0-9\-_.]{1,90}$/, {
message:
'TURBOPUFFER_NAMESPACE_PREFIX may only contain letters, digits, dot, dash, underscore (max 90 chars)',
})
TURBOPUFFER_NAMESPACE_PREFIX: string;
@IsOptional()
@IsString()
AI_EMBEDDING_MODEL: string;
@@ -61,6 +61,7 @@ export enum QueueJob {
WORKSPACE_DELETED = 'workspace-deleted',
WORKSPACE_CREATE_EMBEDDINGS = 'workspace-create-embeddings',
WORKSPACE_DELETE_EMBEDDINGS = 'workspace-delete-embeddings',
WORKSPACE_RESET_EMBEDDINGS = 'workspace-reset-embeddings',
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
@@ -49,6 +49,10 @@ export class StaticModule implements OnModuleInit {
: undefined,
POSTHOG_HOST: this.environmentService.getPostHogHost(),
POSTHOG_KEY: this.environmentService.getPostHogKey(),
AI_VECTOR_DRIVER:
this.environmentService.getAiVectorDriver() === 'turbopuffer'
? 'turbopuffer'
: undefined,
};
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
+17
View File
@@ -593,6 +593,9 @@ importers:
'@socket.io/redis-adapter':
specifier: 8.3.0
version: 8.3.0(socket.io-adapter@2.5.4(supports-color@10.2.2))(supports-color@10.2.2)
'@turbopuffer/turbopuffer':
specifier: ^2.8.0
version: 2.8.0
ai:
specifier: 6.0.134
version: 6.0.134(zod@4.3.6)
@@ -4382,6 +4385,9 @@ packages:
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@turbopuffer/turbopuffer@2.8.0':
resolution: {integrity: sha512-lurmVByLynuWtPtVl7SBEJ0bNakb8bs4KsafKzeLWnXA6BsQiGS9obT/7ExQVb9/yecyRn/grj8VgwUzi2xMyg==}
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
@@ -5698,6 +5704,7 @@ packages:
cron-parser@4.9.0:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'}
deprecated: v4 is no longer maintained, upgrade to v5
cron@4.4.0:
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
@@ -8168,6 +8175,9 @@ packages:
pako@2.0.3:
resolution: {integrity: sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==}
pako@2.2.0:
resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -14341,6 +14351,11 @@ snapshots:
'@tsconfig/node16@1.0.4': {}
'@turbopuffer/turbopuffer@2.8.0':
dependencies:
pako: 2.2.0
undici: 7.29.0
'@tybys/wasm-util@0.10.2':
dependencies:
tslib: 2.8.1
@@ -18749,6 +18764,8 @@ snapshots:
pako@2.0.3: {}
pako@2.2.0: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0