bundleLast updated on
Last updated on
Create Bundle artifacts to group multiple artifacts together
Create Bundle artifacts to group multiple documents, forms, and checklists into a single distributable package. Bundles support conditional includes and can reference content inline, by file path, or by registry coordinate.
Examples
Creating a bundle
const lease = p.form({
name: 'lease',
fields: {
pets: { type: 'list', item: { type: 'text' } },
vehicles: { type: 'list', item: { type: 'text' } },
},
})
const disclosureDoc = p.document({ name: 'disclosure' })
// Object pattern
const bundle = p.bundle({
name: 'lease-package',
version: '1.0.0',
title: 'Residential Lease Package',
defs: { hasPets: { type: 'boolean', value: 'forms.lease.fields.pets.length > 0' } },
contents: [
{ type: 'inline', key: 'lease', artifact: lease },
{ type: 'path', key: 'addendum', path: './pet-addendum.json', include: 'hasPets' },
{ type: 'inline', key: 'disclosure', artifact: disclosureDoc }
]
})
// Builder pattern
const bundle = p.bundle()
.name('lease-package')
.version('1.0.0')
.title('Residential Lease Package')
.def('hasPets', 'forms.lease.fields.pets.length > 0')
.inline('lease', lease)
.path('addendum', './pet-addendum.json', 'hasPets')
.inline('disclosure', disclosureDoc)
.build()Loading from external data
// Parse and validate unknown input (throws on error)
const bundle = p.bundle.from(jsonData)
// Safe parsing (returns result object)
const result = p.bundle.safeFrom(jsonData)
if (result.success) {
const bundle = result.data
}Assembling a bundle
assemble() renders a set of filled content entries into their target
layers and returns the output for each. A content entry is either a filled
artifact instance (DraftForm, DraftChecklist, or DraftDocument) or raw
bytes for content nothing in Paradoc produced, like a scanned certificate.
There's no resolver option: each artifact-instance entry already carries the resolver it was constructed with, so assembly never needs one of its own.
const assembled = await bundle.assemble({
contents: {
lease: filledLease,
disclosure: preparedDocument,
checklist: filledChecklist,
insuranceCert: { kind: 'bytes', content: certBytes, mimeType: 'application/pdf' },
},
})
// Access assembled outputs, keyed by content key
for (const [key, output] of Object.entries(assembled.outputs)) {
console.log(key, output.mimeType, output.filename)
// output.content holds the rendered bytes
}API
Object Pattern
p.bundle(input: BundleInput): BundleInstanceParameters
Returns
Returns a BundleInstance with the following properties and methods:
Builder Pattern
Chain methods to build a bundle incrementally:
All methods return BundleBuilder and are chainable.
Static Methods
Parse bundles from unknown data sources:
Bundle Content Types
Bundles can contain content from three sources:
Adding content
Use builder methods to add content:
// Registry reference - published artifacts
.registry('lease', '@acme/forms/residential-lease/2024-03@1.0.0')
// Path reference - local files
.path('addendum', './pet-addendum.yaml', 'hasPets')
// Inline - embedded artifacts
.inline('disclosure', p.document({...}))Content Item Types
InlineBundleItem
Embed an artifact directly in the bundle:
PathBundleItem
Reference an artifact by file path:
RegistryBundleItem
Reference a published artifact by address, with an optional edition and version. The builder takes a coordinate, @org/repo/name[/edition][@version], and stores the three parts separately:
| Coordinate | Resolves to |
|---|---|
@org/repo/w-9 | The current edition, latest version |
@org/repo/w-9@1.0.0 | That version of the current edition. Fails if absent; never falls back to another edition |
@org/repo/w-9/2024-03 | Latest version of that edition |
@org/repo/w-9/2024-03@1.0.0 | Full pin |
@org/repo/w-9/current@latest | The same as @org/repo/w-9 |
A two-segment value such as @acme/disclosure throws.
Conditional Includes
All item types support conditional inclusion using defs. Item keys start with a letter and contain only letters, digits, _, and -:
const bundle = p.bundle()
.name('lease-package')
// Define typed computed values
.def('hasPets', 'forms.lease.fields.pets.length > 0')
.def('hasVehicles', 'forms.lease.fields.vehicles.length > 0')
// Always include
.inline('lease', lease)
// Include only if hasPets evaluates to true
.path('pet-addendum', './pet-addendum.yaml', 'hasPets')
// Include only if hasVehicles evaluates to true
.path('parking-addendum', './parking.yaml', 'hasVehicles')
.build()Assembly Options
The assemble() method accepts BundleAssemblyOptions. contents is
required: there is no default set of contents, and no resolver option —
each content entry is an artifact instance that carries the resolver bound
when it was constructed.
A BundleAssemblyEntry is a filled artifact instance, bytes supplied
directly, or a bundle nested in this one:
assemble() returns an AssembledBundle: the original bundle plus
outputs, a Record<string, { content, mimeType, filename }> keyed by
content key. A nested bundle's parts are keyed as a folder under the
parent's content key: a bundle nested as nested with contents docA and
docB gives nested/docA and nested/docB, with the files
nested/docA.pdf and nested/docB.pdf. Content keys cannot contain /, so
these names never collide with a sibling key. A runtime bundle's render()
names its outputs the same way.
Sealing a packet
sealBundle(bundle, options) turns a bundle into one signable PDF packet. It seals each part that declares signature slots, renders the rest, flattens and merges every part's pages in bundle order, and returns one signature map in packet page space.
import { sealBundle } from '@paradoc/core'
const packet = await sealBundle(bundle, {
contents: {
lease: filledLease,
addendum: filledAddendum,
insuranceCert: { kind: 'bytes', content: certBytes, mimeType: 'application/pdf' },
},
signers: {
// The same person signs both parts.
'lease/tenant-signer': 'jane',
'addendum/tenant-signer': 'jane',
},
})
packet.pdf // the merged packet
packet.canonicalPdfHash // 'sha256:...' — what a signer signs
packet.signatureMap // every slot, on packet pagesThe result, a SealedBundle, carries bundle, pdf, canonicalPdfHash, packetHash (a digest over the merged PDF's hash and every part's), signers, signatureMap, parts, and warnings. Each part reports its kind ('sealed', 'rendered', or 'annex'), its page range in the packet, and its own digest.
Part signer ids are scoped as <part>/<signerId>, because two parts that both say signer-1 are not evidence that one person signs both. Map them with signers when they are. A failure throws BundleSealError, an Error with a problems: string[] list and an optional part: string content key. Its cause preserves a lower-level rendering or sealing failure when one exists.
Lifecycle Types
Bundles follow a three-phase lifecycle similar to forms (since bundles can contain forms that require signatures):
| Type | Phase | Description |
|---|---|---|
DraftBundle | Draft | Mutable contents, configure signers |
SignableBundle | Signable | Frozen data, capture signatures |
ExecutedBundle | Executed | Fully frozen, ready for archival |
// Prepare bundle with runtime contents (DraftBundle)
const draft = bundle.prepare({
lease: filledLeaseForm,
disclosure: preparedDocument,
checklist: filledChecklist,
})
// Prepare for signing (SignableBundle)
const signable = draft.prepareForSigning()
// Capture a signature on the underlying form, then update the bundle's copy
const leaseForm = signable.getContent('lease') as SignableForm<Form>
const signedLease = leaseForm.captureSignature('tenant', 'tenant-0', 'john', 'sig-1')
const updated = signable.updateContent('lease', signedLease)
// Finalize (ExecutedBundle)
const executed = updated.finalize()
// executed.executedAt === ISO timestampOnly included members change phase. An excluded member stays a draft, so it
never blocks signing and reinclusion keeps its answers. prepareForSigning()
and finalize() throw when a condition is unresolved or an included member
has no content.
The bundle clock
prepare() captures one clock, and every include condition that reads
today() or now() uses it. A member's own clock never decides inclusion, so
members filled at different instants agree. Pass { context: { asOf } } as the
second argument to fix it. The clock is kept through every phase and in
toJSON().
Save and reload
toJSON() stores the bundle clock and every member with its phase, and the
completion time of a checklist or document. runtimeBundleFromJSON(json)
rebuilds it:
import { runtimeBundleFromJSON, runtimeContentFromJSON } from '@paradoc/sdk'
const reloaded = runtimeBundleFromJSON(JSON.parse(stored))
// Bind a resolver for file-backed layers:
const withFiles = runtimeBundleFromJSON(JSON.parse(stored), (member) => runtimeContentFromJSON(member, { resolver }))Loading runs the same checks as prepare() and the phase transitions: an
undeclared key, a member of the wrong kind, or a member in the wrong phase
throws.
DraftBundle
Created by bundle.prepare(). Contains runtime instances of all artifacts.
SignableBundle
Created by draft.prepareForSigning(). Contains signable instances.
ExecutedBundle
Created by signable.finalize(). Fully frozen.