swarm-deploy 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +780 -50
  3. package/RELEASING.md +2 -2
  4. package/SECURITY.md +2 -3
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +182 -25
  7. package/dist/cli.js.map +1 -1
  8. package/dist/client.d.ts +32 -27
  9. package/dist/client.js +103 -54
  10. package/dist/client.js.map +1 -1
  11. package/dist/errors.d.ts +6 -0
  12. package/dist/errors.js +6 -0
  13. package/dist/errors.js.map +1 -1
  14. package/dist/files.d.ts +13 -28
  15. package/dist/files.js +13 -78
  16. package/dist/files.js.map +1 -1
  17. package/dist/hooks-module.d.ts +9 -0
  18. package/dist/hooks-module.js +118 -0
  19. package/dist/hooks-module.js.map +1 -0
  20. package/dist/hooks.d.ts +69 -0
  21. package/dist/hooks.js +88 -0
  22. package/dist/hooks.js.map +1 -0
  23. package/dist/index.d.ts +8 -4
  24. package/dist/index.js +3 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/release.d.ts +26 -0
  27. package/dist/release.js +419 -0
  28. package/dist/release.js.map +1 -0
  29. package/dist/server.d.ts +97 -0
  30. package/dist/server.js +527 -53
  31. package/dist/server.js.map +1 -1
  32. package/dist/storage/commit-journal.d.ts +39 -2
  33. package/dist/storage/commit-journal.js +140 -2
  34. package/dist/storage/commit-journal.js.map +1 -1
  35. package/dist/storage/commit-store.d.ts +69 -8
  36. package/dist/storage/commit-store.js +374 -22
  37. package/dist/storage/commit-store.js.map +1 -1
  38. package/dist/storage/layout.js +7 -1
  39. package/dist/storage/layout.js.map +1 -1
  40. package/dist/storage/link-store.d.ts +88 -0
  41. package/dist/storage/link-store.js +448 -0
  42. package/dist/storage/link-store.js.map +1 -0
  43. package/dist/storage/recovery.d.ts +2 -1
  44. package/dist/storage/recovery.js +8 -1
  45. package/dist/storage/recovery.js.map +1 -1
  46. package/dist/storage/retention.d.ts +41 -21
  47. package/dist/storage/retention.js +162 -8
  48. package/dist/storage/retention.js.map +1 -1
  49. package/dist/storage/tar-session-store.d.ts +36 -5
  50. package/dist/storage/tar-session-store.js +283 -55
  51. package/dist/storage/tar-session-store.js.map +1 -1
  52. package/dist/storage/tree-fs.d.ts +44 -0
  53. package/dist/storage/tree-fs.js +580 -0
  54. package/dist/storage/tree-fs.js.map +1 -0
  55. package/dist/storage/tree-staging.d.ts +9 -0
  56. package/dist/storage/tree-staging.js +64 -0
  57. package/dist/storage/tree-staging.js.map +1 -0
  58. package/dist/storage/types.d.ts +15 -0
  59. package/dist/symlinks.d.ts +44 -0
  60. package/dist/symlinks.js +153 -0
  61. package/dist/symlinks.js.map +1 -0
  62. package/dist/tar-protocol/controls.d.ts +52 -0
  63. package/dist/tar-protocol/controls.js +160 -2
  64. package/dist/tar-protocol/controls.js.map +1 -1
  65. package/dist/tar-protocol/direct-wire.d.ts +8 -3
  66. package/dist/tar-protocol/direct-wire.js +14 -3
  67. package/dist/tar-protocol/direct-wire.js.map +1 -1
  68. package/dist/tar-protocol/manifest.d.ts +13 -1
  69. package/dist/tar-protocol/manifest.js +26 -1
  70. package/dist/tar-protocol/manifest.js.map +1 -1
  71. package/dist/tar-protocol/tree-extract.d.ts +37 -0
  72. package/dist/tar-protocol/tree-extract.js +240 -0
  73. package/dist/tar-protocol/tree-extract.js.map +1 -0
  74. package/dist/tar-protocol/tree-manifest.d.ts +45 -0
  75. package/dist/tar-protocol/tree-manifest.js +277 -0
  76. package/dist/tar-protocol/tree-manifest.js.map +1 -0
  77. package/dist/tar-protocol/tree-path.d.ts +15 -0
  78. package/dist/tar-protocol/tree-path.js +68 -0
  79. package/dist/tar-protocol/tree-path.js.map +1 -0
  80. package/dist/tar-protocol/tree.d.ts +68 -0
  81. package/dist/tar-protocol/tree.js +269 -0
  82. package/dist/tar-protocol/tree.js.map +1 -0
  83. package/dist/tar-protocol/ustar.d.ts +14 -0
  84. package/dist/tar-protocol/ustar.js +81 -1
  85. package/dist/tar-protocol/ustar.js.map +1 -1
  86. package/dist/types.d.ts +3 -0
  87. package/package.json +3 -1
package/README.md CHANGED
@@ -155,7 +155,12 @@ swarm-deploy server \
155
155
  [--allow-key <64-lower-hex>]... \
156
156
  [--max-storage-bytes <bytes>] \
157
157
  [--max-age-days <days>] \
158
- [--replace-name <safe-basename>]...
158
+ [--replace-name <safe-basename>]... \
159
+ [--artifact-pattern <template>]... \
160
+ [--max-count <count>] \
161
+ [--max-versions <count> --version-granularity <major|minor>] \
162
+ [--symlink <selector> [<link-name>]]... \
163
+ [--hooks <module>]
159
164
  ```
160
165
 
161
166
  Replace `--seed-file <seed-file>` with `--seed <64-lower-hex>` to provide the
@@ -181,6 +186,32 @@ Optional options:
181
186
  - `--max-age-days`: remove eligible committed artifacts at or beyond this age.
182
187
  - `--replace-name`: permit replacement of this exact basename. Repeat for
183
188
  multiple mutable names.
189
+ - `--artifact-pattern <template>`: identify releases and make matching
190
+ mandatory. Repeat for multiple templates; declaration order is match order.
191
+ See [Artifact patterns and rotation](#artifact-patterns-and-rotation).
192
+ - `--max-count <count>`: keep the newest `<count>` releases per series. Requires
193
+ at least one `--artifact-pattern`.
194
+ - `--max-versions <count>`: keep every release in the newest `<count>` distinct
195
+ version groups per series. Requires a pattern containing `{version}` and
196
+ `--version-granularity`.
197
+ - `--version-granularity <major|minor>`: how versions are grouped for
198
+ `--max-versions`. It is rejected without `--max-versions`, and there is no
199
+ default.
200
+ - `--symlink <selector> [<link-name>]`: repeatable managed-symlink policy.
201
+ With two arguments, the server automatically points `<link-name>` at the
202
+ newest matching managed artifact. With one argument, the rule creates no
203
+ link by itself; it authorizes authenticated clients to choose any matching
204
+ current managed artifact as a manual-link target. A selector that begins and
205
+ ends with `/` is an unflagged regular expression matched against managed
206
+ artifact basenames; anything else is an exact managed basename.
207
+ - `--hooks <module>`: JavaScript module (`.js`, `.mjs`, or `.cjs`) exporting
208
+ trusted lifecycle callbacks. The path is resolved against the working
209
+ directory. See [Deployment hooks](#deployment-hooks).
210
+
211
+ All counts are positive safe integers. Invalid combinations, malformed or
212
+ duplicate templates, and unloadable hook modules are configuration errors that
213
+ exit `2` before the server listens. Each hooks-module error names only the
214
+ module basename with a fixed reason, never module content.
184
215
 
185
216
  The CLI requires at least one `--allow-key`. Its snapshot is immutable for the
186
217
  life of the process.
@@ -192,6 +223,7 @@ swarm-deploy upload \
192
223
  --seed-file <seed-file> \
193
224
  --server-key <64-lower-hex> \
194
225
  [--idle-timeout <milliseconds>] \
226
+ [--no-source-parent] \
195
227
  <file-or-directory>
196
228
  ```
