@@ -4464,6 +4464,122 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
44644464 } ;
44654465 }
44664466
4467+ /**
4468+ * ADR-0070 D4 — duplicate a writable base into a NEW package (the Airtable
4469+ * "duplicate base" gesture). Clones every ACTIVE item the source owns into
4470+ * `targetPackageId`, RE-NAMESPACING object names — the blueprint prefixes a
4471+ * base's object names with its namespace (e.g. `iojn_repair_ticket`), and
4472+ * `sys_metadata` keys on (type,name,org), so a same-name copy would collide
4473+ * with the source — and rewriting every intra-package reference (lookup
4474+ * `reference`, view `object`, expressions, etc.) to the new names. Per-item
4475+ * best-effort; one failure never aborts the whole clone.
4476+ */
4477+ async duplicatePackage ( request : {
4478+ sourcePackageId : string ;
4479+ targetPackageId : string ;
4480+ targetName ?: string ;
4481+ targetNamespace ?: string ;
4482+ organizationId ?: string ;
4483+ actor ?: string ;
4484+ } ) : Promise < {
4485+ success : boolean ;
4486+ copiedCount : number ;
4487+ failedCount : number ;
4488+ targetPackageId : string ;
4489+ copied : Array < { type : string ; name : string } > ;
4490+ failed : Array < { type : string ; name : string ; error : string } > ;
4491+ } > {
4492+ const registry : any = ( this . engine as any ) . registry ;
4493+ const srcPkg = registry ?. getPackage ?.( request . sourcePackageId ) ;
4494+ const sourceNs : string =
4495+ ( srcPkg ?. manifest ?. namespace as string ) ?? ( request . sourcePackageId . split ( '.' ) . pop ( ) ?? '' ) ;
4496+ const targetNs : string =
4497+ request . targetNamespace ?? ( request . targetPackageId . split ( '.' ) . pop ( ) ?? request . targetPackageId ) ;
4498+
4499+ const where : Record < string , unknown > = { package_id : request . sourcePackageId , state : 'active' } ;
4500+ if ( request . organizationId ) where . organization_id = request . organizationId ;
4501+ const rows = ( await this . engine . find ( 'sys_metadata' , { where } ) ) as any [ ] ;
4502+
4503+ // Map only OBJECT names that carry the source namespace prefix; views/etc.
4504+ // are renamed by the same prefix swap and reference-rewritten via the map.
4505+ const renameName = ( name : string ) : string =>
4506+ sourceNs && typeof name === 'string' && name . startsWith ( `${ sourceNs } _` )
4507+ ? `${ targetNs } _${ name . slice ( sourceNs . length + 1 ) } `
4508+ : name ;
4509+ const renameMap = new Map < string , string > ( ) ;
4510+ for ( const row of rows ) {
4511+ if ( row ?. type === 'object' ) {
4512+ const nn = renameName ( row . name ) ;
4513+ if ( nn !== row . name ) renameMap . set ( row . name , nn ) ;
4514+ }
4515+ }
4516+ // Longest-first, identifier-boundary rewrite so `iojn_task` never corrupts
4517+ // `iojn_task_log`, and `iojn_x` inside `record.iojn_x`/`iojn_x.view` matches.
4518+ const olds = [ ...renameMap . keys ( ) ] . sort ( ( a , b ) => b . length - a . length ) ;
4519+ const esc = ( s : string ) => s . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
4520+ const re = olds . length ? new RegExp ( `(${ olds . map ( esc ) . join ( '|' ) } )(?![A-Za-z0-9_])` , 'g' ) : null ;
4521+ const deepRewrite = ( v : any ) : any => {
4522+ if ( typeof v === 'string' ) return re ? v . replace ( re , ( m ) => renameMap . get ( m ) ?? m ) : v ;
4523+ if ( Array . isArray ( v ) ) return v . map ( deepRewrite ) ;
4524+ if ( v && typeof v === 'object' ) {
4525+ const o : any = { } ;
4526+ for ( const [ k , val ] of Object . entries ( v ) ) o [ k ] = deepRewrite ( val ) ;
4527+ return o ;
4528+ }
4529+ return v ;
4530+ } ;
4531+
4532+ if ( srcPkg ?. manifest && typeof registry ?. installPackage === 'function' ) {
4533+ try {
4534+ registry . installPackage ( {
4535+ ...srcPkg . manifest ,
4536+ id : request . targetPackageId ,
4537+ name : request . targetName ?? `${ srcPkg . manifest . name ?? request . sourcePackageId } (copy)` ,
4538+ namespace : targetNs ,
4539+ } ) ;
4540+ } catch {
4541+ /* best-effort — the per-item package binding still works without a manifest row */
4542+ }
4543+ }
4544+
4545+ const copied : Array < { type : string ; name : string } > = [ ] ;
4546+ const failed : Array < { type : string ; name : string ; error : string } > = [ ] ;
4547+ for ( const row of rows ) {
4548+ const newName = renameName ( row . name ) ;
4549+ let item : any ;
4550+ try {
4551+ item = typeof row . metadata === 'string' ? JSON . parse ( row . metadata ) : ( row . metadata ?? { } ) ;
4552+ } catch {
4553+ failed . push ( { type : row . type , name : row . name , error : 'unparseable metadata' } ) ;
4554+ continue ;
4555+ }
4556+ const rewritten = deepRewrite ( item ) ;
4557+ if ( rewritten && typeof rewritten === 'object' && ! Array . isArray ( rewritten ) ) rewritten . name = newName ;
4558+ try {
4559+ await this . saveMetaItem ( {
4560+ type : row . type ,
4561+ name : newName ,
4562+ item : rewritten ,
4563+ mode : 'publish' ,
4564+ packageId : request . targetPackageId ,
4565+ ...( request . organizationId ? { organizationId : request . organizationId } : { } ) ,
4566+ ...( request . actor ? { actor : request . actor } : { } ) ,
4567+ } ) ;
4568+ copied . push ( { type : row . type , name : newName } ) ;
4569+ } catch ( e : any ) {
4570+ failed . push ( { type : row . type , name : row . name , error : e ?. message ?? 'copy failed' } ) ;
4571+ }
4572+ }
4573+ return {
4574+ success : failed . length === 0 && copied . length > 0 ,
4575+ copiedCount : copied . length ,
4576+ failedCount : failed . length ,
4577+ targetPackageId : request . targetPackageId ,
4578+ copied,
4579+ failed,
4580+ } ;
4581+ }
4582+
44674583 // ─────────────────────────────────────────────────────────────────────
44684584 // ADR-0067 — package-scoped commit history & rollback
44694585 // ─────────────────────────────────────────────────────────────────────
0 commit comments