diff --git a/force-app/main/default/classes/batch/BatchableSchedulable.cls-meta.xml b/force-app/main/default/classes/batch/BatchableSchedulable.cls-meta.xml
index 40d6793..82775b9 100644
--- a/force-app/main/default/classes/batch/BatchableSchedulable.cls-meta.xml
+++ b/force-app/main/default/classes/batch/BatchableSchedulable.cls-meta.xml
@@ -1,5 +1,5 @@
- 54.0
+ 65.0
Active
diff --git a/force-app/main/default/classes/queue/QueueableJob.cls b/force-app/main/default/classes/queue/QueueableJob.cls
index 8c7a8e4..8420d3c 100644
--- a/force-app/main/default/classes/queue/QueueableJob.cls
+++ b/force-app/main/default/classes/queue/QueueableJob.cls
@@ -86,13 +86,34 @@ public abstract class QueueableJob implements Queueable, Comparable {
public QueueableJob cloneJob() {
if (deepClone) {
- return (QueueableJob) JSON.deserialize(
- JSON.serialize(this),
- Type.forName(this.className)
- );
- } else {
- return this.clone();
+ try {
+ return cloneForDeepCopy();
+ } catch (JSONException e) {
+ throw new IllegalArgumentException(
+ 'deepClone() failed for the job "' + className + '". ' +
+ 'When using a namespaced package, override cloneForDeepCopy() in your QueueableJob subclass: ' +
+ 'public override QueueableJob cloneForDeepCopy() { ' +
+ 'return (QueueableJob) JSON.deserialize(JSON.serialize(this), YourClassName.class); }'
+ );
+ }
}
+ return this.clone();
+ }
+
+ public virtual QueueableJob cloneForDeepCopy() {
+ String fullName = this.className;
+ String namespace = null;
+ String typeName = fullName;
+
+ if (fullName.contains('.')) {
+ namespace = fullName.substringBefore('.');
+ typeName = fullName.substringAfter('.');
+ }
+
+ return (QueueableJob) JSON.deserialize(
+ JSON.serialize(this),
+ Type.forName(namespace, typeName)
+ );
}
private String getFullClassName(Object job) {
diff --git a/force-app/main/default/classes/schedule/CronBuilder.cls-meta.xml b/force-app/main/default/classes/schedule/CronBuilder.cls-meta.xml
index 40d6793..82775b9 100644
--- a/force-app/main/default/classes/schedule/CronBuilder.cls-meta.xml
+++ b/force-app/main/default/classes/schedule/CronBuilder.cls-meta.xml
@@ -1,5 +1,5 @@
- 54.0
+ 65.0
Active
diff --git a/scripts/create-unlocked-package-version.sh b/scripts/create-unlocked-package-version.sh
new file mode 100755
index 0000000..0088e7d
--- /dev/null
+++ b/scripts/create-unlocked-package-version.sh
@@ -0,0 +1,57 @@
+#!/bin/bash
+set -e
+
+if ! git diff --quiet || ! git diff --cached --quiet; then
+ echo "Error: Working tree is not clean. Commit or stash changes before running this script."
+ exit 1
+fi
+
+PACKAGE_CREATED=false
+cleanup() {
+ if [ "$PACKAGE_CREATED" = true ]; then
+ echo "Staging sfdx-project.json with new version alias..."
+ git add sfdx-project.json
+ echo "Reverting Apex changes..."
+ git checkout -- .
+ else
+ echo "Package creation failed. Reverting all changes..."
+ git checkout -- .
+ fi
+}
+trap cleanup EXIT
+
+TARGET_FILES=(
+ "force-app/main/default/classes/Async.cls"
+ "force-app/main/default/classes/queue/QueueableJob.cls"
+ "force-app/main/default/classes/queue/QueueableBuilder.cls"
+ "force-app/main/default/classes/batch/BatchableBuilder.cls"
+ "force-app/main/default/classes/schedule/SchedulableBuilder.cls"
+ "force-app/main/default/classes/schedule/CronBuilder.cls"
+ "force-app/main/default/classes/mocks/AsyncMock.cls"
+)
+
+echo "Adding global modifiers to API surface classes..."
+for file in "${TARGET_FILES[@]}"; do
+ sed -i '' 's/public /global /g' "$file"
+done
+
+echo "Reverting internal-type references back to public..."
+sed -i '' 's/global QueueableManager\.EnqueueType/public QueueableManager.EnqueueType/g' \
+ "force-app/main/default/classes/Async.cls"
+sed -i '' 's/global QueueableChainState setEnqueueType/public QueueableChainState setEnqueueType/g' \
+ "force-app/main/default/classes/Async.cls"
+sed -i '' 's/global void enqueue(QueueableChain chain)/public void enqueue(QueueableChain chain)/g' \
+ "force-app/main/default/classes/queue/QueueableJob.cls"
+
+echo "Creating unlocked package version..."
+sf package version create \
+ --package "Async Lib" \
+ --definition-file ./config/project-scratch-def.json \
+ --installation-key-bypass \
+ --code-coverage \
+ --wait 50 \
+ --json \
+ "$@"
+
+PACKAGE_CREATED=true
+echo "Package version created successfully."
diff --git a/sfdx-project.json b/sfdx-project.json
index 8e14a8d..2932643 100644
--- a/sfdx-project.json
+++ b/sfdx-project.json
@@ -1,12 +1,20 @@
{
"packageDirectories": [
{
+ "versionName": "Async Lib 2.4.0",
+ "versionNumber": "2.4.0.NEXT",
"path": "force-app",
- "default": true
+ "default": true,
+ "package": "Async Lib",
+ "versionDescription": ""
}
],
- "name": "BeyondTheCloud",
- "namespace": "",
+ "name": "async-lib",
+ "namespace": "btcdev",
"sfdcLoginUrl": "https://login.salesforce.com",
- "sourceApiVersion": "65.0"
+ "sourceApiVersion": "65.0",
+ "packageAliases": {
+ "Async Lib": "0HoP600000002JZKAY",
+ "Async Lib@2.4.0-4": "04tP6000002cgEnIAI"
+ }
}
diff --git a/website/.vitepress/config.mts b/website/.vitepress/config.mts
index c2077da..668414c 100644
--- a/website/.vitepress/config.mts
+++ b/website/.vitepress/config.mts
@@ -61,6 +61,10 @@ export default defineConfig({
link: '/explanations/initial-scheduled-queuable-batch-job'
},
{ text: 'Job Cloning', link: '/explanations/job-cloning' },
+ {
+ text: 'Deep Clone in Packages',
+ link: '/explanations/deep-clone-in-packages'
+ },
{ text: 'Testing Async Jobs', link: '/explanations/testing-async-jobs' }
]
}
diff --git a/website/api/queueable.md b/website/api/queueable.md
index 4f65ad7..160682a 100644
--- a/website/api/queueable.md
+++ b/website/api/queueable.md
@@ -200,7 +200,11 @@ Async.queueable(new MyQueueableJob())
Clones provided QueueableJob by value for all the member variables. By default
only primitive member variables (String, Boolean, ...) are cloned by value.
-Deeper explanation is [here](/explanations/job-cloning.md).
+Deeper explanation is [here](/explanations/job-cloning).
+
+::: warning Package Usage
+When using Async Lib as a package (`btcdev` namespace), deep clone requires overriding `cloneForDeepCopy()` in your subclass. See [Deep Clone in Packages](/explanations/deep-clone-in-packages).
+:::
**Signature**
diff --git a/website/explanations/deep-clone-in-packages.md b/website/explanations/deep-clone-in-packages.md
new file mode 100644
index 0000000..b1cce19
--- /dev/null
+++ b/website/explanations/deep-clone-in-packages.md
@@ -0,0 +1,113 @@
+---
+outline: deep
+---
+
+# Deep Clone in Packages
+
+## TL;DR
+
+When using Async Lib as a **managed or unlocked package** (with the `btcdev` namespace), `.deepClone()` requires you to override `cloneForDeepCopy()` in your `QueueableJob` subclass. Without this override, the framework cannot serialize and deserialize your job across namespace boundaries.
+
+```apex
+public class MyJob extends btcdev.QueueableJob {
+
+ public override btcdev.QueueableJob cloneForDeepCopy() {
+ return (btcdev.QueueableJob) JSON.deserialize(JSON.serialize(this), MyJob.class);
+ }
+
+ public override void work() {
+ // your logic
+ }
+}
+```
+
+If you deploy Async Lib **without a namespace** (e.g., via the Deploy button or `sf project deploy`), no override is needed.
+
+## Why Is This Needed?
+
+Deep cloning uses `JSON.serialize()` and `JSON.deserialize()` to create a complete copy of a job instance. Two Salesforce platform behaviors make this fail across namespace boundaries:
+
+### 1. Serialization Context
+
+`JSON.serialize(this)` behaves differently depending on **where** it executes. When called from inside the `btcdev` package code, Salesforce attaches internal platform metadata to `Queueable` implementors that cannot be serialized. The same object serializes fine from subscriber code.
+
+**From package code (fails):**
+```apex
+// Inside btcdev.QueueableJob.cloneForDeepCopy()
+JSON.serialize(this); // System.JSONException: Type cannot be serialized
+```
+
+**From subscriber code (works):**
+```apex
+// Inside your class that extends btcdev.QueueableJob
+JSON.serialize(this); // works fine
+```
+
+### 2. Type Resolution Context
+
+`Type.forName()` resolves types relative to the **calling code's namespace**. When the package code tries to find your subscriber class, it looks in the `btcdev` namespace where your class doesn't exist.
+
+**From package code:**
+```apex
+// Inside btcdev.QueueableJob
+Type.forName('MyJob'); // returns null (looks for btcdev.MyJob)
+```
+
+**From subscriber code:**
+```apex
+// Inside your class
+Type.forName('MyJob'); // returns MyJob.class
+```
+
+## The Solution
+
+The `cloneForDeepCopy()` method is `virtual`, allowing you to override it in your subclass. Your override runs in your namespace context, where both serialization and type resolution work correctly.
+
+```apex
+public class AccountProcessorJob extends btcdev.QueueableJob {
+ public List accounts;
+ public Map config;
+
+ public override btcdev.QueueableJob cloneForDeepCopy() {
+ return (btcdev.QueueableJob) JSON.deserialize(
+ JSON.serialize(this), AccountProcessorJob.class
+ );
+ }
+
+ public override void work() {
+ // process accounts
+ }
+}
+```
+
+Then use `.deepClone()` as normal:
+
+```apex
+btcdev.Async.queueable(new AccountProcessorJob())
+ .deepClone()
+ .enqueue();
+```
+
+## When Do I Need This?
+
+| Scenario | Override needed? |
+|----------|:---:|
+| Deployed without namespace (Deploy button / `sf deploy`) | No |
+| Installed as package, using `.deepClone()` | **Yes** |
+| Installed as package, NOT using `.deepClone()` | No |
+
+## Error Message
+
+If you forget the override, the framework throws a descriptive error:
+
+```
+deepClone() failed for the job "MyJob".
+When using a namespaced package, override cloneForDeepCopy() in your QueueableJob subclass:
+public override QueueableJob cloneForDeepCopy() {
+ return (QueueableJob) JSON.deserialize(JSON.serialize(this), YourClassName.class);
+}
+```
+
+## Soft Clone vs Deep Clone Recap
+
+Not sure if you need `.deepClone()` at all? See [Job Cloning](/explanations/job-cloning) for when soft clone (default) is sufficient vs when deep clone is required.
diff --git a/website/explanations/job-cloning.md b/website/explanations/job-cloning.md
index 644eea7..07f93cd 100644
--- a/website/explanations/job-cloning.md
+++ b/website/explanations/job-cloning.md
@@ -192,6 +192,10 @@ Async.queueable(myJob)
- **CPU**: More intensive
- **Recommended for**: Jobs with complex object relationships
+## Package Usage
+
+When Async Lib is installed as a package (with the `btcdev` namespace), deep clone requires an additional override due to Salesforce cross-namespace serialization limitations. See [Deep Clone in Packages](/explanations/deep-clone-in-packages) for details.
+
## Summary
Job cloning is a critical feature that prevents reference corruption in
diff --git a/website/introduction/installation.md b/website/introduction/installation.md
index b9ada1c..aab5795 100644
--- a/website/introduction/installation.md
+++ b/website/introduction/installation.md
@@ -4,6 +4,22 @@ outline: deep
# Installation
+## Install as Unlocked Package
+
+Install the latest version of Async Lib as an unlocked package:
+
+
+
+
+
+```
+https://login.salesforce.com/packaging/installPackage.apexp?p0=04tP6000002cgEnIAI
+```
+
+::: tip
+When installed as a package, all classes use the `btcdev` namespace prefix (e.g., `btcdev.QueueableJob`, `btcdev.Async`). If you use [`.deepClone()`](/api/queueable#deepclone), see [Deep Clone in Packages](/explanations/deep-clone-in-packages) for a required override.
+:::
+
## Deploy via Button
Deploy to your Salesforce org using the deploy button: