chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+583
View File
@@ -0,0 +1,583 @@
import { db } from '@sim/db'
import { document, embedding, knowledgeBase } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { generateId } from '@sim/utils/id'
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm'
import type {
BatchOperationResult,
ChunkData,
ChunkFilters,
ChunkQueryResult,
CreateChunkData,
} from '@/lib/knowledge/chunks/types'
import { getEmbeddingModelInfo } from '@/lib/knowledge/embedding-models'
import { generateEmbeddings } from '@/lib/knowledge/embeddings'
import { estimateTokenCount } from '@/lib/tokenization/estimators'
const logger = createLogger('ChunksService')
const KB_CHUNK_LOCK_TIMEOUT_MS = 5_000
/**
* Query chunks for a document with filtering and pagination
*/
export async function queryChunks(
documentId: string,
filters: ChunkFilters,
requestId: string
): Promise<ChunkQueryResult> {
const {
search,
enabled = 'all',
limit = 50,
offset = 0,
sortBy = 'chunkIndex',
sortOrder = 'asc',
} = filters
const conditions = [eq(embedding.documentId, documentId)]
if (enabled === 'true') {
conditions.push(eq(embedding.enabled, true))
} else if (enabled === 'false') {
conditions.push(eq(embedding.enabled, false))
}
if (search) {
conditions.push(ilike(embedding.content, `%${search}%`))
}
const chunks = await db
.select({
id: embedding.id,
chunkIndex: embedding.chunkIndex,
content: embedding.content,
contentLength: embedding.contentLength,
tokenCount: embedding.tokenCount,
enabled: embedding.enabled,
startOffset: embedding.startOffset,
endOffset: embedding.endOffset,
tag1: embedding.tag1,
tag2: embedding.tag2,
tag3: embedding.tag3,
tag4: embedding.tag4,
tag5: embedding.tag5,
tag6: embedding.tag6,
tag7: embedding.tag7,
createdAt: embedding.createdAt,
updatedAt: embedding.updatedAt,
})
.from(embedding)
.where(and(...conditions))
.orderBy(
(() => {
const col =
sortBy === 'tokenCount'
? embedding.tokenCount
: sortBy === 'enabled'
? embedding.enabled
: embedding.chunkIndex
return sortOrder === 'desc' ? desc(col) : asc(col)
})()
)
.limit(limit)
.offset(offset)
const totalCount = await db
.select({ count: sql`count(*)` })
.from(embedding)
.where(and(...conditions))
logger.info(`[${requestId}] Retrieved ${chunks.length} chunks for document ${documentId}`)
return {
chunks: chunks as ChunkData[],
pagination: {
total: Number(totalCount[0]?.count || 0),
limit,
offset,
hasMore: chunks.length === limit,
},
}
}
/**
* Create a new chunk for a document.
*
* Assigns `chunkIndex` as `max(chunkIndex) + 1` under a transactional
* `pg_advisory_xact_lock` keyed on the document, so concurrent calls for the
* same document serialize instead of computing the same index and colliding
* on the `(document_id, chunk_index)` unique constraint. A `SELECT ... FOR
* UPDATE` on the current max row doesn't prevent that collision, since the
* row it would lock is unrelated to the not-yet-inserted next row. A row
* lock on `document` instead of an advisory lock would also work, but would
* invert the embedding-before-document lock order every other chunk
* mutation path uses (see lock-order.test.ts) — the advisory lock is a
* separate namespace, so it can't deadlock against that convention.
*
* `pg_advisory_xact_lock` auto-releases at transaction end, so there's no
* session lock to leak onto a pooled connection, and `lock_timeout` bounds
* the wait (it raises SQLSTATE 55P03 instead of hanging a pooled connection)
* if a same-document holder is stuck.
*/
export async function createChunk(
knowledgeBaseId: string,
documentId: string,
docTags: Record<string, string | number | boolean | Date | null>,
chunkData: CreateChunkData,
requestId: string,
workspaceId?: string | null
): Promise<ChunkData> {
logger.info(`[${requestId}] Generating embedding for manual chunk`)
const kbRow = await db
.select({ embeddingModel: knowledgeBase.embeddingModel })
.from(knowledgeBase)
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
.limit(1)
if (kbRow.length === 0) {
throw new Error('Knowledge base not found')
}
const kbEmbeddingModel = kbRow[0].embeddingModel
const { embeddings } = await generateEmbeddings(
[chunkData.content],
kbEmbeddingModel,
workspaceId
)
const tokenCount = estimateTokenCount(
chunkData.content,
getEmbeddingModelInfo(kbEmbeddingModel).tokenizerProvider
)
const chunkId = generateId()
const now = new Date()
const newChunk = await db.transaction(async (tx) => {
await tx.execute(
sql`select set_config('lock_timeout', ${`${KB_CHUNK_LOCK_TIMEOUT_MS}ms`}, true)`
)
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${`kb_chunk_seq:${documentId}`}, 0))`
)
const activeDocument = await tx
.select({ id: document.id })
.from(document)
.innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id))
.where(
and(
eq(document.id, documentId),
eq(document.knowledgeBaseId, knowledgeBaseId),
isNull(document.archivedAt),
isNull(document.deletedAt),
isNull(knowledgeBase.deletedAt)
)
)
.limit(1)
if (activeDocument.length === 0) {
throw new Error('Document not found')
}
const lastChunk = await tx
.select({ chunkIndex: embedding.chunkIndex })
.from(embedding)
.where(eq(embedding.documentId, documentId))
.orderBy(sql`${embedding.chunkIndex} DESC`)
.limit(1)
const nextChunkIndex = lastChunk.length > 0 ? lastChunk[0].chunkIndex + 1 : 0
const chunkDBData = {
id: chunkId,
knowledgeBaseId,
documentId,
chunkIndex: nextChunkIndex,
chunkHash: sha256Hex(chunkData.content),
content: chunkData.content,
contentLength: chunkData.content.length,
tokenCount: tokenCount.count,
embedding: embeddings[0],
embeddingModel: kbEmbeddingModel,
startOffset: 0, // Manual chunks don't have document offsets
endOffset: chunkData.content.length,
// Inherit text tags from parent document
tag1: docTags.tag1 as string | null,
tag2: docTags.tag2 as string | null,
tag3: docTags.tag3 as string | null,
tag4: docTags.tag4 as string | null,
tag5: docTags.tag5 as string | null,
tag6: docTags.tag6 as string | null,
tag7: docTags.tag7 as string | null,
// Inherit number tags from parent document (5 slots)
number1: docTags.number1 as number | null,
number2: docTags.number2 as number | null,
number3: docTags.number3 as number | null,
number4: docTags.number4 as number | null,
number5: docTags.number5 as number | null,
// Inherit date tags from parent document (2 slots)
date1: docTags.date1 as Date | null,
date2: docTags.date2 as Date | null,
// Inherit boolean tags from parent document (3 slots)
boolean1: docTags.boolean1 as boolean | null,
boolean2: docTags.boolean2 as boolean | null,
boolean3: docTags.boolean3 as boolean | null,
enabled: chunkData.enabled ?? true,
createdAt: now,
updatedAt: now,
}
await tx.insert(embedding).values(chunkDBData)
// Update document statistics
await tx
.update(document)
.set({
chunkCount: sql`${document.chunkCount} + 1`,
tokenCount: sql`${document.tokenCount} + ${tokenCount.count}`,
characterCount: sql`${document.characterCount} + ${chunkData.content.length}`,
})
.where(eq(document.id, documentId))
return {
id: chunkId,
chunkIndex: nextChunkIndex,
content: chunkData.content,
contentLength: chunkData.content.length,
tokenCount: tokenCount.count,
enabled: chunkData.enabled ?? true,
startOffset: 0,
endOffset: chunkData.content.length,
tag1: docTags.tag1,
tag2: docTags.tag2,
tag3: docTags.tag3,
tag4: docTags.tag4,
tag5: docTags.tag5,
tag6: docTags.tag6,
tag7: docTags.tag7,
createdAt: now,
updatedAt: now,
} as ChunkData
})
logger.info(`[${requestId}] Created chunk ${chunkId} in document ${documentId}`)
return newChunk
}
/**
* Perform batch operations on chunks
*/
export async function batchChunkOperation(
documentId: string,
operation: 'enable' | 'disable' | 'delete',
chunkIds: string[],
requestId: string
): Promise<BatchOperationResult> {
logger.info(
`[${requestId}] Starting batch ${operation} operation on ${chunkIds.length} chunks for document ${documentId}`
)
const errors: string[] = []
let successCount = 0
if (operation === 'delete') {
// Handle batch delete with transaction for consistency
await db.transaction(async (tx) => {
// Get chunks to delete for statistics update
const chunksToDelete = await tx
.select({
id: embedding.id,
tokenCount: embedding.tokenCount,
contentLength: embedding.contentLength,
})
.from(embedding)
.where(and(eq(embedding.documentId, documentId), inArray(embedding.id, chunkIds)))
if (chunksToDelete.length === 0) {
errors.push('No matching chunks found to delete')
return
}
const totalTokensToRemove = chunksToDelete.reduce((sum, chunk) => sum + chunk.tokenCount, 0)
const totalCharsToRemove = chunksToDelete.reduce((sum, chunk) => sum + chunk.contentLength, 0)
// Delete chunks
const deleteResult = await tx
.delete(embedding)
.where(and(eq(embedding.documentId, documentId), inArray(embedding.id, chunkIds)))
// Update document statistics
await tx
.update(document)
.set({
chunkCount: sql`${document.chunkCount} - ${chunksToDelete.length}`,
tokenCount: sql`${document.tokenCount} - ${totalTokensToRemove}`,
characterCount: sql`${document.characterCount} - ${totalCharsToRemove}`,
})
.where(eq(document.id, documentId))
successCount = chunksToDelete.length
})
} else {
// Handle enable/disable operations
const enabled = operation === 'enable'
await db
.update(embedding)
.set({
enabled,
updatedAt: new Date(),
})
.where(and(eq(embedding.documentId, documentId), inArray(embedding.id, chunkIds)))
// For enable/disable, we assume all chunks were processed successfully
successCount = chunkIds.length
}
logger.info(
`[${requestId}] Batch ${operation} completed: ${successCount} chunks processed, ${errors.length} errors`
)
return {
success: errors.length === 0,
processed: successCount,
errors,
}
}
/**
* Update a single chunk
*/
export async function updateChunk(
chunkId: string,
updateData: {
content?: string
enabled?: boolean
},
requestId: string,
workspaceId?: string | null
): Promise<ChunkData> {
// Content updates run in a transaction to keep document statistics
// consistent. The embedding API call happens BEFORE the transaction opens so
// a held pooled connection never waits on external I/O; the transaction then
// re-reads the chunk under a row lock and retries the whole flow in the rare
// case a concurrent edit invalidated the regeneration decision.
if (updateData.content !== undefined && typeof updateData.content === 'string') {
const content = updateData.content
const MAX_UPDATE_ATTEMPTS = 3
for (let attempt = 1; attempt <= MAX_UPDATE_ATTEMPTS; attempt++) {
const [preRead] = await db
.select({ documentId: embedding.documentId, content: embedding.content })
.from(embedding)
.where(eq(embedding.id, chunkId))
.limit(1)
if (!preRead) {
throw new Error(`Chunk ${chunkId} not found`)
}
// The embedding is a function of the new content alone, so generating it
// outside the transaction is always valid.
let regenerated: { embedding: number[]; tokenCount: number } | null = null
if (content !== preRead.content) {
const kbRow = await db
.select({ embeddingModel: knowledgeBase.embeddingModel })
.from(knowledgeBase)
.innerJoin(document, eq(document.knowledgeBaseId, knowledgeBase.id))
.where(eq(document.id, preRead.documentId))
.limit(1)
const chunkEmbeddingModel = kbRow[0]?.embeddingModel
if (!chunkEmbeddingModel) {
throw new Error('Knowledge base for chunk not found')
}
logger.info(`[${requestId}] Content changed, regenerating embedding for chunk ${chunkId}`)
const { embeddings } = await generateEmbeddings([content], chunkEmbeddingModel, workspaceId)
regenerated = {
embedding: embeddings[0],
tokenCount: estimateTokenCount(
content,
getEmbeddingModelInfo(chunkEmbeddingModel).tokenizerProvider
).count,
}
}
const result = await db.transaction(async (tx) => {
const currentChunk = await tx
.select({
documentId: embedding.documentId,
content: embedding.content,
contentLength: embedding.contentLength,
tokenCount: embedding.tokenCount,
})
.from(embedding)
.where(eq(embedding.id, chunkId))
.limit(1)
.for('update')
if (currentChunk.length === 0) {
throw new Error(`Chunk ${chunkId} not found`)
}
// A concurrent edit landed between the pre-read and this row lock and
// we skipped regeneration based on stale content; retry so the
// decision is re-made against the committed content.
if (!regenerated && currentChunk[0].content !== content) {
return null
}
const oldContentLength = currentChunk[0].contentLength
const oldTokenCount = currentChunk[0].tokenCount
const newContentLength = content.length
const chunkUpdate = {
updatedAt: new Date(),
content,
contentLength: newContentLength,
chunkHash: sha256Hex(content),
tokenCount: regenerated ? regenerated.tokenCount : oldTokenCount,
...(regenerated ? { embedding: regenerated.embedding } : {}),
...(updateData.enabled !== undefined ? { enabled: updateData.enabled } : {}),
}
await tx.update(embedding).set(chunkUpdate).where(eq(embedding.id, chunkId))
const charDiff = newContentLength - oldContentLength
const tokenDiff = chunkUpdate.tokenCount - oldTokenCount
await tx
.update(document)
.set({
characterCount: sql`${document.characterCount} + ${charDiff}`,
tokenCount: sql`${document.tokenCount} + ${tokenDiff}`,
})
.where(eq(document.id, currentChunk[0].documentId))
const updatedChunk = await tx
.select({
id: embedding.id,
chunkIndex: embedding.chunkIndex,
content: embedding.content,
contentLength: embedding.contentLength,
tokenCount: embedding.tokenCount,
enabled: embedding.enabled,
startOffset: embedding.startOffset,
endOffset: embedding.endOffset,
tag1: embedding.tag1,
tag2: embedding.tag2,
tag3: embedding.tag3,
tag4: embedding.tag4,
tag5: embedding.tag5,
tag6: embedding.tag6,
tag7: embedding.tag7,
createdAt: embedding.createdAt,
updatedAt: embedding.updatedAt,
})
.from(embedding)
.where(eq(embedding.id, chunkId))
.limit(1)
logger.info(
`[${requestId}] Updated chunk: ${chunkId}${regenerated ? ' (regenerated embedding)' : ''}`
)
return updatedChunk[0] as ChunkData
})
if (result) {
return result
}
}
throw new Error(
`Chunk ${chunkId} was concurrently modified ${MAX_UPDATE_ATTEMPTS} times; retry the update`
)
}
// If only enabled status is being updated, no need for transaction
await db
.update(embedding)
.set({
updatedAt: new Date(),
...(updateData.enabled !== undefined ? { enabled: updateData.enabled } : {}),
})
.where(eq(embedding.id, chunkId))
// Fetch the updated chunk
const updatedChunk = await db
.select({
id: embedding.id,
chunkIndex: embedding.chunkIndex,
content: embedding.content,
contentLength: embedding.contentLength,
tokenCount: embedding.tokenCount,
enabled: embedding.enabled,
startOffset: embedding.startOffset,
endOffset: embedding.endOffset,
tag1: embedding.tag1,
tag2: embedding.tag2,
tag3: embedding.tag3,
tag4: embedding.tag4,
tag5: embedding.tag5,
tag6: embedding.tag6,
tag7: embedding.tag7,
createdAt: embedding.createdAt,
updatedAt: embedding.updatedAt,
})
.from(embedding)
.where(eq(embedding.id, chunkId))
.limit(1)
if (updatedChunk.length === 0) {
throw new Error(`Chunk ${chunkId} not found`)
}
logger.info(`[${requestId}] Updated chunk: ${chunkId}`)
return updatedChunk[0] as ChunkData
}
/**
* Delete a single chunk with document statistics updates
*/
export async function deleteChunk(
chunkId: string,
documentId: string,
requestId: string
): Promise<void> {
await db.transaction(async (tx) => {
// Get chunk data before deletion for statistics update
const chunkToDelete = await tx
.select({
tokenCount: embedding.tokenCount,
contentLength: embedding.contentLength,
})
.from(embedding)
.where(eq(embedding.id, chunkId))
.limit(1)
if (chunkToDelete.length === 0) {
throw new Error('Chunk not found')
}
const chunk = chunkToDelete[0]
// Delete the chunk
await tx.delete(embedding).where(eq(embedding.id, chunkId))
// Update document statistics
await tx
.update(document)
.set({
chunkCount: sql`${document.chunkCount} - 1`,
tokenCount: sql`${document.tokenCount} - ${chunk.tokenCount}`,
characterCount: sql`${document.characterCount} - ${chunk.contentLength}`,
})
.where(eq(document.id, documentId))
})
logger.info(`[${requestId}] Deleted chunk: ${chunkId}`)
}