197
229
 
@@ -204,12 +236,19 @@ seed inline.
204
236
  - The input may be any regular binary file; Swarm Deploy creates the canonical
205
237
  one-entry USTAR stream automatically. Pre-tarring is not required.
206
238
  - A direct file retains its basename.
207
- - A directory processes immediate regular-file children once, in lexical
208
- order, with one independent connection and result per file.
209
- - Subdirectories, symlinks, non-regular files, unsafe names, and names in the
210
- reserved `history-` namespace are skipped and reported.
211
- - An unreadable directory entry is reported as a failure while later entries
212
- continue.
239
+ - Each upload also carries the immediate local parent directory name as
240
+ optional `sourceParent` metadata, but only when that name is a safe single
241
+ component (see [Source parent](#source-parent)).
242
+ - `--no-source-parent` takes no value and suppresses that metadata for every
243
+ file in the run. The upload then keeps the legacy transfer identity and
244
+ cannot match a server pattern with a parent segment.
245
+ - A directory input commits exactly one recursive managed directory artifact
246
+ named after the input basename. Every member must be a safe regular file or
247
+ directory; symlinks, hard links, devices, sockets, FIFOs, unsafe names,
248
+ over-depth, over-count, or over-length members reject the whole upload.
249
+ Nothing is silently skipped.
250
+ - On success the CLI prints one line: `<name> <kind> <status>` (for example
251
+ `0.18.1 directory COMMITTED`).
213
252
 
214
253
  Accepted names start with an ASCII letter or digit, contain only letters,
215
254
  digits, `.`, `_`, and `-`, and occupy at most 100 UTF-8 bytes. `history-` is
@@ -219,9 +258,35 @@ If a basename is exactly 64 lowercase hexadecimal characters, pass it with a
219
258
  directory component such as `./<name>` so the CLI does not treat it as an
220
259
  accidentally pasted seed.
221
260
 
261
+ ### `link`
262
+
263
+ ```sh
264
+ swarm-deploy link \
265
+ --seed-file <seed-file> \
266
+ --server-key <64-lower-hex> \
267
+ [--idle-timeout <milliseconds>] \
268
+ <target> <link-name>
269
+ ```
270
+
271
+ Replace `--seed-file <seed-file>` with `--seed <64-lower-hex>`, or use
272
+ `SWARM_DEPLOY_CLIENT_SEED`, exactly as for `upload`. `--server-key` pins the
273
+ server identity, and `--idle-timeout` has the same 60-second default and
274
+ inactivity semantics as upload.
275
+
276
+ The command asks the authenticated server to point `<link-name>` at the
277
+ existing managed file or directory `<target>`. The target must match a
278
+ one-argument `--symlink` rule. Success prints exactly:
279
+
280
+ ```text
281
+ <link-name> -> <target> <LINKED|UNCHANGED>
282
+ ```
283
+
284
+ `LINKED` means the link was created or repointed. `UNCHANGED` means the same
285
+ managed link already points at that target; the operation is idempotent.
286
+
222
287
  ### Seed sources
223
288
 
224
- Server and upload commands accept exactly one of:
289
+ Server, upload, and link commands accept exactly one of:
225
290
 
226
291
  - `--seed-file <seed-file>`
227
292
  - `--seed <64-lower-hex>`
@@ -240,13 +305,11 @@ tooling logs is acceptable.
240
305
 
241
306
  ### CLI exit codes
242
307
 
243
- - `0`: every selected file was committed or already committed.
308
+ - `0`: the upload committed or was already committed, or the link is linked or
309
+ unchanged.
244
310
  - `1`: upload, network, protocol, storage, cleanup, or runtime failure.
245
311
  - `2`: usage or configuration error.
246
312
 
247
- For a directory, the CLI prints one line for every selected or skipped entry and
248
- returns `1` if any selected upload failed.
249
-
250
313
  ## CI uploader example
251
314
 
252
315
  Store the client seed as a protected CI secret and the server public key as a
@@ -279,7 +342,7 @@ Each file follows this lifecycle:
279
342
  2. The server firewall and connection handler verify the authenticated client
280
343
  key against the startup allowlist.
281
344
  3. The client sends bounded metadata containing the name, file size and digest,
282
- deterministic TAR size and digest, and transfer ID.
345
+ deterministic TAR size and digest, transfer ID, and optional `sourceParent`.
283
346
  4. The server responds with `ACCEPT`, `RESUME`, `VERIFIED`,
284
347
  `ALREADY_COMMITTED`, or a stable rejection.
285
348
  5. The client sends exactly the required deterministic one-entry USTAR bytes.
@@ -331,9 +394,23 @@ Optional retention applies to managed artifacts only:
331
394
  - `maxAge`/`--max-age-days` removes eligible artifacts by age.
332
395
  - `maxStorageBytes`/`--max-storage-bytes` removes the oldest eligible artifacts
333
396
  until under quota.
334
- - Startup recovery re-hashes managed files. Scheduled cleanup validates managed
335
- metadata and file sizes, removes invalid managed records safely, reports
336
- unknown paths without deleting them, and applies age and quota retention.
397
+ - `maxCount`/`--max-count` and `maxVersions`/`--max-versions` rotate released
398
+ artifacts by series; see
399
+ [Artifact patterns and rotation](#artifact-patterns-and-rotation).
400
+ - Directory artifacts are create-only. A directory offer for a configured
401
+ `replaceNames` entry, a file-to-directory kind change, or a directory-to-file
402
+ kind change is rejected. Directory replacement and history are deferred.
403
+ - Committed artifacts, including directories, are immutable on disk. Do not write
404
+ into a committed directory after publish: hooks must not run `npm ci`, create
405
+ `.cache`, dotfiles, or extra symlinks inside the tree. If the tree changes,
406
+ startup hash scrub drops the sidecar while leaving the bytes in place, the
407
+ name stays occupied (`FILE_EXISTS`), the tree falls outside quota and rotation,
408
+ and a managed `latest` link can move to an older release.
409
+ - Managed directories are deleted by renaming into `.swarm-deploy/trash` before
410
+ recursive removal. Startup sweeps proven trash residue.
411
+ - Startup recovery re-hashes managed files and recursively verifies directory
412
+ tree digests. Scheduled and pre-commit passes validate type, sidecar, and root
413
+ identity without re-hashing entire trees on every tick.
337
414
 
338
415
  Runtime defaults:
339
416
 
@@ -345,7 +422,599 @@ Runtime defaults:
345
422
  - 7-day resumable-session lifetime.
346
423
 
347
424
  The advanced runtime API can override these values; the server CLI intentionally
348
- exposes only its required limits, committed retention, and replacement policy.
425
+ exposes only its required limits, committed retention and rotation, replacement
426
+ policy, artifact patterns, and hooks module.
427
+
428
+ ## Artifact patterns and rotation
429
+
430
+ Artifact patterns teach the server which uploads belong to which release
431
+ series and, optionally, which SemVer version each one carries. They drive count
432
+ and version rotation and are configured only on the server:
433
+ `ServerOptions.artifactPatterns` or repeatable `--artifact-pattern`.
434
+
435
+ ### Pattern templates
436
+
437
+ A template is literal text plus the placeholders `{series}` and `{version}`:
438
+
439
+ - At least one placeholder is required; each may appear at most once.
440
+ - Two placeholders in one path segment must be separated by literal text.
441
+ - `{` and `}` are reserved. A template whose literal text still contains a
442
+ brace after the exact `{series}` and `{version}` placeholders are removed
443
+ (`{serie}-{version}.tar.gz`, `{{series}.zip`) is rejected at startup rather
444
+ than matched literally.
445
+ - A template has one segment (matched against the basename) or two segments
446
+ separated by one `/` (`<source-parent>/<basename>`). Empty segments, further
447
+ `/` characters, and duplicate templates are rejected at startup.
448
+ - `{series}` captures one safe component. A template without `{series}` is a
449
+ fixed series whose key is `fixed-` followed by the lowercase hex SHA-256 of
450
+ the exact template text. `{version}/payments.tar.gz` therefore has the series
451
+ `fixed-594ef13d9808171190f7827f7d2fb1dc33f000f531b69d55c1bd485a70ff8c4b`. The
452
+ key is a 70-character safe basename, so it persists in commit records and
453
+ stays identical across restarts and between Node.js and Bare. The root export
454
+ `fixedSeriesKey(template)` derives the same value, so an operator can map a
455
+ series in a sidecar or log line back to the template that produced it.
456
+ - That derived shape is a reserved namespace: a captured `{series}` of exactly
457
+ `fixed-` plus 64 lowercase hex characters never matches, so an uploaded
458
+ filename cannot place itself in a version-only pattern's rotation group. The
459
+ offer is simply unmatched, which with mandatory matching means
460
+ `INVALID_FILENAME`. Look-alikes (different length, uppercase, non-hex) are
461
+ unaffected.
462
+ - `{version}` must be strict SemVer 2.0.0: no `v` prefix, no leading zeros, no
463
+ padding. The stored version is the normalized `major.minor.patch[-prerelease]`;
464
+ build metadata is accepted in a folder name but dropped, and it never
465
+ influences precedence.
466
+ - Basenames never contain `+`, so build metadata can only appear in a
467
+ source-parent segment.
468
+ - A malformed version is a non-match, not a partial match.
469
+
470
+ Examples:
471
+
472
+ | Template | Offer (`sourceParent`, basename) | Series | Version |
473
+ | --------------------------- | ----------------------------------- | ------------- | ------------ |
474
+ | `{series}-{version}.tar.gz` | `payments-2.4.1.tar.gz` | `payments` | `2.4.1` |
475
+ | `{series}-{version}.tar.gz` | `payments-3.0.0-rc.1.tar.gz` | `payments` | `3.0.0-rc.1` |
476
+ | `{version}/{series}.tar.gz` | `2.4.1+build.7` / `payments.tar.gz` | `payments` | `2.4.1` |
477
+ | `releases/{series}.zip` | `releases` / `payments.zip` | `payments` | none |
478
+ | `{version}/payments.tar.gz` | `1.8.0` / `payments.tar.gz` | `fixed-594e…` | `1.8.0` |
479
+ | `{series}.tar.gz` | `payments.tar.gz` | `payments` | none |
480
+
481
+ With a series-first template such as `{series}-{version}.tar.gz`, the split
482
+ chosen is the right-most `-` that yields a valid SemVer, so hyphenated series
483
+ names and prereleases both work. A version-first template such as
484
+ `{version}-{series}.tar.gz` splits at the left-most valid position and cannot
485
+ tell a prerelease hyphen from the series separator; prefer series-first or a
486
+ folder-based version for prereleases.
487
+
488
+ Patterns are tried in declaration order and the first template that produces
489
+ coordinates wins. A template with a parent segment never matches an offer that
490
+ carries no `sourceParent`.
491
+
492
+ ### Source parent
493
+
494
+ The client sends the immediate parent directory name of the resolved input as
495
+ optional authenticated metadata, never an absolute path or a higher component.
496
+ Uploading `/ci/2.4.1+build.7/payments.tar.gz` sends `sourceParent`
497
+ `2.4.1+build.7`; uploading a directory sends that directory's name for each
498
+ child.
499
+
500
+ The parent is sent only when it is a safe single component
501
+ (`[A-Za-z0-9][A-Za-z0-9._+-]*`, at most 100 UTF-8 bytes). A parent with a space,
502
+ a leading dot, a non-ASCII character, an overlong name, or the filesystem root
503
+ is omitted, and the upload keeps the pre-existing metadata shape. Consequently a
504
+ folder pattern rejects uploads whose parent is unsafe or omitted: stage release
505
+ files in a conforming directory. `sourceParent` is part of the transfer ID, so
506
+ it cannot change between resume attempts.
507
+
508
+ A client can also suppress it deliberately with `includeSourceParent: false`
509
+ (CLI `--no-source-parent`) where the staging folder name itself is sensitive.
510
+ The upload then sends no parent and derives the same transfer ID as a client
511
+ that predates the field, so a server pattern needing a parent segment rejects
512
+ it with `INVALID_FILENAME`.
513
+
514
+ ### Mandatory matching
515
+
516
+ When `artifactPatterns` is non-empty, matching is mandatory for new uploads. An
517
+ offer that matches no pattern is rejected with `INVALID_FILENAME` before
518
+ session admission, staging, verification, commit, `beforeCommit`, and
519
+ `afterCommit`; the server then calls `onFailure` once with phase `offer`, a
520
+ `null` path, and no `release`. A server with no patterns accepts every
521
+ otherwise-valid name, and records get no release identity.
522
+
523
+ Release coordinates (`series` and, if the pattern has one, `version`) are
524
+ decided at commit time and persisted in the commit record, replacement history
525
+ inherits them, and they survive restarts and later pattern changes. A commit
526
+ record written before this feature, or by a server without patterns, has no
527
+ release and is never deleted by count or version rotation; it remains subject
528
+ to age and quota retention.
529
+
530
+ A new upload whose matched release differs from the persisted release of the
531
+ same transfer fails closed instead of reporting `ALREADY_COMMITTED`.
532
+
533
+ ### Selection rules
534
+
535
+ Retention runs under the root lease at startup, on schedule, around every
536
+ commit, and manually. After session expiry and a committed-state scrub, its
537
+ stages run in this order, each working from the records the previous stage
538
+ left:
539
+
540
+ 1. age (`maxAge`);
541
+ 2. count (`maxCount`);
542
+ 3. version (`maxVersions` with `versionGranularity`);
543
+ 4. storage quota (`maxStorageBytes`).
544
+
545
+ **Count.** Records are grouped by series and ordered by commit order: newest
546
+ `committedAt` first, with transfer ID and then name as deterministic
547
+ tie-breakers. The newest `maxCount` records in each series are kept. Replacement
548
+ history records count like any other release.
549
+
550
+ **Version.** Records with a version are grouped by series and ordered by SemVer
551
+ precedence, not by commit time. With `major`, every record in the newest
552
+ `maxVersions` distinct majors is kept; with `minor`, in the newest
553
+ `maxVersions` distinct `major.minor` groups. Prereleases and history records in
554
+ a retained group are kept, so `2.0.0-rc.1` and `2.0.0` share group `2`. Series-only
555
+ records (no `{version}`) are never selected by version rotation.
556
+
557
+ **Combined limits.** A record may be removed by either configured bound, so
558
+ `maxCount` plus `maxVersions` retains the intersection of the two keep sets, and
559
+ age and quota then apply to what remains.
560
+
561
+ **Pinned mutable names.** The current artifact of a `--replace-name` name is
562
+ never deleted by any stage, but it still takes part in the accounting. If it
563
+ occupies a slot or falls outside a keep set, the limit is best-effort: the
564
+ series can hold more than the configured number until a later upload moves the
565
+ name on.
566
+
567
+ **Immediate effect.** Rotation also runs right after each commit, so an upload
568
+ that is older than the retained window (for example version `1.0.0` when
569
+ `maxVersions` is `2` and `3.x` and `2.x` exist) is committed and then removed in
570
+ that same pass. The exact ordering depends on hooks:
571
+
572
+ - Without an `afterCommit` hook, the post-commit pass runs immediately after the
573
+ commit becomes durable, before `COMMITTED` is sent.
574
+ - With an `afterCommit` hook, the post-commit pass is deferred. Order:
575
+ commit (the pre-commit quota and age checks still run first), `afterCommit`
576
+ with the final path and sidecar present, and only after the callback
577
+ succeeds the post-commit pass, then `COMMITTED` (or `ALREADY_COMMITTED`).
578
+ Because the deferred pass runs before the terminal reply, a slow retention
579
+ pass delays `COMMITTED` or `ALREADY_COMMITTED`. `beforeCommit`-only and
580
+ `onFailure`-only servers do not defer.
581
+ - The final path exists when `afterCommit` begins in the sequential server
582
+ flow, including for an out-of-window release, and for replacement commits
583
+ (a replaced artifact's history record is also present). It is not a lock:
584
+ a concurrent commit's retention pass, a scheduled retention pass, or a manual
585
+ retention pass can remove the file while the callback runs. A hook that needs stable bytes should open or
586
+ copy the file promptly at the start of the callback.
587
+ - If `afterCommit` throws, no post-commit pass runs for that connection. The
588
+ artifact and its record stay, and the server remembers in memory that this
589
+ transfer still owes a pass. An immediate retry normally reaches
590
+ `ALREADY_COMMITTED`, runs `afterCommit` again with the existing path, and runs
591
+ the owed pass only after that call succeeds. A failing already-committed
592
+ `afterCommit` likewise skips the pass and keeps the transfer owed. See the
593
+ retry caveat below.
594
+ - An ordinary duplicate offer — one whose `afterCommit` already succeeded, or
595
+ that this process never committed — still calls `afterCommit` with
596
+ `alreadyCommitted: true`, but starts **no** retention pass. Only a connection
597
+ that committed in this process, or a retry of a transfer whose hook failed
598
+ here, can trigger the deferred pass, so repeated duplicate offers cannot be
599
+ used to force repeated full retention scans.
600
+ - The owed-transfer set lives only in memory. A server restart forgets it, and
601
+ closing the server clears it; the artifact is then rotated by the next
602
+ startup, scheduled, or commit-triggered pass instead.
603
+ - Post-commit retention failures stay non-fatal: they are logged and reported as
604
+ `retention` events, and never fail the upload.
605
+
606
+ **Retry caveat.** There is no persistent "hook pending" marker. After
607
+ `afterCommit` fails for an out-of-window artifact, any intervening retention
608
+ pass can remove it before the client retries: a startup pass after a restart, a
609
+ scheduled or manual cleanup, or the retention pass of another concurrent commit.
610
+ The retry is then a normal fresh upload (transfer, verification, `beforeCommit`,
611
+ commit, `afterCommit` with `alreadyCommitted: false`), not `ALREADY_COMMITTED`.
612
+ Every statement below that a failed hook is retried as `ALREADY_COMMITTED`
613
+ assumes the artifact was not removed in between.
614
+
615
+ `retention` events and results report `ageDeleted`, `countDeleted`,
616
+ `versionDeleted`, and `storageDeleted` next to `expiredSessions` and `scrubbed`.
617
+ Logged deletions use the reasons `MAX_AGE`, `MAX_COUNT`, `MAX_VERSIONS`, and
618
+ `MAX_STORAGE`.
619
+
620
+ ### Configuration and CLI examples
621
+
622
+ Filename versions, keeping the newest 5 builds per product and the newest two
623
+ minor lines:
624
+
625
+ ```sh
626
+ swarm-deploy server --seed-file server.seed --storage /srv/artifacts \
627
+ --allow-key "$CLIENT_KEY" --max-file-bytes 1073741824 --max-staging-bytes 2147483648 \
628
+ --artifact-pattern '{series}-{version}.tar.gz' \
629
+ --max-count 5 --max-versions 2 --version-granularity minor
630
+ ```
631
+
632
+ Folder versions (the source parent carries the version, build metadata
633
+ included), keeping the two newest majors:
634
+
635
+ ```sh
636
+ swarm-deploy server --seed-file server.seed --storage /srv/artifacts \
637
+ --allow-key "$CLIENT_KEY" --max-file-bytes 1073741824 --max-staging-bytes 2147483648 \
638
+ --artifact-pattern '{version}/{series}.tar.gz' \
639
+ --max-versions 2 --version-granularity major
640
+
641
+ # client: /ci/out/2.4.1+build.7/payments.tar.gz is offered as
642
+ # sourceParent "2.4.1+build.7", name "payments.tar.gz"
643
+ swarm-deploy upload --seed-file client.seed --server-key "$SERVER_KEY" \
644
+ /ci/out/2.4.1+build.7/payments.tar.gz
645
+ ```
646
+
647
+ The runtime API takes the same values:
648
+
649
+ ```ts
650
+ new Server({
651
+ // ...required options
652
+ artifactPatterns: ['{series}-{version}.tar.gz', 'releases/{series}.zip'],
653
+ maxCount: 5,
654
+ maxVersions: 2,
655
+ versionGranularity: 'minor'
656
+ })
657
+ ```
658
+
659
+ `maxCount` requires at least one pattern. `maxVersions` requires at least one
660
+ pattern containing `{version}` and a `versionGranularity`; a granularity without
661
+ `maxVersions` is invalid. These errors are raised at construction or CLI startup
662
+ (exit `2`). The pattern list is snapshotted at construction.
663
+
664
+ ### Rollout and compatibility
665
+
666
+ Upgrade every server before any client. An older server decodes upload metadata
667
+ with an exact key set and rejects the new `sourceParent` field, so a new client
668
+ that sends a parent to an old server fails.
669
+
670
+ A new server remains compatible with older clients: they never send
671
+ `sourceParent`, no-pattern servers are unaffected, filename-only patterns still
672
+ match, and patterns that need a parent reject their offers as unmatched. Older
673
+ sessions and commit records stay readable, and older commit records are not
674
+ count or version rotated.
675
+
676
+ Roll out the server, then the pattern configuration, then clients that stage
677
+ into conforming folders.
678
+
679
+ #### Managed-link rollback warning
680
+
681
+ **Do not roll a server back to v0.2.0 after an upgraded server has created,
682
+ repaired, or repointed any managed link.** Those operations write a v2
683
+ ownership record, which v0.2.0 cannot read. Rollback requires restoring the
684
+ complete pre-upgrade storage backup (including visible links and
685
+ `.swarm-deploy/links`), or keeping the upgraded server in place.
686
+
687
+ `swarm-deploy link` requires an upgraded server; v0.2.0 does not recognize
688
+ link control requests.
689
+
690
+ #### Overlapping retries now get `FILE_BUSY`
691
+
692
+ **Behavior change.** A transfer ID may have only one in-flight commit
693
+ lifecycle. If a client (or a job runner that fans the same artifact out to
694
+ parallel workers) opens a second connection for a transfer that is still
695
+ verifying or committing, that second connection is now rejected with
696
+ `FILE_BUSY` instead of being admitted alongside the first. Sequential retries,
697
+ resumes, and verified reconnects are unaffected, and so is a retry issued after
698
+ the previous attempt failed — the server releases the transfer before running
699
+ its observational `onFailure`, so a slow callback never blocks the retry.
700
+
701
+ Audit any automation that uploads the same artifact from more than one worker
702
+ at a time, and treat `FILE_BUSY` on an upload as "retry after the in-flight
703
+ attempt finishes" rather than a permanent failure.
704
+
705
+ #### Enabling or changing patterns
706
+
707
+ A commit record's release identity is durable and is never rewritten in place.
708
+ Enabling patterns, or editing one that an in-flight upload already matched,
709
+ therefore changes the identity a retry computes for bytes that are already
710
+ committed, and the server fails closed rather than relabelling them:
711
+
712
+ - A create-only name whose record carries a different release (or none) is no
713
+ longer recognised as the same commit, so the retry is rejected with
714
+ `FILE_EXISTS`.
715
+ - A replaceable name retried under the same transfer ID with a different
716
+ release is rejected as a release-identity conflict.
717
+
718
+ Drain or complete in-flight uploads before enabling or changing
719
+ `artifactPatterns`. Uploads started after the change are unaffected, and a
720
+ rejected legacy retry is resolved by re-uploading under a new transfer (new
721
+ content or a new staged name), not by editing records on disk.
722
+
723
+ #### Session records and rollback
724
+
725
+ A resumable session is written at on-disk version 3 only when it carries a
726
+ `sourceParent`; a session without one is written at version 2, exactly as an
727
+ older server would. The new server reads both. An older server cannot read
728
+ version 3, so only parent-bearing resumable sessions are lost to a downgrade:
729
+ they cannot resume and must be re-uploaded. Draining in-flight uploads before
730
+ rolling a server back avoids this entirely.
731
+
732
+ Commit-record compatibility with older code is covered only by what this
733
+ version's tests assert: an older record without release coordinates is read by
734
+ this version and is not count or version rotated. Whether older code tolerates
735
+ the release coordinates this version writes is untested.
736
+
737
+ ## Managed symlinks
738
+
739
+ Managed symlinks support both automatic newest-match aliases and explicit
740
+ client promotions. Configure either form with repeatable `--symlink` options:
741
+
742
+ ```sh
743
+ swarm-deploy server \
744
+ --seed-file ./server.seed \
745
+ --storage /srv/artifacts \
746
+ --allow-key <64-lower-hex> \
747
+ --max-file-bytes 1073741824 \
748
+ --max-staging-bytes 4294967296 \
749
+ --symlink '/^\d+\.\d+\.\d+$/' newest \
750
+ --symlink '/^app-\d+\.\d+\.\d+\.tar\.gz$/'
751
+ ```
752
+
753
+ The two-argument rule automatically reconciles `newest` to the newest matching
754
+ artifact. The one-argument rule is a target allowlist: it creates no link until
755
+ an authenticated client explicitly chooses a matching current artifact:
756
+
757
+ ```sh
758
+ swarm-deploy link --seed-file ./client.seed --server-key "$SERVER_KEY" \
759
+ app-2.4.1.tar.gz latest-app
760
+
761
+ # app-2.4.1.tar.gz is the tested target
762
+ # output: latest-app -> app-2.4.1.tar.gz LINKED
763
+ ```
764
+
765
+ Runtime equivalent:
766
+
767
+ ```js
768
+ const server = new Server({
769
+ seed,
770
+ storageDir: '/srv/artifacts',
771
+ allowedKeys,
772
+ maxFileBytes: 1024 ** 3,
773
+ maxStagingBytes: 4 * 1024 ** 3,
774
+ symlinks: [
775
+ { selector: '/^\\d+\\.\\d+\\.\\d+$/', name: 'newest' },
776
+ { selector: '/^app-\\d+\\.\\d+\\.\\d+\\.tar\\.gz$/' }
777
+ ]
778
+ })
779
+ ```
780
+
781
+ Selectors are exact safe managed-artifact basenames or unflagged `/regex/`
782
+ expressions. Automatic selection uses only validated managed commit records.
783
+ The newest `committedAt` wins, tie-broken by transfer ID then name; replacement
784
+ history never matches, and a rule is dormant until a target exists. Automatic
785
+ link names are unique and reserved even while their rule is dormant, so a
786
+ manual request cannot claim one.
787
+
788
+ Manual targets must be current managed top-level files or directories, must
789
+ match a one-argument rule, and cannot be history names. Manual link names are
790
+ safe top-level basenames and cannot equal the target, an automatic link name,
791
+ or any managed artifact name. A missing destination may be created, and an
792
+ existing manual managed link may be repointed. An unmanaged file, directory,
793
+ unrecorded symlink, changed symlink, or foreign ownership record is never
794
+ replaced, moved, or deleted: the operation fails closed.
795
+
796
+ Manual links retain their selected target across restart and automatic
797
+ reconciliation. Removing their authorization rule prevents future requests
798
+ that need that rule but does not remove or rewrite the existing link.
799
+ Automatic and manual targets are pinned against age, count, SemVer, and quota
800
+ retention and still count toward those limits. Reconciliation and manual
801
+ mutation run under the storage-root lease, and uploads cannot claim any
802
+ persisted managed-link name.
803
+
804
+ There is no unlink RPC. To remove a managed link safely, stop the server,
805
+ remove the visible symlink and its matching
806
+ `.swarm-deploy/links/<sha256(link-name)>.json` ownership record, then restart
807
+ the server. Never edit the ledger while the server runs. Removing all automatic
808
+ rules intentionally reconciles proven stale automatic links away; malformed or
809
+ conflicting ledgers still fail closed on the default symlink-capable storage.
810
+
811
+ A safe promotion workflow is:
812
+
813
+ 1. Upload a versioned artifact such as `app-2.4.1.tar.gz`.
814
+ 2. QA that exact version through its deployed versioned URL.
815
+ 3. Run `swarm-deploy link app-2.4.1.tar.gz latest` to point `latest` at the
816
+ tested artifact.
817
+
818
+ The explicit link operation is a control request, not an upload. It supports
819
+ files and directories, is idempotent, and does not invoke deployment hooks.
820
+ `symlinks` requires `StorageAdapter.symlink` and `readlink`; the Node and Bare
821
+ default adapters provide them; a custom adapter without symlink rules stays
822
+ source-compatible.
823
+
824
+ ## Deployment hooks
825
+
826
+ Hooks let a trusted server operator run code around the commit. They are
827
+ server-side only, run in the server process with its privileges, and receive no
828
+ seeds, secret keys, TAR bytes, or session material.
829
+
830
+ ### Lifecycle points
831
+
832
+ - `beforeCommit(context)`: after verification, before any commit mutation. For a
833
+ file, `path` is the verified `.part` staging file; for a directory, `path` is
834
+ the verified `.tree` staging directory. Its failure aborts the commit.
835
+ - `afterCommit(context)`: after the artifact is durably committed and configured
836
+ links are reconciled, before the terminal success reply. For a directory,
837
+ `path` is the committed directory tree. Its failure fails the upload even
838
+ though the artifact is already stored. When it is configured, post-commit
839
+ rotation is deferred until it succeeds (see "Immediate effect" above).
840
+ - `onFailure(context)`: once per failed connection whose metadata was decoded,
841
+ after the client has been answered.
842
+
843
+ Callbacks may return `void` or a promise, are called with no receiver, and
844
+ receive a frozen context. The artifact is
845
+ `{ name, kind, size, sha256, transferId, entryCount?, sourceParent?, release? }`,
846
+ where `entryCount` is present only for directories and `release` is
847
+ `{ series, version? }` when patterns are configured. These descriptive fields
848
+ come from decoded offer metadata, not yet from verified content.
849
+
850
+ | Callback | Extra context |
851
+ | -------------- | ---------------------------------------------------------------------------- |
852
+ | `beforeCommit` | `path` (verified staging file or tree), `resumed`, `alreadyCommitted: false` |
853
+ | `afterCommit` | `path` (committed artifact), `resumed`, `alreadyCommitted` |
854
+ | `onFailure` | `path` (`string \| null`), `phase`, `resumed`, `alreadyCommitted`, `error` |
855
+
856
+ `resumed` is `true` when the server admitted the connection as `RESUME` or
857
+ `VERIFIED`. Closing the server aborts the wait; a callback that outlives the
858
+ abort continues detached and its later result is ignored.
859
+
860
+ There is no hook timeout, and a hung callback holds more than its own
861
+ connection. On a fresh or resumed upload it also holds a `maxActiveUploads`
862
+ slot and the session's staging reservation for as long as it runs, so enough
863
+ simultaneously hung callbacks stop the server from admitting new uploads
864
+ (`ACTIVE_UPLOAD_LIMIT`) until the server is closed and the waits are aborted.
865
+ Only the already-committed path holds no upload slot. Write callbacks that
866
+ finish or throw; bounded hook timeout and anti-spam controls are tracked in
867
+ issue #8.
868
+
869
+ The **lifecycle** hooks `beforeCommit` and `afterCommit` never run concurrently
870
+ for one transfer. Once a transfer ID authenticates, that connection owns it for
871
+ the whole verify/commit sequence; a second connection offering the same transfer
872
+ ID — fresh, resumed, verified, or already committed — is rejected with
873
+ `FILE_BUSY` before any lifecycle hook runs, and that rejection is reported to
874
+ `onFailure` once with `phase: 'offer'`.
875
+
876
+ `onFailure` is observational and is **not** covered by that guarantee. A failed
877
+ connection hands the transfer back before awaiting its `onFailure`, because the
878
+ callback only reports an outcome and the connection has already finished
879
+ mutating state. A slow callback must not reject the client's legitimate retry,
880
+ so a retry of the same transfer can be admitted — and can run its own
881
+ `beforeCommit`/`afterCommit` — while the previous `onFailure` is still running.
882
+ Each connection still calls `onFailure` at most once, so write it to tolerate
883
+ overlapping with the next attempt and key any external effect on
884
+ `artifact.transferId`.
885
+
886
+ Hooks for **different** transfers always run concurrently.
887
+
888
+ ### Invocation sequences
889
+
890
+ Fresh upload:
891
+
892
+ 1. Offer inspected and admitted (`ACCEPT`), TAR received.
893
+ 2. Verification.
894
+ 3. `beforeCommit` (`resumed: false`).
895
+ 4. Commit.
896
+ 5. `afterCommit` (`resumed: false`, `alreadyCommitted: false`).
897
+ 6. `COMMITTED`.
898
+
899
+ Partial resume (`RESUME`): the same steps, with `resumed: true` in both hooks.
900
+
901
+ Verified reconnect (`VERIFIED`): the staged data was already verified, so the
902
+ server re-reads it, calls `beforeCommit` again with `resumed: true`, commits, and
903
+ calls `afterCommit` with `resumed: true`. A previous connection may have ended
904
+ after `beforeCommit` returned but before the commit finished.
905
+
906
+ Already committed: identical content is detected during offer inspection, so
907
+ there is no transfer, verification, or `beforeCommit`. The server calls
908
+ `afterCommit` with `resumed: false` and `alreadyCommitted: true`, then replies
909
+ `ALREADY_COMMITTED`. A deferred retention pass follows only when this process
910
+ committed that transfer and its `afterCommit` has not yet succeeded. This path
911
+ is reached only while the artifact still exists; see the
912
+ [retry caveat](#artifact-patterns-and-rotation).
913
+
914
+ Failure sequences:
915
+
916
+ - `beforeCommit` throws: nothing is committed and the verified session remains.
917
+ The client receives `HOOK_FAILED`; `onFailure` runs (`phase: 'beforeCommit'`).
918
+ A retry reconnects as `VERIFIED` and calls `beforeCommit` again.
919
+ - `afterCommit` throws on a fresh or resumed upload: the artifact **stays
920
+ durably committed** and its session is retired. The client receives
921
+ `HOOK_FAILED`; `onFailure` runs (`phase: 'afterCommit'`, final path). Unless
922
+ retention removed the artifact first (see the
923
+ [retry caveat](#artifact-patterns-and-rotation)), a retry takes the
924
+ already-committed path and calls `afterCommit` with `alreadyCommitted: true`;
925
+ otherwise it is a fresh upload.
926
+ - `afterCommit` throws on an already-committed retry: the offer is rejected with
927
+ `HOOK_FAILED` and a later retry repeats `afterCommit`, subject to the same
928
+ retry caveat.
929
+ - A failure after both hooks succeeded (for example the terminal reply cannot be
930
+ written) does not call `onFailure`.
931
+
932
+ Retries therefore can call hooks more than once for one transfer. **Make hooks
933
+ idempotent and key external side effects on `artifact.transferId`.** Treat
934
+ `afterCommit` as at-least-once: record the transfer ID when the deployment step
935
+ finishes and skip repeated calls.
936
+
937
+ ### onFailure
938
+
939
+ `phase` is one of `offer`, `transfer`, `verification`, `beforeCommit`, `commit`,
940
+ or `afterCommit`. `offer` covers every failure before a staging path exists:
941
+ metadata or transfer-ID problems, unmatched patterns, file-size, capacity and
942
+ destination rejections, and inspection errors.
943
+
944
+ An `offer`-phase context is built from decoded metadata **before** the transfer
945
+ ID is authenticated, and it is also reported when that authentication is exactly
946
+ what failed. Its `transferId`, `name`, `size`, `sha256`, and `sourceParent` are
947
+ therefore attacker-chosen values from an allowlisted key, not verified identity.
948
+ Use them for logging and alerting only; never as audit records or idempotency
949
+ keys. Every later phase runs after authentication, so only `offer` carries this
950
+ caveat.
951
+
952
+ `error` is the original failure. For an exception thrown by `beforeCommit` or
953
+ `afterCommit`, hooks receive the **raw thrown value** (not a wrapper) because
954
+ hooks are trusted. The client and server events see only the stable wire code
955
+ `HOOK_FAILED` with a fixed message, so callback text never reaches the wire. Other
956
+ failures arrive as `SwarmDeployError` (or raw storage errors).
957
+ A failing `onFailure` is logged as a secondary warning and never replaces the
958
+ original failure.
959
+
960
+ `path` by phase:
961
+
962
+ | Situation | `path` |
963
+ | ------------------------------------------------------------------ | ------------------------------------------------------- |
964
+ | `offer` | `null` |
965
+ | `transfer`; fresh-upload `verification` | `<storage>/.swarm-deploy/staging/<transferId>.tar.part` |
966
+ | `beforeCommit`, `commit`; `verification` of a `VERIFIED` reconnect | `<storage>/.swarm-deploy/staging/<transferId>.part` |
967
+ | `afterCommit` (including already committed) | `<storage>/<name>` |
968
+
969
+ ### Hook modules
970
+
971
+ `--hooks <module>` accepts `.js`, `.mjs`, and `.cjs`; any other extension is
972
+ rejected at startup with exit code `2` before the module is loaded. ESM and
973
+ CommonJS examples:
974
+
975
+ ```js
976
+ // hooks.mjs
977
+ export async function afterCommit({ artifact, path, alreadyCommitted }) {
978
+ // Idempotent: keyed by artifact.transferId.
979
+ await deploy(artifact.transferId, path)
980
+ }
981
+
982
+ export function onFailure({ phase, artifact, error }) {
983
+ console.error('deploy failed', phase, artifact.transferId, error)
984
+ }
985
+ ```
986
+
987
+ ```js
988
+ // hooks.cjs
989
+ module.exports = {
990
+ beforeCommit({ artifact, path }) {
991
+ // Return or throw; a throw rejects the upload with HOOK_FAILED.
992
+ },
993
+ async afterCommit({ artifact, path }) {
994
+ await deploy(artifact.transferId, path)
995
+ }
996
+ }
997
+ ```
998
+
999
+ Loader rules:
1000
+
1001
+ - Only the own properties `beforeCommit`, `afterCommit`, and `onFailure` are
1002
+ read. Other exports (helpers, configuration) are ignored, including a
1003
+ harmless non-object default export.
1004
+ - Named exports take precedence over callbacks on a default export object; a
1005
+ named export set to `undefined` does not override it.
1006
+ - A hook name that is present must be a function, the callbacks must be plain
1007
+ own properties, and at least one must exist; otherwise startup fails.
1008
+ - One level of compiled-CommonJS interop (`__esModule` with `default`) is
1009
+ unwrapped; deeper nesting is ignored.
1010
+ - Modules are loaded once with `import()` and cached by the runtime. Top-level
1011
+ code runs with the server's privileges during startup, so the module is fully
1012
+ trusted.
1013
+
1014
+ `ServerOptions.hooks` is stricter: it must be a hook object whose own keys are
1015
+ only the three callbacks, every callback a function. Unknown keys and
1016
+ non-function values throw `PROTOCOL_INVALID`. The object is snapshotted at
1017
+ construction, so later mutation has no effect.
349
1018
 
350
1019
  ## Runtime API
351
1020
 
@@ -378,6 +1047,16 @@ newlines. Blank lines and lines beginning with `#` are ignored; malformed or
378
1047
  duplicate keys throw. It returns a `Set<string>` suitable for
379
1048
  `ServerOptions.allowedKeys`.
380
1049
 
1050
+ `fixedSeriesKey(template)` returns the series a template without `{series}`
1051
+ persists, so an operator reading `fixed-…` in a commit sidecar, retention log,
1052
+ or hook context can identify which configured pattern owns it:
1053
+
1054
+ ```ts
1055
+ import { fixedSeriesKey } from 'swarm-deploy'
1056
+
1057
+ const owner = patterns.find((template) => fixedSeriesKey(template) === record.release?.series)
1058
+ ```
1059
+
381
1060
  ### Server
382
1061
 
383
1062
  ```ts
@@ -423,6 +1102,30 @@ Optional operational limits and policies:
423
1102
  - `maxConnections`, `maxActiveUploads`, `idleTimeout`
424
1103
  - `cleanupInterval`, `resumeTtl`, `minFreeBytes`
425
1104
  - `maxAge`, `maxStorageBytes`, `replaceNames`
1105
+ - `artifactPatterns?: Iterable<string>`, `maxCount?: number`,
1106
+ `maxVersions?: number`, and `versionGranularity?: 'major' | 'minor'`; see
1107
+ [Artifact patterns and rotation](#artifact-patterns-and-rotation).
1108
+ - `symlinks?: Iterable<SymlinkRule>` with automatic
1109
+ `{ selector, name }` and manual-authorization `{ selector }` entries; see
1110
+ [Managed symlinks](#managed-symlinks).
1111
+ - `hooks?: ServerHooks | null`; see [Deployment hooks](#deployment-hooks).
1112
+
1113
+ The root package exports the `ServerHooks`, `BeforeCommitContext`,
1114
+ `AfterCommitContext`, `HookFailureContext`, `HookFailurePhase`, `HookArtifact`,
1115
+ `ReleaseCoordinates`, and `VersionGranularity` types:
1116
+
1117
+ ```ts
1118
+ import type { ServerHooks } from 'swarm-deploy'
1119
+
1120
+ const hooks: ServerHooks = {
1121
+ async afterCommit({ artifact, path, alreadyCommitted }) {
1122
+ // Idempotent: skip work already recorded for artifact.transferId.
1123
+ },
1124
+ onFailure({ phase, path, error }) {
1125
+ console.error(phase, path, error)
1126
+ }
1127
+ }
1128
+ ```
426
1129
 
427
1130
  Advanced integration and test seams:
428
1131
 
@@ -451,11 +1154,31 @@ await client.close()
451
1154
 
452
1155
  `ClientOptions.seed` accepts a Buffer or canonical lowercase 64-character hex
453
1156
  string. `serverPublicKey` is a Buffer produced by `parsePublicKey`. Optional
454
- values are `connectTimeout`, `idleTimeout`, `dht`, `dhtFactory`, and `logger`.
1157
+ values are `connectTimeout`, `idleTimeout`, `includeSourceParent`, `dht`,
1158
+ `dhtFactory`, and `logger`.
455
1159
 
456
1160
  `connectTimeout` defaults to 30 seconds. `idleTimeout` defaults to 60 seconds.
1161
+ `includeSourceParent` defaults to `true`; set it to `false` to suppress the
1162
+ [source parent](#source-parent) for every upload the client makes.
457
1163
  Calling `close()` aborts pending work, closes active sockets, and is idempotent.
458
1164
 
1165
+ ### Link results
1166
+
1167
+ `Client.link(target, name)` resolves to:
1168
+
1169
+ ```ts
1170
+ interface ClientLinkResult {
1171
+ target: string
1172
+ name: string
1173
+ status: 'LINKED' | 'UNCHANGED'
1174
+ }
1175
+ ```
1176
+
1177
+ The client validates both names before connecting. Link operations share the
1178
+ client's pinned server identity, authenticated client identity, connection and
1179
+ idle timeouts, serialized operation queue, cancellation, and `close()`
1180
+ behavior with uploads.
1181
+
459
1182
  ### Upload results
460
1183
 
461
1184
  A direct file resolves to:
@@ -463,36 +1186,18 @@ A direct file resolves to:
463
1186
  ```ts
464
1187
  interface UploadResult {
465
1188
  status: 'COMMITTED' | 'ALREADY_COMMITTED'
1189
+ kind: 'file' | 'directory'
466
1190
  name: string
467
1191
  size: number
468
1192
  digest: Buffer
469
1193
  transferId: Buffer
1194
+ entryCount?: number
470
1195
  }
471
1196
  ```
472
1197
 
473
- A directory resolves to:
474
-
475
- ```ts
476
- interface BatchUploadResult {
477
- status: 'COMMITTED' | 'FAILED'
478
- results: Array<
479
- | UploadResult
480
- | {
481
- name: string
482
- status: ErrorCode
483
- reason?: string
484
- }
485
- >
486
- skipped: Array<{
487
- name: string
488
- path: string
489
- reason: SkippedUploadReason
490
- }>
491
- }
492
- ```
493
-
494
- Directory members are processed sequentially. A failed member does not prevent
495
- later members from being attempted.
1198
+ Files and directories share the same result shape. For a directory, `size` is
1199
+ aggregate payload bytes and `entryCount` counts stored entries (files and
1200
+ directories).
496
1201
 
497
1202
  ### Events
498
1203
 
@@ -510,12 +1215,19 @@ Server events:
510
1215
  - `connection`, `connection-open`, `connection-close`: authenticated connection
511
1216
  lifecycle and current count.
512
1217
  - `offer`: accepted, resumed, reset, rejected, or already-committed state.
513
- - `progress`: durable TAR bytes received and total TAR bytes.
1218
+ Includes artifact `kind`.
1219
+ - `progress`: durable TAR bytes received and total TAR bytes. Includes artifact
1220
+ `kind`.
514
1221
  - `verification`: started, succeeded, or failed.
515
1222
  - `commit`: succeeded or failed.
516
1223
  - `recovery`: startup, per-journal, corruption, resumable, and completion
517
1224
  outcomes.
518
- - `retention`: startup, scheduled, manual, commit, or post-commit outcomes.
1225
+ - `retention`: startup, scheduled, manual, commit, or post-commit outcomes,
1226
+ with `expiredSessions`, `scrubbed`, `ageDeleted`, `countDeleted`,
1227
+ `versionDeleted`, and `storageDeleted` counters.
1228
+ - `link`: one terminal manual-link outcome with `target`, `name`, client
1229
+ fingerprint, and status `linked`, `unchanged`, `rejected`, or `failed`.
1230
+ Rejected and failed outcomes also include a stable `reason`.
519
1231
  - `failure`: stable failure code and peer fingerprint.
520
1232
  - `listening`: local server-key fingerprint.
521
1233
  - `close`: closed or failed outcome.
@@ -527,8 +1239,12 @@ Client events:
527
1239
  - `progress`: cumulative TAR `bytesSent` and full `totalBytes`, including a
528
1240
  durable resume offset.
529
1241
  - `verification`, `commit`
530
- - `result`: direct result or per-file/aggregate directory result.
531
- - `skipped`: skipped directory member and stable reason.
1242
+ - `result`: terminal upload result with artifact `kind`.
1243
+ - `link`: after the request is written, `requested` with `final: false`,
1244
+ followed by exactly one `LINKED`, `UNCHANGED`, or stable error code with
1245
+ `final: true`. A request-write failure after connection can emit only the
1246
+ terminal failed event. Local argument validation, an already closed or
1247
+ cancelled client, and connection failure can reject before any `link` event.
532
1248
  - `failure`, `close`
533
1249
 
534
1250
  Use the exported `ServerEventMap`, `ClientEventMap`, `ServerEventName`, and
@@ -584,11 +1300,23 @@ try {
584
1300
  }
585
1301
  ```
586
1302
 
1303
+ `HOOK_FAILED` is the stable code for a failed `beforeCommit` or `afterCommit`
1304
+ callback. Its message is fixed and the original exception is available only as
1305
+ `cause` and to `onFailure`.
1306
+
587
1307
  Stable codes include authentication and server-key rejection, invalid
588
1308
  configuration and protocol records, file and staging limits, disk reserve,
589
1309
  filename and replacement conflicts, checksum failures, connection or upload
590
- timeouts, aborts, commit failures, and cleanup failures. Import `ERRORS` rather
591
- than matching exception messages.
1310
+ timeouts, aborts, commit failures, cleanup failures, and managed-link failures
1311
+ (`LINK_NOT_ALLOWED`, `LINK_TARGET_NOT_FOUND`, `LINK_CONFLICT`, `LINK_FAILED`,
1312
+ `UNSUPPORTED_STORAGE`). Manual requests use `LINK_NOT_ALLOWED` for policy,
1313
+ self-link, and automatic-name rejection; `LINK_TARGET_NOT_FOUND` for an absent
1314
+ current managed target; `LINK_CONFLICT` for managed-namespace or unmanaged-path
1315
+ collisions; and `LINK_FAILED` for other link-operation failures. A link
1316
+ reconciliation failure after a durable commit behaves like other post-commit
1317
+ deployment failures: the artifact stays committed, the client receives a
1318
+ stable failure, and an already-committed retry reruns reconciliation before
1319
+ succeeding. Import `ERRORS` rather than matching exception messages.
592
1320
 
593
1321
  ## Production operations
594
1322
 
@@ -596,8 +1324,10 @@ than matching exception messages.
596
1324
  - Restrict the server seed and storage root to that account.
597
1325
  - Supervise the process and wait for the final `ready` line before marking it
598
1326
  healthy.
599
- - Restart with the same seed, allowlist, limits, replacement names, and storage
600
- root so interrupted sessions and commit journals can recover.
1327
+ - Upgrade servers before clients; see
1328
+ [Rollout and compatibility](#rollout-and-compatibility).
1329
+ - Restart with the same seed, allowlist, limits, replacement names, artifact
1330
+ patterns, rotation limits, hooks module, and storage root so interrupted sessions and commit journals can recover.
601
1331
  - Alert on nonzero CLI exits and failed authentication, recovery, verification,
602
1332
  commit, retention, and cleanup events.
603
1333
  - Never edit `.swarm-deploy/` while the server is running.
@@ -617,7 +1347,7 @@ untracked `dist/` and `.test-dist/`. Tests generate binary payloads in temporary
617
1347
  directories and use an isolated local HyperDHT testnet, not the public DHT.
618
1348
 
619
1349
  ```sh
620
- npm ci
1350
+ npm install
621
1351
  npm run build
622
1352
  npm run build:test
623
1353
  npm run test:types