diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d6dcca1..483d52f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,6 +4,13 @@ on:
branches: [master]
pull_request:
branches: [master]
+ workflow_dispatch:
+ inputs:
+ enable_destructive_solrcloud_tests:
+ description: "Run opt-in SPLITSHARD and MIGRATE checks on the disposable two-node cluster"
+ required: false
+ default: false
+ type: boolean
jobs:
test:
strategy:
@@ -45,7 +52,7 @@ jobs:
--volume "$RUNNER_TEMP/solr-backups:/var/solr/backups" \
--publish 8983:8983 \
--env SOLR_JAVA_MEM="-Xms512m -Xmx512m" \
- --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups" \
+ --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups -Dsolr.disable.shardsWhitelist=true -Dsolr.disable.allowUrls=true -Dsolr.security.allow.urls.enabled=false" \
"solr:${{ matrix.solr-version }}" \
solr-precreate integration
- name: Wait for Solr and integration core
@@ -67,6 +74,7 @@ jobs:
env:
SOLR_BASE_URL: http://localhost:8983/solr
SOLR_CORE: integration
+ SOLR_VERSION: "${{ matrix.solr-version }}"
SOLR_BACKUP_LOCATION: /var/solr/backups
- name: Print Solr logs
if: always()
@@ -124,7 +132,7 @@ jobs:
--volume "$RUNNER_TEMP/solr-backups:/var/solr/backups" \
--publish 8983:8983 \
--env SOLR_JAVA_MEM="-Xms512m -Xmx512m" \
- --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups" \
+ --env SOLR_OPTS="-Dsolr.allowPaths=/var/solr/backups -Dsolr.disable.shardsWhitelist=true -Dsolr.disable.allowUrls=true -Dsolr.security.allow.urls.enabled=false" \
--entrypoint /bin/bash \
"solr:${{ matrix.solr-version }}" \
-lc 'mkdir -p /var/solr/data && cp /tmp/security.json /var/solr/data/security.json && chmod 666 /var/solr/data/security.json && exec docker-entrypoint.sh solr-precreate integration'
@@ -149,6 +157,7 @@ jobs:
env:
SOLR_BASE_URL: http://localhost:8983/solr
SOLR_CORE: integration
+ SOLR_VERSION: "${{ matrix.solr-version }}"
SOLR_SECURITY: "true"
SOLR_USERNAME: integration-admin
SOLR_PASSWORD: integration-password
@@ -158,6 +167,8 @@ jobs:
run: docker logs solr-secure || true
cloud-integration:
+ env:
+ SOLR_DESTRUCTIVE_OPT_IN: "${{ github.event_name == 'workflow_dispatch' && inputs.enable_destructive_solrcloud_tests == true && 'true' || 'false' }}"
strategy:
fail-fast: false
matrix:
diff --git a/docs/administration.md b/docs/administration.md
index 4647f56..a1c1a9a 100644
--- a/docs/administration.md
+++ b/docs/administration.md
@@ -161,3 +161,18 @@ The alias and cluster-status integration checks exercise alias read-back, collec
The V1 parameter spellings are intentional: this client calls `/solr/admin/collections`, not the Solr 10 V2 `/api/collections` resources. The V2 API maps `split.key` to `splitKey` and `target.collection` to `target`; those V2 names must not be sent through this V1 client.
Reference guides: [Solr 8.1 Collections API](https://archive.apache.org/dist/lucene/solr/ref-guide/apache-solr-ref-guide-8.1.pdf), [Solr 9.2 Collection Management](https://solr.apache.org/guide/solr/9_2/deployment-guide/collection-management.html), [Solr 9.10 Shard Management](https://solr.apache.org/guide/solr/9_10/deployment-guide/shard-management.html), and [Solr 10.0 Collections API](https://solr.apache.org/guide/solr/10_0/configuration-guide/collections-api.html).
+
+## Opt-in SPLITSHARD and MIGRATE verification
+
+These operations are intentionally excluded from normal push/pull-request CI. The opt-in suite uses only collections created by the fixture with unique `integration_*` names. It refuses to run unless all of the following are true:
+
+- `SOLR_CLOUD=true` and `SOLR_MULTINODE=true`.
+- `SOLR_DESTRUCTIVE_OPT_IN=true` is explicitly set.
+- `SOLR_BASE_URL` resolves to localhost / loopback; remote Solr endpoints are skipped.
+- The disposable cluster reports at least two live nodes; the split test additionally confirms its new collection starts with one shard and the migration test confirms the target collection is empty.
+
+Run it from the GitHub Actions **CI** workflow using **Run workflow** and enable `enable_destructive_solrcloud_tests`. This provisions the workflow's ephemeral two-node SolrCloud cluster and runs the opt-in tests for the pinned Solr version matrix. Leaving the input disabled keeps both tests skipped, including on ordinary pull requests.
+
+The tests use asynchronous request IDs and poll `REQUESTSTATUS` for up to 180 seconds, then remove completed status records. They verify active child-shard state and post-split document visibility, and verify that every routed document is searchable in the migration target. They do not claim to test concurrent writes, rollback of a partially completed operation, or recovery after a cluster/node failure.
+
+If a request times out or fails, do not rerun against a reused cluster. Preserve the CI job logs, inspect `CLUSTERSTATUS` and `REQUESTSTATUS`, and discard the ephemeral cluster. Fixture cleanup deletes only the collections it created; if Solr still has an operation in flight and refuses deletion, destroy the ephemeral cluster rather than attempting cleanup against a shared server. The suite is an opt-in verification harness, not permission to point these destructive actions at a user-owned cluster. A workflow that is skipped or has not been manually dispatched is not runtime evidence; record successful Solr versions individually before updating compatibility status. Keep the workflow run URL and tested Solr version alongside the compatibility record.
diff --git a/docs/api-coverage.md b/docs/api-coverage.md
index 31ff5fb..5bc6354 100644
--- a/docs/api-coverage.md
+++ b/docs/api-coverage.md
@@ -24,9 +24,10 @@ A generic request method does not mean that every endpoint has a typed wrapper,
| Core Admin | `coreAdmin(action, params)` with `CoreAdminParams` action-specific models for STATUS, CREATE, RELOAD, UNLOAD, RENAME, SWAP, MERGEINDEXES, and SPLIT; `CoreAdminStatusResponse` for STATUS and `CoreAdminCommandResponse` as the default non-STATUS response envelope | STATUS models per-core paths/config metadata, optional index statistics, initialization failures, and unknown fields. Other actions default to a named extensible response-header envelope; callers may supply a more specific generic. Unit/type fixtures cover minimal and detailed STATUS payloads plus CREATE/RELOAD response inference. Isolated CREATE, STATUS, RELOAD, and fixture-cleanup UNLOAD are integration-tested on Solr 8.11.4/9.11.0/10.1.0; remaining actions retain serialization/type coverage only and are not claimed as runtime-certified. See [Core Admin evidence](administration.md#core-admin-status-response) and Issue #229.
| Collections | `collections(action, params)` with action-specific `CollectionAdminParams` and `CollectionAdminResponse` conditional response contracts | `LIST` uses `SolrCollectionListResponse`; collection lifecycle/policy, replica, and role actions use named extensible response envelopes; alias, cluster, shard, backup/restore, and async actions retain their named response models. Runtime evidence remains partial, not blanket certification of every action: exact Solr 8.11.4/9.11.0/10.1.0 evidence is listed in the [per-action Collections matrix](collections-api-coverage.md). `MODIFYCOLLECTION`, `SPLITSHARD`, `MIGRATE`, `ADDROLE`, and `REMOVEROLE` remain runtime-unverified. See [backup and restore docs](backup-restore.md), [Collections action matrix](collections-api-coverage.md), and Issues #80–#85, #121, and #230. |
| Security | `security(area, command)` | Security-enabled standalone matrix covers anonymous 401 responses for both authentication and authorization, authenticated reads, authentication property mutation/read-back, and malformed JSON rejection on Solr 8.11.4/9.11.0/10.1.0. Unit tests verify Basic/Bearer credentials and Authorization headers are not exposed through client HTTP errors. See Issues #86–#88. |
-| System | `systemInfo()`, `systemProperties()`, `systemThreads()`, `systemLogging()` | System info, non-empty JVM/process properties, a non-empty thread dump, and logger metadata are exercised on pinned Solr 8.11.4/9.11.0/10.1.0. Optional fields remain version-dependent; response models preserve unknown fields. See Issues #89 and #91. |\n| Metrics | `metrics()` with typed group/registry/prefix/regex filters; exported `SolrMetricsJsonResponse`, `SolrMetricsRegistry`, and open `SolrMetricValue`; JSON/Prometheus response union | JVM response-format coverage remains distinct (observed JSON on Solr 8/9 and Prometheus exposition on 10.1). A `node` group integration check runs on all pinned versions, asserting a non-empty JSON registry on Solr 8/9 and Prometheus samples on 10.1; registry, prefix, and regex filters are asserted against JSON responses on Solr 8/9 (Solr 10.1 supports Prometheus/OpenMetrics, not the JSON writer). Consumer fixtures verify the extensible registry/value envelope; plugin-specific metrics remain open and unenumerated. See Issues #90, #91, and #236. |
+| System | `systemInfo()`, `systemProperties()`, `systemThreads()`, `systemLogging()` | System info, non-empty JVM/process properties, a non-empty thread dump, and logger metadata are exercised on pinned Solr 8.11.4/9.11.0/10.1.0. Optional fields remain version-dependent; response models preserve unknown fields. See Issues #89 and #91. |
+| Metrics | `metrics()` with typed group/registry/prefix/regex filters; exported `SolrMetricsJsonResponse`, `SolrMetricsRegistry`, and open `SolrMetricValue`; JSON/Prometheus response union | JVM response-format coverage remains distinct (observed JSON on Solr 8/9 and Prometheus exposition on 10.1). A `node` group integration check runs on all pinned versions, asserting a non-empty JSON registry on Solr 8/9 and Prometheus samples on 10.1; registry, prefix, and regex filters are asserted against JSON responses on Solr 8/9 (Solr 10.1 supports Prometheus/OpenMetrics, not the JSON writer). Consumer fixtures verify the extensible registry/value envelope; plugin-specific metrics remain open and unenumerated. See Issues #90, #91, and #236. |
| Backup and restore | `replication("backup" | "restore" | "restorestatus" | "deletebackup")`, `collections("BACKUP" | "RESTORE" | "LISTBACKUP" | "DELETEBACKUP" | "REQUESTSTATUS" | "DELETESTATUS")` | Typed core and SolrCloud backup/restore parameters and response models, including async status polling. Full core restore and SolrCloud collection backup/restore cycles are integration-tested on Solr 8.11.4/9.11.0/10.1.0 using local filesystem repositories and shared SolrCloud storage. See [backup and restore docs](backup-restore.md). |
-| Replication | `replication(command, params)`, `replicationFile(params)`; exported command-specific parameter and response models | V1 ReplicationHandler commands for index version, details, file list, leader/follower control (Solr 10 fetchindex/abortfetch; Solr 8/9 snappull/abort), core backup/restore/status/delete, and raw file-content streaming. Integration coverage includes core backup/restore cycles on Solr 8.11.4/9.11.0/10.1.0. An end-to-end leader/follower fetch cycle is not yet certified. See Issue #95 and [replication docs](replication.md). |
+| Replication | `replication(command, params)`, `replicationFile(params)`; exported command-specific parameter and response models | V1 ReplicationHandler commands for index version, details, file list, leader/follower control (fetchindex/abortfetch across Solr 8/9/10), core backup/restore/status/delete, and raw file-content streaming. Integration coverage includes core backup/restore cycles on Solr 8.11.4/9.11.0/10.1.0. A disposable two-core leader/follower fetch/read-back check is implemented but remains pending runtime verification until its pinned-version CI jobs pass; it uses `fetchindex`/`abortfetch` across Solr 8/9/10. Abort and polling lifecycle behavior remains unit-only. See Issue #95 and [replication docs](replication.md). |
| Streaming Expressions | `stream()` | Sends `expr` with `wt=json` and parses the complete JSON response through the shared transport. It is not an incremental tuple-stream parser: no chunk-boundary protocol or memory-bound/backpressure guarantee is claimed. SolrCloud search is integration-tested on pinned Solr 8.11.4/9.11.0/10.1.0; unit tests cover JSON parsing, caller cancellation during body consumption, and cleanup after body-stream errors. See [Streaming Expressions](streaming.md) and Issue #93. |
| Export | `export()`, `exportStream()`; exported `SolrExportParams`, `SolrExportSort`, and `SolrExportFieldType` | Export requests require typed `fl` and `sort` parameters, support repeated filter queries, structured or raw sort expressions, batch sizing, JSON/JavaBin writers, and an explicit custom-parameter escape hatch. The raw Response preserves backpressure-aware streaming; `exportStream()` exposes its byte body directly. Sort fields must be single-valued docValues fields (int, long, float, double, or string); field lists additionally support date and boolean and may be multi-valued. Solr 10.1 `includeStoredFields` support is exposed but requires version-specific verification. Existing raw Export response tests pass on pinned Solr 8.11.4/9.11.0/10.1.0; typed parameter serialization and streamed chunk consumption have unit coverage. See [Export docs](export.md) and Issues #92–#94. |
| Advanced / Raw | `requestRaw(path, options)` with typed method/query/header/body/control options and a raw Fetch `Response` | Relative paths and same-origin absolute URLs use the shared transport. Cross-origin URLs, embedded credentials, and URL fragments are rejected before Fetch, preventing configured credentials from being sent to unrelated hosts. Generic request()/requestRaw() transport behavior has unit coverage only; arbitrary custom endpoint behavior remains unverified until endpoint-specific integration tests exist. See [raw requests](raw-requests.md) and Issue #98. |
diff --git a/docs/api-inventory.json b/docs/api-inventory.json
index 6ef9b53..a255a0b 100644
--- a/docs/api-inventory.json
+++ b/docs/api-inventory.json
@@ -84,12 +84,13 @@
"testFiles": [
"test/admin.test.ts",
"test/integration/solr-collections.test.ts",
+ "test/integration/solrcloud-split-migrate-optin.test.ts",
"test/collections-types.ts",
"test/backup-restore.test.ts",
"test/backup-restore-api-types.ts",
"test/integration/solrcloud-backup-restore.test.ts"
],
- "notes": "SolrCloud CREATE/LIST/DELETE, alias lifecycle, implicit-router shard lifecycle, named ADDREPLICA/DELETEREPLICA lifecycle, replica property add/delete and BALANCESHARDUNIQUE responses, plus multi-node MOVEREPLICA and REBALANCELEADERS leader reassignment are tested on pinned Solr 8.11.4, 9.11.0, and 10.1.0 images (CI #177, #192, #209, and PR #142 CI #224). MOVEREPLICA verifies the named replica becomes active on a different live node. REBALANCELEADERS assigns preferredLeader to an active non-leader and verifies it becomes shard leader. Snapshot, backup, and cluster-state response models remain unverified. Additional operation evidence: Typed SolrCloud collection backup/restore actions and asynchronous REQUESTSTATUS/DELETESTATUS tracking. Full backup, list, restore, query read-back, and delete cycle is integration-tested on Solr 8.11.4, 9.11.0, and 10.1.0 with a shared local filesystem volume mounted on both SolrCloud nodes. S3 and other external repositories are not certified. LISTALIASES defaults to SolrCollectionAliasesResponse; CREATEALIAS/DELETEALIAS default to SolrCollectionAliasResponse; CLUSTERSTATUS defaults to SolrClusterStatusResponse with extensible collection/shard/replica models. Filtered cluster status and unobserved version-specific fields remain optional/open. CREATESHARD/DELETESHARD now default to SolrCollectionShardOperationResponse and remain integration-tested on all pinned versions. SPLITSHARD has a typed V1 split.key contract (shard or routing-key mode), but remains runtime-unverified because it is long-running, hash-router-specific, and has disk/routing implications. MIGRATE is added with V1 collection/split.key/target.collection/forward.timeout/async parameters and the same response envelope; it remains runtime-unverified because it is destructive and requires a controlled compositeId source/target pair. Do not treat these typed requests as integration certification. RELOAD now has exact action-specific serialization and isolated collection integration coverage on pinned Solr 8.11.4/9.11.0/10.1.0. MODIFYCOLLECTION, ADDROLE, and REMOVEROLE now have dedicated serialization and consumer type fixtures; MODIFYCOLLECTION remains runtime-unverified because it mutates collection policy, while role actions remain runtime-unverified because they mutate cluster-wide node role assignments. Named response contracts now cover every action literal: LIST uses SolrCollectionListResponse; CREATE/DELETE/RELOAD/MODIFYCOLLECTION use SolrCollectionAdminResponse; replica lifecycle, movement, balancing, and property actions use SolrCollectionReplicaResponse; ADDROLE/REMOVEROLE use SolrCollectionRoleResponse. CollectionAdminResponse selects the default contract from the action literal while explicit caller generics remain supported. These are extensible response envelopes, not a claim that all action-specific payload fields or all actions are runtime-certified."
+ "notes": "SolrCloud CREATE/LIST/DELETE, alias lifecycle, implicit-router shard lifecycle, named ADDREPLICA/DELETEREPLICA lifecycle, replica property add/delete and BALANCESHARDUNIQUE responses, plus multi-node MOVEREPLICA and REBALANCELEADERS leader reassignment are tested on pinned Solr 8.11.4, 9.11.0, and 10.1.0 images (CI #177, #192, #209, and PR #142 CI #224). MOVEREPLICA verifies the named replica becomes active on a different live node. REBALANCELEADERS assigns preferredLeader to an active non-leader and verifies it becomes shard leader. Snapshot, backup, and cluster-state response models remain unverified. Additional operation evidence: Typed SolrCloud collection backup/restore actions and asynchronous REQUESTSTATUS/DELETESTATUS tracking. Full backup, list, restore, query read-back, and delete cycle is integration-tested on Solr 8.11.4, 9.11.0, and 10.1.0 with a shared local filesystem volume mounted on both SolrCloud nodes. S3 and other external repositories are not certified. LISTALIASES defaults to SolrCollectionAliasesResponse; CREATEALIAS/DELETEALIAS default to SolrCollectionAliasResponse; CLUSTERSTATUS defaults to SolrClusterStatusResponse with extensible collection/shard/replica models. Filtered cluster status and unobserved version-specific fields remain optional/open. CREATESHARD/DELETESHARD now default to SolrCollectionShardOperationResponse and remain integration-tested on all pinned versions. SPLITSHARD has a typed V1 split.key contract (shard or routing-key mode). MIGRATE uses V1 collection/split.key/target.collection/forward.timeout/async parameters and the same response envelope. Both now have opt-in asynchronous integration checks that require SOLR_DESTRUCTIVE_OPT_IN=true, a loopback endpoint, SOLR_CLOUD=true, SOLR_MULTINODE=true, and disposable collections; the manual workflow provisions its own two-node cluster. The tests verify child-shard state and document read-back after split, and target document read-back after migration. The opt-in workflow has not yet been executed, so both operations remain runtime-unverified. Abort, partial-failure recovery, and concurrent-write behavior are not certified. RELOAD now has exact action-specific serialization and isolated collection integration coverage on pinned Solr 8.11.4/9.11.0/10.1.0. MODIFYCOLLECTION, ADDROLE, and REMOVEROLE now have dedicated serialization and consumer type fixtures; MODIFYCOLLECTION remains runtime-unverified because it mutates collection policy, while role actions remain runtime-unverified because they mutate cluster-wide node role assignments. Named response contracts now cover every action literal: LIST uses SolrCollectionListResponse; CREATE/DELETE/RELOAD/MODIFYCOLLECTION use SolrCollectionAdminResponse; replica lifecycle, movement, balancing, and property actions use SolrCollectionReplicaResponse; ADDROLE/REMOVEROLE use SolrCollectionRoleResponse. CollectionAdminResponse selects the default contract from the action literal while explicit caller generics remain supported. These are extensible response envelopes, not a claim that all action-specific payload fields or all actions are runtime-certified."
},
{
"id": "update.commit",
@@ -396,11 +397,12 @@
"test/replication.test.ts",
"test/replication-api-types.ts",
"test/integration/solr-replication.test.ts",
+ "test/integration/solr-replication-lifecycle.test.ts",
"test/backup-restore.test.ts",
"test/backup-restore-api-types.ts",
"test/integration/solr-core-backup-restore.test.ts"
],
- "notes": "Typed V1 ReplicationHandler commands for enablereplication, disablereplication, indexversion, fetchindex, abortfetch, snappull, abort, enablepoll, disablepoll, details, filelist, backup, restore, restorestatus, and deletebackup; replicationFile() returns raw filecontent streams. Live integration coverage on Solr 8.11.4, 9.11.0, and 10.1.0 covers indexversion, details, filelist, and filecontent. enablereplication/disablereplication are integration-tested on a configured leader; fetchindex/abortfetch (Solr 10), snappull/abort (Solr 8/9), enablepoll, and disablepoll have unit serialization coverage, but a multi-core leader/follower fetch cycle is not yet certified. Core-level backup, restore, restorestatus, and deletebackup are integration-tested on all three pinned versions. Filelist uses the generation returned by newer Solr indexversion responses and falls back to the legacy indexversion parameter when that field is absent. Additional operation evidence: Core-level ReplicationHandler backup, restore, restorestatus, and deletebackup are typed. Backup defaults to the core dataDir; restore status is polled with restorestatus. Full backup/restore/read-back/delete cycle is integration-tested on Solr 8.11.4, 9.11.0, and 10.1.0. Custom filesystem locations must be permitted by server allowPaths."
+ "notes": "Typed V1 ReplicationHandler commands for enablereplication, disablereplication, indexversion, fetchindex, abortfetch, snappull, abort, enablepoll, disablepoll, details, filelist, backup, restore, restorestatus, and deletebackup; replicationFile() returns raw filecontent streams. Live integration coverage on Solr 8.11.4, 9.11.0, and 10.1.0 covers indexversion, details, filelist, filecontent, leader enable/disable, and a disposable two-core leader/follower fetch with follower query read-back is implemented but remains pending runtime verification until the pinned-version CI jobs pass. The integration job supplies SOLR_VERSION so the test uses fetchindex on Solr 10 and snappull on Solr 8/9. abortfetch/abort, enablepoll, and disablepoll retain unit serialization coverage only; an in-progress abort lifecycle is not certified. Core-level backup, restore, restorestatus, and deletebackup are integration-tested on all three pinned versions. Filelist uses the generation returned by newer Solr indexversion responses and falls back to the legacy indexversion parameter when that field is absent. Additional operation evidence: Core-level ReplicationHandler backup, restore, restorestatus, and deletebackup are typed. Backup defaults to the core dataDir; restore status is polled with restorestatus. Full backup/restore/read-back/delete cycle is integration-tested on Solr 8.11.4, 9.11.0, and 10.1.0. Custom filesystem locations must be permitted by server allowPaths."
},
{
"id": "replication.file",
diff --git a/docs/collections-api-coverage.md b/docs/collections-api-coverage.md
index 60e3b5c..fa27607 100644
--- a/docs/collections-api-coverage.md
+++ b/docs/collections-api-coverage.md
@@ -23,8 +23,8 @@ This matrix is deliberately action-specific. The public client infers a named re
| `ADDREPLICAPROP` | `AddReplicaPropertyParams` | `SolrCollectionReplicaResponse` | Serialization and compile fixtures | Adds replica property; all three pinned versions |
| `DELETEREPLICAPROP` | `DeleteReplicaPropertyParams` | `SolrCollectionReplicaResponse` | Serialization and compile fixtures | Removes replica property; all three pinned versions |
| `CREATESHARD` | `CreateShardParams` | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Creates a shard on a disposable implicit collection and reads it through CLUSTERSTATUS; all three pinned versions |
-| `DELETESHARD` | `DeleteShardParams` | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Deletes only the shard created by the test and verifies absence; all three pinned versions |\n| `MIGRATE` | `MigrateCollectionParams` (`collection`, V1 `split.key`, V1 `target.collection`, optional `forward.timeout`, `async`) | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Not integration-tested: data migration is destructive/long-running and requires a controlled compositeId source/target pair |
-| `SPLITSHARD` | `SplitShardParams` (`shard` or V1 `split.key`; `ranges`, `numSubShards`, `splitMethod`, `async`) | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Not integration-tested: long-running, hash-router-only operation with disk and routing implications; unsuitable for shared CI collections |
+| `DELETESHARD` | `DeleteShardParams` | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Deletes only the shard created by the test and verifies absence; all three pinned versions |\n| `MIGRATE` | `MigrateCollectionParams` (`collection`, V1 `split.key`, V1 `target.collection`, optional `forward.timeout`, `async`) | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Opt-in only: disposable compositeId source/empty target; async completion plus target document read-back. Not yet executed against pinned versions; remains runtime-unverified |
+| `SPLITSHARD` | `SplitShardParams` (`shard` or V1 `split.key`; `ranges`, `numSubShards`, `splitMethod`, `async`) | `SolrCollectionShardOperationResponse` | Exact serialization and consumer type fixture | Opt-in only: disposable one-shard compositeId collection; async completion, active child-shard state, and document read-back. Not yet executed against pinned versions; remains runtime-unverified |
| `ADDROLE` | `CollectionRoleParams` | `SolrCollectionRoleResponse` | Exact serialization and consumer type fixture | Not integration-tested: changes cluster-wide node role assignment; requires a dedicated isolated cluster policy fixture |
| `REMOVEROLE` | `CollectionRoleParams` | `SolrCollectionRoleResponse` | Exact serialization and consumer type fixture | Not integration-tested: changes cluster-wide node role assignment; requires a dedicated isolated cluster policy fixture |
| `BACKUP` | `CollectionBackupParams` | `SolrCollectionBackupResponse` | Named response type and compile-time API declarations | Async collection backup with shared storage; all three pinned versions |
@@ -44,13 +44,14 @@ The public `CollectionAdminResponse` conditional type maps each action litera
- Compile-time consumer examples: `test/admin-api-types.ts`, `test/collections-types.ts`.
- Unit serialization: `test/admin.test.ts`.
- Collection, alias, shard, replica, and cluster-status runtime checks: `test/integration/solr-collections.test.ts`.
+- Destructive, opt-in SPLITSHARD and MIGRATE verification: `test/integration/solrcloud-split-migrate-optin.test.ts` (manual workflow dispatch only).
- Backup/restore and async status runtime checks: `test/integration/solrcloud-backup-restore.test.ts`.
- Pinned server matrix: `test/integration/fixtures.ts` and the SolrCloud jobs in the CI workflow.
## Gaps and scope boundaries
1. **Cluster status response typing:** `SolrClusterStatusResponse` now models the stable live-node, alias, collection, shard, and replica envelope. Unknown and version-specific fields remain open; it is not a closed schema for every SolrCloud response variation.
-2. **Shard split and migration:** `SPLITSHARD` and `MIGRATE` have typed V1 parameter and response contracts plus serialization/type fixtures, but no shared-CI runtime test. Both remain explicitly unverified because they can be long-running and affect routing/data placement. `MIGRATE` additionally requires a compositeId router and a quiescent target collection.
+2. **Shard split and migration:** `SPLITSHARD` and `MIGRATE` have typed V1 parameter and response contracts plus serialization/type fixtures, but no normal-CI runtime test. A manually dispatched, loopback-only two-node workflow now contains opt-in tests that poll asynchronous completion and verify cluster/data state; until that workflow passes for a pinned version, both actions remain explicitly runtime-unverified because they can be long-running and affect routing/data placement. `MIGRATE` additionally requires a compositeId router and a quiescent target collection.
3. **Collection role operations:** `ADDROLE` and `REMOVEROLE` share `CollectionRoleParams` and now have exact serialization and consumer type fixtures. They remain runtime-unverified because they mutate cluster-wide node role assignments.
4. **Collection `RELOAD` and `MODIFYCOLLECTION`:** `RELOAD` now has an isolated integration assertion on the pinned matrix. `MODIFYCOLLECTION` has serialization/type coverage but remains runtime-unverified because it mutates collection policy and behavior varies with SolrCloud configuration.
5. **Response contract boundaries:** `SolrCollectionAdminResponse`, `SolrCollectionReplicaResponse`, and `SolrCollectionRoleResponse` are intentionally extensible envelopes; they do not claim undocumented action-specific payload fields. `SolrCollectionListResponse` types the stable `collections` list. These contracts describe the client-side JSON shape, not blanket runtime certification for every action or Solr version.
diff --git a/docs/compatibility.md b/docs/compatibility.md
index f255131..b604ca9 100644
--- a/docs/compatibility.md
+++ b/docs/compatibility.md
@@ -38,7 +38,7 @@ The 8.11, 9.11, and 10.1 guides describe the API families below. The Replica Man
| `security()` — authentication and authorization handlers | Documented | Documented | Documented | Tested: authenticated BasicAuth/RuleBasedAuthorization reads, anonymous 401 on both handlers, authentication `set-property` read-back, and malformed JSON rejection on pinned 8.11.4 / 9.11.0 / 10.1.0 images. Unit tests verify Basic/Bearer credentials are not exposed by client HTTP errors |
| `stream()` — `/stream` | Documented | Documented | Documented | Tested: Streaming Expressions search against a disposable SolrCloud collection on pinned 8.11.4 / 9.11.0 / 10.1.0 images (CI #145) |
| `export()` — `/export` | Documented | Documented | Documented | Tested: raw response with docValues sort field on pinned 8.11.4 / 9.11.0 / 10.1.0 images |
-| `replication()` / `replicationFile()` — `/solr/{core}/replication` | Documented | Documented | Documented | Tested: indexversion, details, filelist (generation on newer versions / legacy indexversion on older versions), streamed filecontent, enablereplication/disablereplication, core backup, restorestatus, restore, and deletebackup on pinned 8.11.4 / 9.11.0 / 10.1.0 images. fetchindex/abortfetch (Solr 10), snappull/abort (Solr 8/9), enablepoll, and disablepoll have unit serialization coverage; an end-to-end multi-core leader/follower fetch cycle is not yet certified |
+| `replication()` / `replicationFile()` — `/solr/{core}/replication` | Documented | Documented | Documented | Tested: indexversion, details, filelist (generation on newer versions / legacy indexversion on older versions), streamed filecontent, enablereplication/disablereplication, core backup, restorestatus, restore, and deletebackup on pinned 8.11.4 / 9.11.0 / 10.1.0 images. A disposable two-core leader/follower fetch with follower query read-back is covered by the regular standalone/security integration matrix; runtime verification remains pending until those CI jobs pass on each pinned version. It uses snappull on Solr 8/9 and fetchindex on Solr 10. Abort and polling lifecycle behavior remains unit-only |
| Core and collection backup/restore | `replication("backup" | "restore" | "restorestatus" | "deletebackup")`, `collections("BACKUP" | "RESTORE" | "LISTBACKUP" | "DELETEBACKUP" | "REQUESTSTATUS" | "DELETESTATUS")` | Documented | Documented | Documented | Tested: full core backup/restore/read-back and SolrCloud collection backup/list/restore/read-back/delete with asynchronous status polling on pinned 8.11.4 / 9.11.0 / 10.1.0 images using local filesystem repositories and shared SolrCloud storage |
| `request()` / `requestRaw()` — generic caller-defined transport | Depends on the selected endpoint | Depends on the selected endpoint | Depends on the selected endpoint | Generic transport behavior has unit tests only; no endpoint-independent live Solr integration claim is made. Each custom endpoint remains unverified until tested individually |
diff --git a/docs/config.md b/docs/config.md
index ad65901..e1b53f6 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -17,7 +17,7 @@ await solr.config({
});
```
-Typed command families include set/unset properties, request handlers, search components, query response writers, init params, query parsers, value-source parsers, transformers, update processors, query converters, listeners, and expressible components. `SolrRequestHandlerConfig` exposes typed `defaults`, `appends`, and `invariants` through `SolrConfigRequestHandlerParams`; common request parameters such as `q`, `fq`, `fl`, `rows`, `start`, `sort`, `wt`, `echoParams`, and time limits receive value-aware autocomplete, while the parameter map remains open for Solr extensions. Read models distinguish request handlers, search components, response writers, listeners, and init-params sets; response writers expose `contentType`/`charset`, and listeners expose standard event names with custom plugin event names still accepted. Cache settings are configured through `set-property` (for example, `query.filterCache.size`), not through a separate cache command. Search-component option maps and other plugin-defined component settings remain open; `customOptions` is the explicit flattened escape hatch for plugin-specific command properties. The library does not claim to validate every plugin's schema. Check the documentation for the target Solr version before applying configuration changes.
+Typed command families include set/unset properties, request handlers, search components, query response writers, init params, query parsers, value-source parsers, transformers, update processors, query converters, listeners, and expressible components. `SolrRequestHandlerConfig` exposes typed `handleSelect` plus `defaults`, `appends`, and `invariants` through `SolrConfigRequestHandlerParams`; common request parameters such as `q`, `fq`, `fl`, `rows`, `start`, `sort`, `wt`, `echoParams`, and time limits receive value-aware autocomplete, while the parameter map remains open for Solr extensions. Read models distinguish request handlers, search components, response writers, listeners, and init-params sets; response writers expose `contentType`/`charset`, and listeners expose standard event names with custom plugin event names still accepted. Cache settings are configured through `set-property` (for example, `query.filterCache.size`), not through a separate cache command. Search-component option maps and other plugin-defined component settings remain open; `customOptions` is the explicit flattened escape hatch for plugin-specific command properties. The library does not claim to validate every plugin's schema. Check the documentation for the target Solr version before applying configuration changes.
## Common properties and response types
diff --git a/docs/endpoint-inventory.md b/docs/endpoint-inventory.md
index 35c74ec..7c40c31 100644
--- a/docs/endpoint-inventory.md
+++ b/docs/endpoint-inventory.md
@@ -12,14 +12,14 @@ This inventory defines the boundary of the typed client API after review against
| Schema API `/schema` | `schema()` | Typed schema command models; integration coverage includes field, field-type, dynamic-field, copy-field, and schema read operations. |
| Config API `/config` | `config()`, `getConfig()`, `getConfigOverlay()` | Typed POST command models and typed GET effective-config/overlay responses. Integration coverage includes user-property mutation/read-back; broader component mutation coverage is tracked separately in [Issue #75](https://github.com/vsimko/node-solr-lambda/issues/75). |
| CoreAdmin V1 `/solr/admin/cores` | `coreAdmin()` | Action-specific parameter models include STATUS, CREATE, RELOAD, UNLOAD, RENAME, SWAP, MERGEINDEXES, SPLIT, REQUESTSTATUS, and REQUESTRECOVERY. Integration coverage includes isolated CREATE, STATUS discovery, RELOAD, and cleanup UNLOAD; async status/recovery actions have serialization coverage only. UPGRADEINDEX is intentionally excluded from the cross-version union because it is not part of the pinned Solr 8.11.4 baseline and is an in-place, non-SolrCloud operation. |
-| Collections V1 `/solr/admin/collections` | `collections()` | Action-specific input models cover the current action union. Backup/restore and async operations have named response models; other actions use caller-supplied generic response shapes. Per-action evidence and unverified operations are tracked in the [Collections action coverage matrix](collections-api-coverage.md). Other unmodelled actions include MIGRATE, FORCELEADER, and selected cluster/node management commands. |
+| Collections V1 `/solr/admin/collections` | `collections()` | Action-specific input models cover the current action union. Backup/restore and async operations have named response models; other actions use caller-supplied generic response shapes. Per-action evidence and unverified operations are tracked in the [Collections action coverage matrix](collections-api-coverage.md). SPLITSHARD and MIGRATE have opt-in, loopback-only runtime checks in the manually dispatched workflow; they remain unverified until that workflow passes. Other unmodelled actions include FORCELEADER and selected cluster/node management commands. |
| Security API `/solr/admin/authentication`, `/solr/admin/authorization` | `security()` | Authentication and authorization requests; dedicated security-enabled integration coverage is available on all three pinned versions. |
| Ping `/admin/ping` | `ping()` | Typed client method; response status tested on all three pinned versions. |
| System information `/solr/admin/info/system` | `systemInfo()` | Typed client method; representative response integration-tested on all three pinned versions. |
| Metrics API `/solr/admin/metrics` | `metrics()` | Typed response models; JSON metrics on Solr 8/9 and Prometheus exposition text on Solr 10.1 are documented. |
| Streaming Expressions `/stream` | `stream()` | Expression request support and JSON response parsing; tested against SolrCloud on all three pinned versions. |
| Export handler `/export` | `export()`, `exportStream()` | Typed export parameters and raw backpressure-aware response/byte-stream access; docValues sort-field flow tested on all three pinned versions. |
-| ReplicationHandler V1 `/solr/{core}/replication` | `replication()`, `replicationFile()` | Typed commands include indexversion, details, filelist, fetch/poll/enable/disable/abort, backup, restore, restorestatus, and deletebackup (including Solr 8/9 snappull/abort aliases), plus raw file-content streaming. Core backup/restore cycles are integration-tested on all three pinned versions. A complete leader/follower fetch cycle is not yet certified. |
+| ReplicationHandler V1 `/solr/{core}/replication` | `replication()`, `replicationFile()` | Typed commands include indexversion, details, filelist, fetch/poll/enable/disable/abort, backup, restore, restorestatus, and deletebackup (using fetchindex/abortfetch across the pinned Solr 8/9/10 versions), plus raw file-content streaming. Core backup/restore cycles are integration-tested on all three pinned versions. A complete leader/follower fetch cycle is not yet certified. |
| Generic/custom request handlers | `request()`, `requestRaw()` | Escape hatches for custom paths and parameters. These do not imply typed support or integration certification for arbitrary endpoints. |
## Explicitly deferred or excluded endpoint families
diff --git a/docs/replication.md b/docs/replication.md
index 375730c..68632b4 100644
--- a/docs/replication.md
+++ b/docs/replication.md
@@ -43,23 +43,23 @@ The `indexversion` response includes the current `generation` on newer Solr vers
## Control a follower
```ts
-// Solr 10 uses fetchindex/abortfetch. Solr 8 and 9 use the legacy snappull/abort names.
+// Use fetchindex/abortfetch across the pinned Solr 8, 9, and 10 versions.
// leaderUrl may be omitted when configured on the follower.
await client.replication("fetchindex", {
- leaderUrl: "http://leader:8983/solr/catalog/replication",
+ leaderUrl: "http://leader:8983/solr/catalog",
compression: "internal",
httpConnTimeout: 5_000,
httpReadTimeout: 10_000
});
-// On Solr 8/9, use "snappull" to trigger a fetch and "abort" to stop it.
+// Use "fetchindex" to trigger a fetch and "abortfetch" to stop it.
// Pause/resume automatic polling or abort an in-progress fetch.
await client.replication("disablepoll");
await client.replication("enablepoll");
await client.replication("abortfetch");
```
-`fetchindex` accepts the documented follower connection and compression settings. For Basic authentication between the follower and its leader, configure the ReplicationHandler's server-side follower settings; do not put credentials into URLs or query parameters.
+`fetchindex`/`abortfetch` are the canonical fetch and cancellation commands across the pinned Solr 8.11.4, 9.11.0, and 10.1.0 versions. `leaderUrl` is the leader core URL without the `/replication` suffix; Solr appends the handler path when fetching files. For Basic authentication between the follower and its leader, configure the ReplicationHandler's server-side follower settings; do not put credentials into URLs or query parameters.
## Control a leader
@@ -97,10 +97,13 @@ Supported directory types are `file` (Lucene index files), `cf` (configuration f
## Scope and compatibility
-- Typed commands: `enablereplication`, `disablereplication`, `indexversion`, `fetchindex`/`abortfetch` (Solr 10), `snappull`/`abort` (Solr 8/9), `enablepoll`, `disablepoll`, `details`, `filelist`, `backup`, `restore`, `restorestatus`, and `deletebackup`.
+- Typed commands: `enablereplication`, `disablereplication`, `indexversion`, `fetchindex`/`abortfetch` (Solr 8/9/10), `enablepoll`, `disablepoll`, `details`, `filelist`, `backup`, `restore`, `restorestatus`, and `deletebackup`.
- `filecontent` is exposed separately through `replicationFile()` because it returns a raw stream.
- Core-level `backup`, `restore`, `restorestatus`, and `deletebackup` are exposed through `replication()`; see [backup and restore docs](backup-restore.md).
- The client currently exposes the V1 handler path. Solr 10.1 also documents V2 equivalents for selected file and index-version operations; no dedicated V2 wrapper is provided.
- The handler may not be configured for user-managed leader/follower replication on every core. The server must have the appropriate ReplicationHandler configuration before control commands can be used.
+- A regular standalone/security integration test creates disposable leader and follower cores, commits a unique leader document, triggers `fetchindex` on Solr 8/9/10 with the fully qualified leader handler URL, and verifies that the document becomes searchable on the follower. Treat this behavior as pending runtime verification until the pinned Solr 8.11.4, 9.11.0, and 10.1.0 CI jobs pass. The test does not cover polling or abort of an in-progress transfer.
+- `abortfetch` on Solr 8/9/10, plus `enablepoll`/`disablepoll`, have request-serialization unit coverage; cancellation of a live transfer remains unverified. The disposable-core fixture unloads both cores in its `finally` cleanup. If a CI job is cancelled or the fetch does not settle, the ephemeral Solr container is discarded rather than reusing the cores.
+
See the [Solr 8.11](https://solr.apache.org/__root/docs.solr.apache.org/guide/8_11/deployment-guide/user-managed-index-replication.html), [Solr 9.11](https://solr.apache.org/guide/solr/9_11/deployment-guide/user-managed-index-replication.html), and [Solr 10.1](https://solr.apache.org/guide/solr/10_1/deployment-guide/user-managed-index-replication.html) guides for server configuration and command semantics.
diff --git a/src/types.ts b/src/types.ts
index 249a2fd..28807a7 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1102,6 +1102,8 @@ export interface SolrConfigPlugin {
customOptions?: Readonly>;
}
export interface SolrRequestHandlerConfig extends SolrConfigPlugin {
+ /** Allow the handler to be selected through the legacy `qt` request parameter. */
+ handleSelect?: boolean;
defaults?: SolrConfigRequestHandlerParams;
appends?: SolrConfigRequestHandlerParams;
invariants?: SolrConfigRequestHandlerParams;
diff --git a/test/admin-api-types.ts b/test/admin-api-types.ts
index 24cb2cb..ce7814c 100644
--- a/test/admin-api-types.ts
+++ b/test/admin-api-types.ts
@@ -192,6 +192,7 @@ const invalidConfigValue: SolrConfigCommand = { "set-property": { "query.maxBool
// @ts-expect-error unset-property only accepts known common Config API property names.
const invalidUnsetProperty: SolrConfigCommand = { "unset-property": "query.notARealSetting" };
void [invalidConfigProperty, invalidConfigValue, invalidUnsetProperty];
+const handleSelectHandler: SolrConfigCommand = { "add-requesthandler": { name: "/replication", class: "solr.ReplicationHandler", handleSelect: true } };
const addHandler: SolrConfigCommand = {
"add-requesthandler": {
name: "/select",
@@ -214,6 +215,7 @@ const addInitParams: SolrConfigCommand = {
"add-initparams": { name: "shared-search-params", path: ["/select", "/query"], defaults: { rows: 20, wt: "json" } }
};
void client.config(setProperty);
+void client.config(handleSelectHandler);
void client.config(addHandler);
void client.config(addListener);
void client.config(addInitParams);
diff --git a/test/integration/all.test.ts b/test/integration/all.test.ts
index 36f2f2f..be2a43a 100644
--- a/test/integration/all.test.ts
+++ b/test/integration/all.test.ts
@@ -1,4 +1,5 @@
import "./solr-collections.test.js";
+import "./solrcloud-split-migrate-optin.test.js";
import "./solr-config-security.test.js";
import "./solr-config-operations.test.js";
import "./solr-core-lifecycle.test.js";
@@ -15,6 +16,7 @@ import "./solr-cursor.test.js";
import "./solr-schema.test.js";
import "./solr-security.test.js";
import "./solr-replication.test.js";
+import "./solr-replication-lifecycle.test.js";
import "./solr-core-backup-restore.test.js";
import "./solrcloud-backup-restore.test.js";
import "./solr-smoke.test.js";
diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts
index 0b8d7dd..2bceb86 100644
--- a/test/integration/fixtures.ts
+++ b/test/integration/fixtures.ts
@@ -234,9 +234,11 @@ export function integrationTest(
export function cloudIntegrationTest(
name: string,
run: (fixture: SolrCloudIntegrationFixture) => Promise,
- requiresMultipleNodes = false
+ requiresMultipleNodes = false,
+ requiresDestructiveOptIn = false
): void {
- test(name, { skip: !baseUrl || !cloudEnabled || (requiresMultipleNodes && process.env.SOLR_MULTINODE !== "true") }, async () => {
+ const localEndpoint = baseUrl ? ["localhost", "127.0.0.1", "::1"].includes(new URL(baseUrl).hostname) : false;
+ test(name, { skip: !baseUrl || !cloudEnabled || (requiresMultipleNodes && process.env.SOLR_MULTINODE !== "true") || (requiresDestructiveOptIn && (process.env.SOLR_DESTRUCTIVE_OPT_IN !== "true" || !localEndpoint)) }, async () => {
const client = new SolrClient({ baseUrl: baseUrl!, timeoutMs: 30_000 });
const createdCollections = new Set();
const createdAliases = new Set();
diff --git a/test/integration/solr-replication-lifecycle.test.ts b/test/integration/solr-replication-lifecycle.test.ts
new file mode 100644
index 0000000..cc7eda8
--- /dev/null
+++ b/test/integration/solr-replication-lifecycle.test.ts
@@ -0,0 +1,79 @@
+import assert from "node:assert/strict";
+import { SolrClient } from "../../src/index.js";
+import { integrationTest } from "./fixtures.js";
+
+integrationTest("ReplicationHandler fetches a committed leader index into an isolated follower core", async ({
+ createCore,
+ createId
+}) => {
+ const leaderCore = await createCore("replication_lifecycle_leader");
+ const followerCore = await createCore("replication_lifecycle_follower");
+ const baseUrl = process.env.SOLR_BASE_URL!;
+ const username = process.env.SOLR_USERNAME;
+ const password = process.env.SOLR_PASSWORD;
+ const makeClient = (core: string) => new SolrClient({
+ baseUrl,
+ core,
+ timeoutMs: 30_000,
+ ...(username && password ? { auth: { type: "basic" as const, username, password } } : {})
+ });
+ const leader = makeClient(leaderCore);
+ const follower = makeClient(followerCore);
+ const leaderUrl = `${baseUrl.replace(/\/$/, "")}/${leaderCore}`;
+ const documentId = createId("replication_lifecycle");
+
+ const leaderConfig = await leader.config({
+ "update-requesthandler": {
+ name: "/replication",
+ class: "solr.ReplicationHandler",
+ customOptions: { leader: { replicateAfter: ["commit"] } }
+ }
+ });
+ assert.equal(leaderConfig.responseHeader?.status, 0);
+ const followerConfig = await follower.config({
+ "update-requesthandler": {
+ name: "/replication",
+ class: "solr.ReplicationHandler",
+ customOptions: { follower: { leaderUrl, ...(username && password ? { httpBasicAuthUser: username, httpBasicAuthPassword: password } : {}) } }
+ }
+ });
+ assert.equal(followerConfig.responseHeader?.status, 0);
+
+ await leader.add([{ id: documentId, title: "leader follower lifecycle" }]);
+ await leader.commit({ waitSearcher: true });
+
+ const leaderVersion = await leader.replication("indexversion");
+ assert.equal(leaderVersion.responseHeader?.status, 0);
+ const leaderGeneration = leaderVersion.generation ?? Number(leaderVersion.indexversion);
+ assert.ok(Number.isFinite(leaderGeneration) && leaderGeneration > 0, "leader must publish a replicable index");
+
+ const fetchCommand = "fetchindex";
+ const fetchResponse = await follower.replication(fetchCommand, {
+ leaderUrl,
+ ...(username && password ? { customParams: { httpBasicAuthUser: username, httpBasicAuthPassword: password } } : {})
+ });
+ assert.equal(fetchResponse.responseHeader?.status, 0, `${fetchCommand} should complete successfully`);
+
+ // Replication copies Lucene index files asynchronously; assert the resulting
+ // searchable state rather than treating the command's HTTP response as proof.
+ let found = false;
+ for (let attempt = 0; attempt < 120; attempt += 1) {
+ const result = await follower.query<{ response?: { numFound?: number } }>({ query: `id:"${documentId}"`, fields: ["id"], limit: 1 });
+ if ((result.response?.numFound ?? 0) === 1) {
+ found = true;
+ break;
+ }
+ await new Promise(resolve => setTimeout(resolve, 1_000));
+ }
+ if (!found) {
+ const details = await follower.replication("details");
+ const abortCommand = "abortfetch";
+ await follower.replication(abortCommand).catch(() => undefined);
+ assert.fail(`follower did not expose the leader document after fetch; details: ${JSON.stringify(details)}`);
+ }
+
+ const followerVersion = await follower.replication("indexversion");
+ assert.equal(followerVersion.responseHeader?.status, 0);
+ const followerGeneration = followerVersion.generation ?? Number(followerVersion.indexversion);
+ assert.ok(Number.isFinite(followerGeneration) && followerGeneration > 0, "follower should publish its fetched index");
+});
diff --git a/test/integration/solrcloud-split-migrate-optin.test.ts b/test/integration/solrcloud-split-migrate-optin.test.ts
new file mode 100644
index 0000000..84c683b
--- /dev/null
+++ b/test/integration/solrcloud-split-migrate-optin.test.ts
@@ -0,0 +1,103 @@
+import assert from "node:assert/strict";
+import { randomUUID } from "node:crypto";
+import { cloudIntegrationTest } from "./fixtures.js";
+
+const routeKey = "tenant!";
+
+async function waitForCollectionRequest(client: import("../../src/index.js").SolrClient, requestId: string): Promise {
+ for (let attempt = 0; attempt < 180; attempt += 1) {
+ const result = await client.collections("REQUESTSTATUS", { requestid: requestId });
+ const state = result.status?.state;
+ if (state === "completed") {
+ await client.collections("DELETESTATUS", { requestid: requestId });
+ return;
+ }
+ if (state === "failed" || state === "notfound") {
+ throw new Error(`Collection request ${requestId} ended in ${state}: ${JSON.stringify(result)}`);
+ }
+ await new Promise(resolve => setTimeout(resolve, 1_000));
+ }
+ throw new Error(`Collection request ${requestId} did not complete within 180 seconds`);
+}
+
+cloudIntegrationTest(
+ "SPLITSHARD splits an isolated compositeId collection and preserves routed documents",
+ async ({ client, createCollection, clientForCollection }) => {
+ const collection = await createCollection("integration_splitshard_optin");
+ const statusBefore = await client.collections("CLUSTERSTATUS", { collection });
+ const liveNodes = statusBefore.cluster?.live_nodes ?? [];
+ assert.ok(liveNodes.length >= 2, "opt-in verification requires at least two live SolrCloud nodes");
+ const beforeShards = statusBefore.cluster?.collections?.[collection]?.shards ?? {};
+ assert.equal(Object.keys(beforeShards).length, 1, "new disposable collection must start with exactly one shard");
+
+ const source = clientForCollection(collection);
+ const ids = [1, 2, 3].map(index => `${routeKey}split-${index}`);
+ await source.add(ids.map(id => ({ id, title: "splitshard opt-in verification" })));
+ await source.commit({ waitSearcher: true });
+
+ const splitRequestId = `split-${randomUUID()}`;
+ const split = await client.collections("SPLITSHARD", {
+ collection,
+ "split.key": routeKey,
+ async: splitRequestId
+ });
+ assert.equal(split.responseHeader?.status, 0, "SPLITSHARD should be accepted");
+ await waitForCollectionRequest(client, splitRequestId);
+
+ const statusAfter = await client.collections("CLUSTERSTATUS", { collection });
+ const shardsAfter = statusAfter.cluster?.collections?.[collection]?.shards ?? {};
+ assert.ok(Object.keys(shardsAfter).length >= 2, "cluster state should expose the split child shards");
+ assert.ok(
+ Object.values(shardsAfter).filter(shard => shard.state === "active").length >= 2,
+ "at least two child shards should be active"
+ );
+ const result = await source.query<{ response?: { numFound?: number } }>({ query: "*:*", fields: ["id"], limit: 10 });
+ assert.equal(result.response?.numFound, ids.length, "all documents should remain searchable after the split");
+ },
+ true,
+ true
+);
+
+cloudIntegrationTest(
+ "MIGRATE moves an isolated compositeId routing prefix into a disposable target collection",
+ async ({ client, createCollection, clientForCollection }) => {
+ const sourceName = await createCollection("integration_migrate_source_optin");
+ const targetName = await createCollection("integration_migrate_target_optin");
+ const status = await client.collections("CLUSTERSTATUS");
+ assert.ok((status.cluster?.live_nodes?.length ?? 0) >= 2, "opt-in verification requires at least two live SolrCloud nodes");
+ const source = clientForCollection(sourceName);
+ const target = clientForCollection(targetName);
+ const ids = [1, 2, 3].map(index => `${routeKey}migrate-${index}`);
+
+ await source.add(ids.map(id => ({ id, title: "migration opt-in verification" })));
+ await source.commit({ waitSearcher: true });
+
+ const targetBefore = await target.query<{ response?: { numFound?: number } }>({ query: "*:*", fields: ["id"], limit: 10 });
+ assert.equal(targetBefore.response?.numFound, 0, "migration target must be empty before mutation");
+
+ const migrationRequestId = `migrate-${randomUUID()}`;
+ const migrated = await client.collections("MIGRATE", {
+ collection: sourceName,
+ "split.key": routeKey,
+ "target.collection": targetName,
+ "forward.timeout": 120,
+ async: migrationRequestId
+ });
+ assert.equal(migrated.responseHeader?.status, 0, "MIGRATE should be accepted");
+ await waitForCollectionRequest(client, migrationRequestId);
+
+ let targetCount = 0;
+ for (let attempt = 0; attempt < 60; attempt += 1) {
+ const result = await target.query<{ response?: { numFound?: number } }>({ query: "*:*", fields: ["id"], limit: 10 });
+ targetCount = result.response?.numFound ?? 0;
+ if (targetCount === ids.length) break;
+ await new Promise(resolve => setTimeout(resolve, 1_000));
+ }
+ assert.equal(targetCount, ids.length, "target collection should contain every migrated routing-key document");
+ const targetReadBack = await target.query<{ response?: { docs?: Array<{ id: string }> } }>({ query: "*:*", fields: ["id"], limit: 10 });
+ const foundIds = new Set(targetReadBack.response?.docs?.map(document => String(document.id)) ?? []);
+ for (const id of ids) assert.ok(foundIds.has(id), `target collection should contain migrated document ${id}`);
+ },
+ true,
+ true
+);