@@ -12,7 +12,7 @@ import {
1212import { createLogger } from '@sim/logger'
1313import { getErrorMessage } from '@sim/utils/errors'
1414import { generateId } from '@sim/utils/id'
15- import { and , asc , eq , gt , inArray , isNull , type SQL } from 'drizzle-orm'
15+ import { and , asc , eq , gt , inArray , isNull , type SQL , sql } from 'drizzle-orm'
1616import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
1717import type { DbOrTx } from '@/lib/db/types'
1818import type { TableSchema } from '@/lib/table/types'
@@ -47,6 +47,13 @@ const CONTENT_PAGE = 500
4747 */
4848const KB_DOCUMENT_COPY_CONCURRENCY = 5
4949
50+ /**
51+ * Max copied skill bodies rewritten concurrently within one keyset page. Bounds the per-skill
52+ * re-read + UPDATE fan-out so a page of copied skills doesn't issue every write at once; the keyset
53+ * loop still processes one page at a time, so peak concurrency stays at this cap.
54+ */
55+ const SKILL_REWRITE_CONCURRENCY = 5
56+
5057export interface CopyResourcesParams {
5158 tx : DbOrTx
5259 sourceWorkspaceId : string
@@ -95,13 +102,14 @@ export interface ForkContentKbEntry extends ForkContentPlanEntry {
95102
96103/**
97104 * A copied skill whose body's in-content references (`sim:` links + embedded file URLs) are
98- * rewritten post-commit. The child row is inserted in the fork tx with the SOURCE body; the
99- * content phase rewrites it best-effort so it points at the copied child resources (an unmapped
100- * target degrades to a graceful broken link, never an FK/subblock reference).
105+ * rewritten post-commit. Only the child id is carried: the child row is inserted in the fork tx
106+ * with the SOURCE body copied IN-DB (never materialized in app memory or embedded in the job
107+ * payload), and the content phase RE-READS the body keyset-paginated to rewrite it best-effort so
108+ * it points at the copied child resources (an unmapped target degrades to a graceful broken link,
109+ * never an FK/subblock reference).
101110 */
102111export interface ForkContentSkillEntry {
103112 childId : string
104- content : string
105113}
106114
107115/**
@@ -181,6 +189,13 @@ function setId(idMap: Map<ForkResourceType, Map<string, string>>, type: ForkReso
181189 return map
182190}
183191
192+ /**
193+ * Child `skill` insert whose `content` is a correlated subquery (copied server-side from the source
194+ * row) rather than a materialized string, so the fork tx never pulls skill bodies into app memory -
195+ * see the skeleton skill copy in {@link copyForkResourceContainers}.
196+ */
197+ type SkillSkeletonInsert = Omit < typeof skill . $inferInsert , 'content' > & { content : SQL }
198+
184199/**
185200 * Copy the selected resources' **container rows** into the child workspace inside
186201 * the fork transaction: custom tools, skills, and MCP server configs (each a
@@ -261,25 +276,37 @@ export async function copyForkResourceContainers(
261276 }
262277
263278 if ( selection . skills . length > 0 ) {
279+ // Select every skill column EXCEPT `content`: the body (capped at 50 KB each, up to 2000 skills)
280+ // is copied server-side via the correlated subquery below, so it is never materialized in app
281+ // memory while the fork tx holds its locks - nor carried in the background-job payload.
264282 const rows = await tx
265- . select ( )
283+ . select ( {
284+ id : skill . id ,
285+ workspaceId : skill . workspaceId ,
286+ userId : skill . userId ,
287+ name : skill . name ,
288+ description : skill . description ,
289+ createdAt : skill . createdAt ,
290+ updatedAt : skill . updatedAt ,
291+ } )
266292 . from ( skill )
267293 . where ( and ( inArray ( skill . id , selection . skills ) , eq ( skill . workspaceId , sourceWorkspaceId ) ) )
268- const inserts : ( typeof skill . $inferInsert ) [ ] = [ ]
294+ const inserts : SkillSkeletonInsert [ ] = [ ]
269295 for ( const row of rows ) {
270296 const childId = generateId ( )
271297 inserts . push ( {
272298 ...row ,
273299 id : childId ,
274300 workspaceId : childWorkspaceId ,
275301 userId,
302+ // Copy the body straight from the source row in-DB (never through app memory). Re-read and
303+ // rewritten post-commit (see copyForkResourceContent), out of the locked fork tx.
304+ content : sql `(SELECT ${ skill . content } FROM ${ skill } WHERE ${ skill . id } = ${ row . id } )` ,
276305 createdAt : now ,
277306 updatedAt : now ,
278307 } )
279308 record ( 'skill' , row . id , childId )
280- // Rewritten post-commit (see copyForkResourceContent): the row is inserted with the
281- // source body here so the locked fork tx never runs the per-skill content UPDATE.
282- contentPlan . skills . push ( { childId, content : row . content } )
309+ contentPlan . skills . push ( { childId } )
283310 names . skills . push ( row . name )
284311 }
285312 if ( inserts . length > 0 ) await tx . insert ( skill ) . values ( inserts )
@@ -578,8 +605,9 @@ function remapTableRowResourceUrls(value: unknown, maps: ForkContentRefMaps): un
578605 * resource is copied in its own short statements (never one long transaction).
579606 * Best-effort: a failure on one resource is logged and the others continue - the
580607 * fork itself (workflows + container rows) already succeeded. Copied skill bodies are
581- * rewritten here too (`contentRefMaps`), out of the locked fork tx; that rewrite is an
582- * in-content link fixup, so a failure degrades to a broken link and never fails a resource.
608+ * RE-READ (keyset-paginated) and rewritten here too (`contentRefMaps`), out of the locked
609+ * fork tx; that rewrite is an in-content link fixup, so a failure degrades to a broken link
610+ * and never fails a resource.
583611 */
584612export async function copyForkResourceContent ( params : {
585613 contentPlan : ForkContentPlan
@@ -760,26 +788,47 @@ export async function copyForkResourceContent(params: {
760788 }
761789 }
762790
763- // Rewrite copied skill bodies out of the locked fork tx (the rows were inserted with the
764- // source body). Their `sim:` links + embedded file URLs are remapped to the child resources;
765- // this is an in-content link fixup, so a per-skill failure degrades to a broken link and is
766- // never counted as a failed resource (unmapped targets are left as graceful broken links).
791+ // Rewrite copied skill bodies out of the locked fork tx (the rows were inserted with the source
792+ // body via an in-DB copy). RE-READ each child body keyset-paginated - it is never carried in the
793+ // job payload - remap its `sim:` links + embedded file URLs to the child resources, and write it
794+ // back. An in-content link fixup: a per-skill failure degrades to a broken link and is never
795+ // counted as a failed resource (unmapped targets are left as graceful broken links).
767796 if ( contentRefMaps && contentPlan . skills . length > 0 ) {
768- for ( const copiedSkill of contentPlan . skills ) {
769- try {
770- const rewritten = rewriteForkContentRefs ( copiedSkill . content , contentRefMaps )
771- if ( rewritten !== copiedSkill . content ) {
772- await db
773- . update ( skill )
774- . set ( { content : rewritten } )
775- . where ( eq ( skill . id , copiedSkill . childId ) )
797+ const childSkillIds = contentPlan . skills . map ( ( entry ) => entry . childId )
798+ let afterId : string | null = null
799+ for ( ; ; ) {
800+ const where : SQL < unknown > | undefined =
801+ afterId === null
802+ ? inArray ( skill . id , childSkillIds )
803+ : and ( inArray ( skill . id , childSkillIds ) , gt ( skill . id , afterId ) )
804+ const rows = await db
805+ . select ( { id : skill . id , content : skill . content } )
806+ . from ( skill )
807+ . where ( where )
808+ . orderBy ( asc ( skill . id ) )
809+ . limit ( CONTENT_PAGE )
810+ if ( rows . length === 0 ) break
811+ // Bounded fan-out: the mapper never rejects (it captures its own error), so mapWithConcurrency
812+ // settles the whole page. A rewrite is a best-effort link fixup, so a per-skill failure is
813+ // logged and the body keeps its source links rather than failing a resource.
814+ await mapWithConcurrency ( rows , SKILL_REWRITE_CONCURRENCY , async ( row ) : Promise < void > => {
815+ try {
816+ const rewritten = rewriteForkContentRefs ( row . content , contentRefMaps )
817+ if ( rewritten !== row . content ) {
818+ await db . update ( skill ) . set ( { content : rewritten } ) . where ( eq ( skill . id , row . id ) )
819+ }
820+ } catch ( error ) {
821+ logger . warn (
822+ `[${ requestId } ] Failed to rewrite copied skill content; keeping source links` ,
823+ {
824+ childSkillId : row . id ,
825+ error : getErrorMessage ( error ) ,
826+ }
827+ )
776828 }
777- } catch ( error ) {
778- logger . warn ( `[${ requestId } ] Failed to rewrite copied skill content; keeping source links` , {
779- childSkillId : copiedSkill . childId ,
780- error : getErrorMessage ( error ) ,
781- } )
782- }
829+ } )
830+ afterId = rows [ rows . length - 1 ] . id
831+ if ( rows . length < CONTENT_PAGE ) break
783832 }
784833 }
785834
0 commit comments