@unconfirmed/sui-effect 0.1.1 → 0.1.2

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 (58) hide show
  1. package/AGENTS.md +34 -11
  2. package/CHANGELOG.md +46 -0
  3. package/LLMS.md +953 -301
  4. package/README.md +171 -9
  5. package/dist/domain/bcs.d.ts.map +1 -1
  6. package/dist/domain/bcs.js +7 -0
  7. package/dist/domain/bcs.js.map +1 -1
  8. package/dist/domain/errors.d.ts +101 -5
  9. package/dist/domain/errors.d.ts.map +1 -1
  10. package/dist/domain/errors.js +158 -8
  11. package/dist/domain/errors.js.map +1 -1
  12. package/dist/domain/executed.d.ts +69 -0
  13. package/dist/domain/executed.d.ts.map +1 -1
  14. package/dist/domain/executed.js +196 -5
  15. package/dist/domain/executed.js.map +1 -1
  16. package/dist/domain/schemas.d.ts +27 -1
  17. package/dist/domain/schemas.d.ts.map +1 -1
  18. package/dist/domain/schemas.js +20 -2
  19. package/dist/domain/schemas.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/script.d.ts +1 -1
  25. package/dist/script.d.ts.map +1 -1
  26. package/dist/script.js +1 -1
  27. package/dist/script.js.map +1 -1
  28. package/dist/services/Script.d.ts +42 -0
  29. package/dist/services/Script.d.ts.map +1 -1
  30. package/dist/services/Script.js +69 -2
  31. package/dist/services/Script.js.map +1 -1
  32. package/dist/services/Signer.d.ts +32 -7
  33. package/dist/services/Signer.d.ts.map +1 -1
  34. package/dist/services/Signer.js +69 -10
  35. package/dist/services/Signer.js.map +1 -1
  36. package/dist/services/Sui.d.ts +42 -1
  37. package/dist/services/Sui.d.ts.map +1 -1
  38. package/dist/services/Sui.js +18 -4
  39. package/dist/services/Sui.js.map +1 -1
  40. package/dist/services/SuiCoreFake.d.ts +47 -4
  41. package/dist/services/SuiCoreFake.d.ts.map +1 -1
  42. package/dist/services/SuiCoreFake.js +193 -22
  43. package/dist/services/SuiCoreFake.js.map +1 -1
  44. package/dist/services/Tx.d.ts +627 -11
  45. package/dist/services/Tx.d.ts.map +1 -1
  46. package/dist/services/Tx.js +187 -8
  47. package/dist/services/Tx.js.map +1 -1
  48. package/dist/testing.d.ts +1 -0
  49. package/dist/testing.d.ts.map +1 -1
  50. package/dist/testing.js +9 -0
  51. package/dist/testing.js.map +1 -1
  52. package/dist/tx.d.ts +1 -1
  53. package/dist/tx.d.ts.map +1 -1
  54. package/dist/tx.js +1 -1
  55. package/dist/tx.js.map +1 -1
  56. package/docs/extensions.md +533 -8
  57. package/examples/extension-template/src/Escrow.ts +1 -1
  58. package/package.json +1 -1
@@ -198,6 +198,36 @@ nothing happened — answering `"not_applied"` would tell the documented retry
198
198
  idiom to send again. The `"not_applied"` default is for @unconfirmed/sui-effect's own
199
199
  taxonomy, not for yours.
200
200
 
201
+ **Tag strings are namespaced by whoever defined them, and sui-effect's are
202
+ not.** @unconfirmed/sui-effect's own tags are bare — `TransportError`,
203
+ `ObjectNotFound`, `DecodeError` — while an extension prefixes its own, so a
204
+ platform's tag is `partyos/PartyNotFound` or `EscrowNotFound` depending on the
205
+ convention that package chose. `Effect.catchTag` matches the string exactly, so
206
+ **copy the tag from the installed package**, never from a migration note or
207
+ from memory: a `catchTag("PartyNotFound")` against a package that ships
208
+ `partyos/PartyNotFound` compiles (the union is open at the string level in
209
+ neither direction you expect) or silently never fires, and a README that
210
+ disagrees with the class is the single most common conversion bug. When you
211
+ rename or re-prefix a tag, that is a breaking change and belongs in your
212
+ changelog with the old and the new string side by side.
213
+
214
+ **`DecodeError` carries a `kind`, and that is what to branch on.** `"type"` is
215
+ "this object is not of the type I asked for" — the one a read service answers
216
+ with a 404 or a `filter`. `"bytes"` is "the type matched and the BCS did not
217
+ parse", which is a layout mismatch between your package and the chain and must
218
+ never be swallowed. `"shape"` is a domain schema refusing an already-parsed
219
+ value. The `issue` string is for a human and its wording changes between
220
+ releases; branching on it is how a foreign-object 404 quietly starts hiding a
221
+ real decode bug.
222
+
223
+ **`SuiError.outcome` takes a phase.** The default (`"post-submit"`) answers
224
+ `"unknown"` for a tag it does not recognise, because after a submission an
225
+ unfamiliar error is not evidence that nothing was sent. In a `catchAll` that can
226
+ only be reached **before** a submission — validation, a build, a signature —
227
+ pass `{ phase: "pre-submit" }` and an unrecognised tag becomes `"not_applied"`,
228
+ which is true by construction there. `SuiError.isTaxonomy(error)` is the same
229
+ question one level lower.
230
+
201
231
  Do not invent an error for something the taxonomy already names. A node that
202
232
  could not be reached is a `TransportError`; bytes that did not decode are a
203
233
  `DecodeError`; a transaction that aborted on chain is an `ExecutionFailed`; a
@@ -558,7 +588,8 @@ out of fifty is not a failed read. Two idioms, and you should pick deliberately:
558
588
  `results.filter(Result.isSuccess).map((result) => result.success)`, or
559
589
  `Result.getOrElse(result, () => fallback)` per item, or a `Map` keyed by id so
560
590
  a caller can ask about one;
561
- - **hard** — every id must be there: `sui.getObjectsOrFail(ids, opts)`, which
591
+ - **hard** — every id must be there: `sui.getObjectsStrict(ids, opts)` (named
592
+ `getObjectsOrFail` before 0.1.2, and still reachable under that name), which
562
593
  fails with the first item's error (`ObjectNotFound`, `ObjectDeleted`,
563
594
  `ObjectUnavailable` or `DecodeError`) and otherwise hands back the objects in
564
595
  the order of the ids.
@@ -718,6 +749,66 @@ An extension whose two parties cannot both sign in one process — the sponsor i
718
749
  a remote service, the sender is a wallet — uses the explicit lifecycle instead:
719
750
  `Tx.build`, `Tx.sign`, hand the bytes over, `Tx.cosign`, `Tx.submit`.
720
751
 
752
+ ### A signer double, for tests
753
+
754
+ `Signer.fromSdkSigner` reads `toSuiAddress()` and `getKeyScheme()` **at
755
+ construction** and rejects a value that has neither, so a partial double is a
756
+ `TypeError` where it used to be `scheme: undefined` and silence. A double is
757
+ `Signer.remote`, which takes exactly what a signer is:
758
+
759
+ <!-- inline -->
760
+
761
+ ```ts
762
+ const doubleSigner = (address: SuiAddress): Signer =>
763
+ Signer.remote({
764
+ address,
765
+ scheme: "ED25519",
766
+ signTransaction: () => Effect.succeed("AAAA…" as string) // any non-empty string decodes
767
+ })
768
+ ```
769
+
770
+ For a test that actually submits, use a real keypair
771
+ (`Signer.fromKeypair(Ed25519Keypair.fromSecretKey(new Uint8Array(32).fill(7)))`):
772
+ the fake checks that the signatures cover the addresses the bytes name, and a
773
+ fabricated signature only passes the count check.
774
+
775
+ ### Sponsored by an external service
776
+
777
+ `Tx.run`'s `sponsor` and `Tx.cosign` both want a local `Signer`. A service that
778
+ co-signs **and submits** on your behalf — a relayer, a sponsorship API — has
779
+ neither, and `Tx.submit` is not the last step either, because the service sends
780
+ the bytes. The supported sequence is:
781
+
782
+ <!-- inline -->
783
+
784
+ ```ts
785
+ const built = yield* Tx.build(Tx.sponsored({ sender, gasOwner })(recipe), {
786
+ sender,
787
+ gasOwner
788
+ })
789
+ const signed = yield* Tx.sign(built, signer)
790
+ // The wire form: base64 bytes, and the serialized signature string the SDK
791
+ // produces. `signed.signatures[0]` is the sender's; the service adds its own.
792
+ const envelope = {
793
+ transactionBlockBytes: toBase64(signed.bytes),
794
+ signature: signed.signatures[0]!
795
+ }
796
+ const reply = yield* callTheSponsor(envelope)
797
+ // Two ways to end, and both are supported:
798
+ // the service returned an execute envelope — decode it and keep the accessors
799
+ const executed = yield* Executed.fromPartial(reply)
800
+ // or it returned only a digest — ask the chain yourself
801
+ const settled = yield* Tx.reconcile(signed)
802
+ ```
803
+
804
+ Three things to know. The digest does not change when the sponsor adds its
805
+ signature, so `signed.digest` is the digest to record and to reconcile by.
806
+ `Tx.reconcile(signed)` — passing the `Signed`, not the bare digest — is what
807
+ gets the evidence rules, so a service that never sent the bytes ends as
808
+ `NotApplied` rather than as an eternal `SubmissionUnknown`. And whatever the
809
+ service returns is a **reduced** envelope: see "Relay and sponsor envelopes"
810
+ below, and use `Executed.fromPartial` rather than `Schema.decodeUnknownSync`.
811
+
721
812
  ## 6. Layers
722
813
 
723
814
  Following the house convention: `layer(opts)` for the live one, `layerConfig`
@@ -956,6 +1047,35 @@ chain. What catches it is the chain itself: `Tx.build` stamps that id on the
956
1047
  transaction's expiration and a validator refuses bytes signed for another chain.
957
1048
  Register lazily when the assertion is what you want.
958
1049
 
1050
+ **Do not over-invest in testing the mismatch checks.** A consumer that reads
1051
+ both from one deployment manifest — `network: deployment.network` on the client
1052
+ and `chainId: deployment.chainIdentifier` on the registration — cannot make the
1053
+ network check and the chain-id check fire except by supplying an inconsistent
1054
+ pair on purpose. One test that a wrong `chainId` throws is worth having; a
1055
+ matrix over the combinations is testing the manifest, not the code.
1056
+
1057
+ **In a browser, `warm` throws into your import graph.** The natural place for a
1058
+ registration in an SPA is module scope, and a throw there takes down the whole
1059
+ module — no error boundary, no console line a user can act on, just a blank
1060
+ page. Two patterns work: register lazily and `await client.ext.$ready()` in a
1061
+ boot step that has somewhere to put the failure; or keep the `warm`
1062
+ registration and wrap it, exporting the error instead of throwing it:
1063
+
1064
+ <!-- inline -->
1065
+
1066
+ ```ts
1067
+ // sui-client.ts
1068
+ export let bootError: unknown
1069
+ export const client = (() => {
1070
+ try {
1071
+ return baseClient.$extend(escrow({ warm: { chainId } }))
1072
+ } catch (cause) {
1073
+ bootError = cause
1074
+ return baseClient.$extend(escrow({})) // lazy: every call rejects, nothing throws
1075
+ }
1076
+ })()
1077
+ ```
1078
+
959
1079
  **Thread the chain id through your registration options**, the way
960
1080
  `src/extension.ts` and `src/Platform.ts` both do, rather than relying on the
961
1081
  built-in table. It is what makes the face work on `devnet` and `localnet`, and
@@ -1269,7 +1389,7 @@ const feeCollector = sui.core
1269
1389
  Effect.flatMap((raw) =>
1270
1390
  decodeAddress(raw).pipe(
1271
1391
  Effect.mapError((issue) =>
1272
- new DecodeError({ expectedType: "SuiAddress", issue: issue.message })
1392
+ new DecodeError({ expectedType: "SuiAddress", kind: "shape", issue: issue.message })
1273
1393
  )
1274
1394
  )
1275
1395
  ),
@@ -1511,6 +1631,91 @@ test("a retryable transport failure re-sends the identical bytes", async () => {
1511
1631
  })
1512
1632
  ```
1513
1633
 
1634
+ ### Scripting a submit, and the traps in it
1635
+
1636
+ Four things about the fake decide whether a submit test means anything.
1637
+
1638
+ **`Tx.submit` asks `getTransaction` when it cannot get a clean answer.** A
1639
+ retryable transport failure, a timeout, anything that leaves the outcome open
1640
+ ends in `Tx.reconcile`, and `reconcile`'s first move is a `getTransaction` for
1641
+ the digest. So a script whose `getTransaction` is a `succeed` is saying "this
1642
+ transaction is on chain" — and a submit test that did not mean that gets a
1643
+ **success** out of a path it was trying to prove fails. Unless the test is
1644
+ modelling a landed transaction, script `getTransaction: [FakeOutcome.notFound()]`
1645
+ and let the evidence rules run.
1646
+
1647
+ **A sponsored submit needs `Tx.cosign` first.** The fake refuses a submission
1648
+ whose signatures do not cover the addresses the bytes name — by count, and by
1649
+ the addresses recovered from the signatures themselves — the way a validator
1650
+ does, with a gRPC `INVALID_ARGUMENT`. `Tx.submit` reports that one outright as a
1651
+ `TransportError { retryable: false, status: "INVALID_ARGUMENT" }` rather than
1652
+ reconciling it, because the node refused the request and nothing was executed.
1653
+ So a sponsored-flow test builds with `Tx.sponsored`, signs with the sender,
1654
+ **`Tx.cosign`s with the sponsor**, and only then submits; `Tx.run(recipe, {
1655
+ signer, sponsor })` does all of that itself. Assert it, too — the signature
1656
+ count is on the recorded call:
1657
+
1658
+ <!-- inline -->
1659
+
1660
+ ```ts
1661
+ const sent = yield* SuiTest.calls("executeTransaction")
1662
+ expect(sent).toHaveLength(1)
1663
+ expect((sent[0]!.options as { signatures: ReadonlyArray<string> }).signatures)
1664
+ .toHaveLength(2)
1665
+ ```
1666
+
1667
+ **`FakeOutcome.failWith` takes either reason shape.** The SDK's
1668
+ `SuiClientTypes.ExecutionError` is the wire shape — a top-level `message` and an
1669
+ `abortCode` **string** — and sui-effect's decoded `ExecutionReason` has a
1670
+ `bigint` `abortCode` and no `message`. Both are accepted and the second is
1671
+ encoded for you; anything that is neither throws where the fixture is written,
1672
+ naming both shapes, instead of failing a decode several calls later on an
1673
+ unrelated method.
1674
+
1675
+ **Which script slot drives the build's simulate.** `Tx.build` always simulates,
1676
+ and on the fake that simulate is the **resolver's**: it is recorded (so
1677
+ `SuiTest.calls("simulateTransaction")` counts it) and it is answered by
1678
+ `buildSimulate` when that script has entries, and otherwise by the ordered
1679
+ `simulate` script. So `simulate: [FakeOutcome.failWith(...)]` makes `Tx.build`
1680
+ and `Tx.run` fail with `SimulationFailed` the way a node would, and
1681
+ `buildSimulate` is the slot to use when a test needs the build's simulate and
1682
+ an explicit `sui.simulate` to answer differently.
1683
+
1684
+ **`layerTest` asserts the built-in chain id for `mainnet` and `testnet`.**
1685
+ It is `Sui.layerNoDeps`, the production layer, so a script that says
1686
+ `network: "mainnet"` and a `chainId` of its own fails to build with
1687
+ `NetworkMismatch`. Use `network: "localnet"` (the default, which asserts
1688
+ nothing) in fixtures, or the real bundled identifier for the network you named.
1689
+
1690
+ **The fake writes nothing to standard error, ever.** If your test output has a
1691
+ line in it, it came from your code or from Effect's logger, not from here.
1692
+
1693
+ ### Injecting a read failure
1694
+
1695
+ `FakeScript.getObject` is a list of `FakeOutcome`s consumed one per `getObject`,
1696
+ for the retry and fallback paths a script of *objects* cannot express:
1697
+ `FakeOutcome.transportError("UNAVAILABLE")` to drive `SuiCore`'s read retry,
1698
+ `FakeOutcome.notFound()` for an `ObjectNotFound`, `FakeOutcome.timeoutThen` for
1699
+ an interruption. A `succeed` entry — and an absent or exhausted script — serves
1700
+ the object map as usual. `SuiTest.scriptGetObject(outcomes)` sets it mid-test.
1701
+
1702
+ `FakeScript.balances` is keyed by **owner and coin type**: an entry with an
1703
+ `owner` answers only for that address, one without answers for any (which is
1704
+ what a pre-0.1.2 script meant), and an owner with no entry gets zero, the way a
1705
+ node answers.
1706
+
1707
+ ### The fixture package's `node_modules` is not your source
1708
+
1709
+ An isolated-consumer fixture — a directory with its own `package.json` that
1710
+ installs a packed tarball, the way `scripts/check-package.ts` builds one — is
1711
+ typechecked against **whatever tarball it last installed** the moment `test` is
1712
+ in the package `tsconfig`'s `include`. So a fixture left over from a previous
1713
+ release quietly typechecks your new code against the old library, and the
1714
+ checklist item "put `test` in `include`" turns into a stale pin. Either exclude
1715
+ the fixture directory from `include` (it has its own `tsconfig`), or re-pack and
1716
+ re-install it as a step of `check`, with `bun install --force` when the filename
1717
+ did not change.
1718
+
1514
1719
  ### Your own fake beside the harness
1515
1720
 
1516
1721
  `layerExtensionTest` composes: the first argument is *your* layer, and your
@@ -1546,6 +1751,9 @@ the harness's.
1546
1751
 
1547
1752
  ### What the fake does and does not do
1548
1753
 
1754
+ - **`getObject` can be scripted to fail.** `FakeScript.getObject` /
1755
+ `SuiTest.scriptGetObject` inject a transport failure, a miss or a timeout into
1756
+ a read; the object map serves everything else.
1549
1757
  - **Its client supports `$extend`.** `SuiCoreFake`'s handle exposes `client`, a
1550
1758
  `ClientWithCoreApi` that implements `$extend`, so a derived Promise face can
1551
1759
  be tested exactly the way a consumer writes it — `fake.client.$extend(escrow(options))`
@@ -1621,6 +1829,28 @@ field; there is nothing to change in your errors. That is
1621
1829
  what makes a structured log of a failed run useful, and it is a reason to give
1622
1830
  every field of an error a schema rather than stuffing detail into a string.
1623
1831
 
1832
+ **A wrapper error must carry what it wrapped.** `Script.exitCode` honours a
1833
+ declared `outcome` **before** the tag, which is what makes an extension's errors
1834
+ land on the right exit code — and what makes an error that wraps one and forgets
1835
+ to copy the `outcome` land on the wrong one. `catchAll(cause => new MyError({
1836
+ cause }))` around a `Tx.run` turns a charged `ExecutionFailed` (exit 5, do not
1837
+ retry) into an unclassified error (exit 1) or, worse, into a default
1838
+ `not_applied` (exit 4, "safe to retry") and the wrapper retries a transaction
1839
+ that already ran. Copy both fields: `outcome: SuiError.outcome(cause)` and the
1840
+ digest from `digestOf(cause)`, or do not wrap at all. The same applies to a CLI
1841
+ that catches at the command boundary and re-raises its own error type.
1842
+
1843
+ **A CLI with its own argv parser does not need `Script.run`.** `Script.run` is a
1844
+ whole entrypoint — it builds the layer, forks the root fiber, installs signal
1845
+ handlers and exits — and a commander program with twenty subcommands has all of
1846
+ that. What it still wants is the two things `run` does at the end:
1847
+ `Script.report(exit, { stderr?, journal? })` writes one diagnostic line per
1848
+ failure (with a `SubmissionUnknown`'s bytes) plus every unresolved journal
1849
+ entry, and returns the exit code. Assign it to `process.exitCode` rather than
1850
+ calling `process.exit`, so buffered output flushes, and pass the journal the
1851
+ program actually ran with — reading the default reference would look in the
1852
+ process-wide in-memory journal and find nothing.
1853
+
1624
1854
  Two of those deserve a second look. `UnexpectedEffects` — what
1625
1855
  `executed.expectCreated(type)` fails with — is **applied**, exit 5: it can only
1626
1856
  come from an `Executed`, so the transaction ran and gas was charged and only the
@@ -1642,6 +1872,18 @@ functions beside it is a different job, and the order that works is this.
1642
1872
  fine, the face maps it either way. Write the interface before you move any
1643
1873
  code: it is the only artefact the conversion is reviewed against.
1644
1874
 
1875
+ **Grep for the shapes the library replaces, not only for names.** Two are
1876
+ worth a pattern each. `\.find\(.*type\??\.includes\(` followed by a
1877
+ `throw` — "find the created object whose type contains `::Receipt`, or blow
1878
+ up" — is exactly `executed.expectCreated(type)`, which compares normalized
1879
+ struct tags and fails with `UnexpectedEffects { digest, expected, found }`.
1880
+ And `@<scope>/` imports resolved against the **published `exports` map**
1881
+ rather than against remembered call sites: regenerate the consumer-edit
1882
+ table with `grep -rn "@scope/" <consumer>/src` and one row per removed
1883
+ subpath export, because a re-export dropped from a subpath (`/party` no
1884
+ longer re-exporting `TxThunk`) breaks every importer while no facade call
1885
+ site changed at all.
1886
+
1645
1887
  **Grep for captured aliases, not only for dotted calls.** A consumer that
1646
1888
  writes `const party = client.miso.party` and then `party.join(...)` does not
1647
1889
  appear in a search for `client.miso.party.join`, and a namespace that looks
@@ -1687,13 +1929,13 @@ conversion is mechanical except where the behaviour deliberately changed.
1687
1929
  | `GraphQLUnavailableError` | `GraphQLUnavailable { method, reason }`, in the taxonomy, outcome `not_applied` — what `SuiGraphQL.layerUnavailable` rejects every call with |
1688
1930
  | `DeploymentError` | your own `<pkg>/DeploymentError` (the template's `EscrowUnsupportedNetwork`), a `Schema.TaggedError` declaring `outcome: "not_applied"`, failed from a `Layer.unwrap` that reads `sui.network` (section 6) |
1689
1931
  | `ObjectNotFoundError` | `ObjectNotFound`, plus `ObjectDeleted` and `ObjectUnavailable` from the SDK's own `reason` |
1690
- | `ObjectTypeMismatchError` | `DecodeError { objectId, expectedType, issue }` from the bridge's tag check |
1932
+ | `ObjectTypeMismatchError` | `DecodeError { objectId, expectedType, kind: "type", issue }` from the bridge's tag check. Branch on `kind`, which is `"type"` here and `"bytes"` for a BCS failure, so a consumer that used to catch a type mismatch to 404 a foreign object keeps doing exactly that and stops swallowing real decode bugs |
1691
1933
  | `SuiRpcError { operation }` | `TransportError { method }`. For your own HTTP or GraphQL calls, `TransportError.fromUnknown(method, cause, retryable?)` classifies the status and the retryability the way `SuiCore` does — do not hand-build the three fields |
1692
1934
  | `BcsDecodeError` | `DecodeError` |
1693
1935
  | `TransactionFailedError { digest, status }` | `ExecutionFailed { digest, reason, command, effects }` |
1694
1936
  | `getObjectContent` | `sui.getObject(id)` — with no schema, `content` is the raw bytes |
1695
1937
  | `getOptionalObjectContent` | `sui.getObjectOption` — `None` for missing and deleted, which is also the blessed way to express domain absence |
1696
- | `getObjectsContent` | `sui.getObjects` — chunked, integrity-checked, a per-item `Result` instead of silently dropping errored ids; `sui.getObjectsOrFail` when every id must be there |
1938
+ | `getObjectsContent` | `sui.getObjects` — chunked, integrity-checked, a per-item `Result` instead of silently dropping errored ids; `sui.getObjectsStrict` (the deprecated `getObjectsOrFail`) when every id must be there |
1697
1939
  | `listDynamicFields` | `sui.streamDynamicFields` |
1698
1940
  | filtering dynamic fields by key type | filter entries on `name.type` with `SuiSchema.matchesType` (never `normalizeStructTag`, which throws on the primitive key types), then decode `name.bcs` with `SuiSchema.decode(keyCodec, entry.name.bcs)`; the entry carries both |
1699
1941
  | `deriveDynamicFieldID` + `getObjectOption` for existence | `sui.getDynamicFieldOption(parent, name)` — one call, `None` for absent |
@@ -1707,12 +1949,21 @@ conversion is mechanical except where the behaviour deliberately changed.
1707
1949
  | `ParallelTransactionExecutor` | `Tx.run` per PTB, under the sender lock. Parallel submission from one address needs distinct gas owners (`Tx.sponsored`) and is otherwise deferred: the lock is what stops two transactions picking the same gas coin |
1708
1950
  | `ExecResult` and its extractors | `Executed` with `created(type)`, `createdWhere(predicate)`, `packagesPublished()`, `balanceChange(address, coinType)`, `expectCreated` |
1709
1951
  | a `register(client)` building a class of Promise methods | the service above plus `SuiExtension.fromService`, with `warm` when the surface has synchronous members |
1952
+ | a hand-rolled idempotent submitter (persist the signed bytes, execute, wait, re-poll by digest on error, resubmit the identical bytes) | `Tx.build` → `Tx.sign` → `Tx.submit` with a durable `Journal` (`@unconfirmed/sui-effect/journal`), and `Tx.reconcileAll()` at startup. The journal write before the first send, the verbatim resubmit and the reconcile are all in `Tx.submit`; what stays yours is the domain record, which goes in `Tx.run`'s `onSigned` hook |
1953
+ | `client.core.getTransaction(digest)` on a transaction that may have failed | `sui.core.getTransaction` — **not** `sui.getTransaction`, which fails with `ExecutionFailed` for a `FailedTransaction` (that is the point of it). Reach for the core tier when what you need is the failed transaction's own events or effects |
1954
+ | a `ready()` that checks the genesis digest before anything else | `Sui.layerNoDepsWith({ chainId: deployment.chainIdentifier })`. `Sui.layerNoDeps` asserts the **built-in table's** id for the client's network, which is not the same claim as "this is the chain my deployment manifest was generated against"; `layerNoDepsPinned(chainId)` asserts nothing and makes no call, for a consumer that has already checked |
1955
+ | a standalone read function the predecessor exported (`getReleaseById(client, id)`) | a member on the service taking the branded id and returning decoded content. Grep the consumer for the **function name**, not for a facade call site: a removed standalone export does not appear in any `client.*` search |
1710
1956
 
1711
1957
  Five behaviour changes to put in the conversion issues:
1712
1958
 
1713
1959
  1. `getObjects` returns a per-item `Result`; ids that failed are no longer
1714
- silently dropped. `getObjectsOrFail` is the fail-first variant.
1715
- 2. `balanceChange` and `gasUsedTotal` are `bigint`, not `number`.
1960
+ silently dropped. `getObjectsStrict` is the fail-first variant.
1961
+ 2. `balanceChange` and `gasUsedTotal` are `bigint`, not `number` — and a
1962
+ `bigint` **throws** in `JSON.stringify`. Anything that logs, persists or
1963
+ returns one over HTTP needs `value.toString()` or a replacer
1964
+ (`(_, v) => typeof v === "bigint" ? v.toString() : v`). Decimal strings are
1965
+ what the wire uses and what every schema here decodes from, so a string is
1966
+ the right thing to store.
1716
1967
  3. `created(type)` compares normalized struct tags; the substring matching of
1717
1968
  `createdByType` / `allCreatedByType` is `createdWhere(predicate)`.
1718
1969
  4. `Tx.run` replaces sign-and-execute plus wait, and a transport failure once
@@ -1779,7 +2030,13 @@ Reject an extension that:
1779
2030
  `SuiExtension.Leaf<T>` / `SuiExtension.leaf(value)`;
1780
2031
  - calls `.make` on a branded schema with a value that came from outside;
1781
2032
  - promises a `ConfigError` for an empty environment variable it reads with
1782
- `Config.option`.
2033
+ `Config.option`;
2034
+ - has a test double for a signer that is not a real SDK `Signer`
2035
+ (`Signer.fromSdkSigner` now rejects a value with no `toSuiAddress`,
2036
+ `getKeyScheme` or `signTransaction`; use `Signer.remote` for a double);
2037
+ - branches on a `DecodeError`'s `issue` text instead of its `kind`;
2038
+ - typechecks its tests against an isolated-consumer fixture's installed
2039
+ `node_modules` (see section 10) rather than against the source under test.
1783
2040
 
1784
2041
  The effect-ts skill's own checklist still applies underneath: v3 names,
1785
2042
  `Effect.gen` returned from a plain arrow, throwing inside an Effect, mutable
@@ -1922,7 +2179,250 @@ Put both halves of the swap on the release checklist:
1922
2179
  Say in the PR which form was used while the branch was in flight. A `link:` that
1923
2180
  reaches `main` is a build that works on one machine.
1924
2181
 
1925
- ## 17. What extension authors must know
2182
+ ## 17. Application consumers
2183
+
2184
+ The sections above are for the package that *is* an extension. This one is for
2185
+ the application that consumes one — a React or Svelte SPA, a Next route, a test
2186
+ suite on vitest — because none of its problems are the extension's and all of
2187
+ them are recurring.
2188
+
2189
+ **One runtime per process, at module scope.** An Effect program at the edge of a
2190
+ browser app wants exactly one `ManagedRuntime`, built once and imported
2191
+ everywhere:
2192
+
2193
+ <!-- inline -->
2194
+
2195
+ ```ts
2196
+ // sui-client.ts
2197
+ import { SuiGrpcClient } from "@mysten/sui/grpc"
2198
+ import { Sui, SuiCore } from "@unconfirmed/sui-effect"
2199
+ import { Effect, Layer, ManagedRuntime } from "effect"
2200
+
2201
+ const client = new SuiGrpcClient({ network: deployment.network, url: deployment.url })
2202
+
2203
+ // `layerNoDepsWith({ chainId })` over the client you already have: the chain id
2204
+ // asserted is the deployment's, not the built-in table's entry for the network.
2205
+ const layer = Sui.layerNoDepsWith({ chainId: deployment.chainIdentifier }).pipe(
2206
+ Layer.provide(SuiCore.layerFromClient(client))
2207
+ )
2208
+
2209
+ export const runtime = ManagedRuntime.make(layer)
2210
+ export const runSui = <A, E>(effect: Effect.Effect<A, E, Sui | SuiCore>): Promise<A> =>
2211
+ runtime.runPromise(effect)
2212
+
2213
+ // Vite / webpack HMR: dispose the old runtime, or every edit leaks one.
2214
+ if (import.meta.hot) import.meta.hot.dispose(() => void runtime.dispose())
2215
+ ```
2216
+
2217
+ **A `ManagedRuntime` memoizes its layer build, including a failure.** The build
2218
+ here makes one `getChainIdentifier` call, and if that call fails — a flaky
2219
+ network on the first paint, a proxy still waking up — the runtime caches the
2220
+ failed build and **every** later use fails with the same stale `TransportError`,
2221
+ forever. Three cures, and a browser app usually wants two of them: pass
2222
+ `retry` (`Sui.layerNoDepsWith({ chainId, retry: Schedule.exponential("200 millis") })`)
2223
+ so a transient failure does not decide the runtime's life; dispose and rebuild
2224
+ the runtime when a use fails at the layer (it is a module-level `let`, not a
2225
+ `const`, in that design); or use `Sui.layerNoDepsPinned(chainId)`, which makes no
2226
+ call at all when the deployment manifest has already told you the chain id.
2227
+
2228
+ **`Journal`'s default is process-wide memory, and in a browser that means
2229
+ nothing survives.** "A journal entry written before the first execute, so a
2230
+ crash mid-flight leaves a record" is true of a server process and false of a
2231
+ tab: a refresh is a new process with an empty `Map`, and two tabs are two
2232
+ journals and two sender locks. What an app actually has is the
2233
+ `SubmissionUnknown` in its hands — it carries the signed bytes, and
2234
+ `SuiError.describe` prints them — so persist *that* (IndexedDB, `localStorage`,
2235
+ your own backend) at the moment you catch it, and reconcile it on the next boot
2236
+ with `Tx.reconcile(signed)`. `@unconfirmed/sui-effect/journal` over a
2237
+ `KeyValueStore` is the durable version of the same idea when you want the
2238
+ library to do it; see the Workers section for the adapter shape.
2239
+
2240
+ **Mapping failures onto UI states.** `SuiError.outcome(error)` is the axis:
2241
+ `"applied"` means it happened and the UI must not offer "try again",
2242
+ `"unknown"` means show the digest and a reconcile action, `"not_applied"` means
2243
+ the button can be re-enabled. Two caveats. In a `catchAll` that only wraps a
2244
+ build, a simulate or a signature, pass `{ phase: "pre-submit" }`, or an
2245
+ extension error the taxonomy does not own comes back `"unknown"` and the UI
2246
+ offers a reconcile for a transaction that was never built. And
2247
+ `SuiError.describe(error)` is safe to show in a debug panel for **any** error,
2248
+ including one of your own — since 0.1.2 it falls back to the tag and message
2249
+ rather than returning nothing.
2250
+
2251
+ **Signing with an external cosigner, from an app.** A wallet signs as the
2252
+ sender, a sponsorship service signs as the gas owner and submits. That is not
2253
+ `Tx.run`: see "Sponsored by an external service" in section 5 for the exact
2254
+ sequence, and note that the digest to record is the one `Tx.sign` already
2255
+ returned.
2256
+
2257
+ **Testing an app on vitest.** The harness does not assume `bun:test`: it is
2258
+ `layerTest`/`layerExtensionTest` plus `SuiTest`, all ordinary Effect values.
2259
+ Give the app's own `runSui` a test double built on the same layer and the app's
2260
+ components are testable with no network at all:
2261
+
2262
+ <!-- inline -->
2263
+
2264
+ ```ts
2265
+ // test/sui-client.ts
2266
+ import { layerTest } from "@unconfirmed/sui-effect/testing"
2267
+ import { Effect, Layer, ManagedRuntime } from "effect"
2268
+ import { Journal } from "@unconfirmed/sui-effect/tx"
2269
+
2270
+ export const testRuntime = (script: Parameters<typeof layerTest>[0] = {}) => {
2271
+ const runtime = ManagedRuntime.make(
2272
+ Layer.mergeAll(layerTest(script), Journal.layerMemory)
2273
+ )
2274
+ return { runSui: runtime.runPromise.bind(runtime), dispose: () => runtime.dispose() }
2275
+ }
2276
+ ```
2277
+
2278
+ Dispose it in an `afterEach`, keep `Journal.layerMemory` in the layer (the
2279
+ default journal is process-wide and leaks entries between tests), and script
2280
+ `getTransaction: [FakeOutcome.notFound()]` on anything that submits unless the
2281
+ test means "this landed" — section 10 has the rest.
2282
+
2283
+ **Keep Effect out of the first paint if bundle size matters.** The runtime
2284
+ module above is a fine dynamic `import()`: nothing in it runs until something
2285
+ awaits it, and a lazy `$extend` registration costs nothing at module scope.
2286
+
2287
+ ## 18. Workers and Durable Objects
2288
+
2289
+ Cloudflare Workers, Durable Objects and every other isolate runtime work, with
2290
+ four differences that are not obvious.
2291
+
2292
+ **There is no `process`.** `Script` (and `Script.run`) is a Node entrypoint and
2293
+ does not belong here; build the layer directly. Configuration comes from the
2294
+ Worker's `env` argument, not from `process.env`, which means providing a
2295
+ `ConfigProvider` per request rather than relying on the default one:
2296
+
2297
+ <!-- inline -->
2298
+
2299
+ ```ts
2300
+ const provider = ConfigProvider.fromEnvRecord(env as Record<string, string>)
2301
+ const program = effect.pipe(Effect.provideService(ConfigProvider.ConfigProvider, provider))
2302
+ ```
2303
+
2304
+ **One runtime per isolate, never one per request.** `Effect.provide(effect,
2305
+ Sui.layerNoDeps)` inside a `fetch` handler rebuilds the layer — and its chain-id
2306
+ round trip — on every request. Cache a `ManagedRuntime` in module scope (a
2307
+ Worker isolate is reused across requests) or on the Durable Object instance, and
2308
+ read the "memoizes its failure" warning in section 17 (Application consumers): in an isolate that lives
2309
+ for hours, a cached failed build is a much longer outage than in a tab.
2310
+
2311
+ **The sender lock does not cross isolates.** `sui.withSenderLock` is a
2312
+ semaphore in one runtime's memory. Two isolates, two Durable Objects, two
2313
+ regions — two locks, and nothing stops both picking the same gas coin. Either
2314
+ serialize submissions for an address through one Durable Object (which is what
2315
+ DOs are for), or stop depending on the lock: with `tx.setGasPayment([])` there is
2316
+ no gas coin to equivocate on, the node picks from the address balance, and
2317
+ `SubmitConfig.lockSender: false` is then correct rather than reckless. A
2318
+ sponsored transaction built with `Tx.sponsored` already has an empty gas
2319
+ payment, and `Tx.build` preserves it through the resolver.
2320
+
2321
+ **Time is not wall-clock time in a DO alarm.** `Tx.submit`'s resubmit schedule
2322
+ and `visibilityTimeout` are Effect sleeps inside one invocation; a Durable
2323
+ Object that wants to retry across hours uses an alarm and calls
2324
+ `Tx.reconcileAll()` (or `Tx.reconcile(signed)`) when it wakes, with a durable
2325
+ `Journal` underneath. That is the split: sleeps for seconds, alarms plus the
2326
+ journal for anything longer.
2327
+
2328
+ ### A `KeyValueStore` over Durable Object storage
2329
+
2330
+ `@unconfirmed/sui-effect/journal` needs a `KeyValueStore`, and Effect ships
2331
+ memory, filesystem, SQL and Web Storage layers — none of which exist in a DO.
2332
+ `KeyValueStore.makeStringOnly({ get, set, remove, clear, size })` is the whole
2333
+ adapter: five members over strings, and the journal uses nothing else (its
2334
+ entries are JSON and its unresolved index is one more key).
2335
+
2336
+ <!-- inline -->
2337
+
2338
+ ```ts
2339
+ import { KeyValueStore } from "effect/unstable/persistence"
2340
+ import { Effect, Layer } from "effect"
2341
+ import { layerKeyValueStore } from "@unconfirmed/sui-effect/journal"
2342
+
2343
+ const durableStore = (storage: DurableObjectStorage) =>
2344
+ KeyValueStore.makeStringOnly({
2345
+ get: (key) =>
2346
+ Effect.map(
2347
+ Effect.promise(() => storage.get<string>(key)),
2348
+ Option.fromNullishOr
2349
+ ),
2350
+ set: (key, value) => Effect.promise(() => storage.put(key, value)),
2351
+ remove: (key) => Effect.asVoid(Effect.promise(() => storage.delete(key))),
2352
+ clear: Effect.promise(() => storage.deleteAll()),
2353
+ size: Effect.map(Effect.promise(() => storage.list()), (map) => map.size)
2354
+ })
2355
+
2356
+ const journal = (storage: DurableObjectStorage) =>
2357
+ layerKeyValueStore({ onUnresolved: "ignore" }).pipe(
2358
+ Layer.provide(Layer.succeed(KeyValueStore.KeyValueStore, durableStore(storage)))
2359
+ )
2360
+ ```
2361
+
2362
+ Build it once per DO instance, alongside the runtime. `onUnresolved: "fail"`
2363
+ refuses to build while the store still holds unsettled entries, which is the
2364
+ right setting for a process whose startup is allowed to demand attention and
2365
+ the wrong one for a DO that must answer the next request.
2366
+
2367
+ ## 19. Relay and sponsor envelopes
2368
+
2369
+ `Executed` describes the SDK's own execute include set: effects, events, balance
2370
+ changes and the `objectTypes` join. A relay, a sponsor or any service that
2371
+ submitted on your behalf sends back whatever *it* asked the node for, which is
2372
+ usually less — `changedObjects` with an `objectId` and an `idOperation` and
2373
+ nothing else, no `objectTypes`, no `balanceChanges`, no checkpoint, events as
2374
+ JSON with no BCS.
2375
+
2376
+ `Executed.fromPartial(envelope)` decodes exactly that. What it was not told
2377
+ stays "not told": the input and output states are `Unknown` rather than a
2378
+ guessed `ObjectWrite`, versions and digests are `null`, and the accessors read
2379
+ `Unknown` as "the envelope did not say" so `created()` and `deleted()` still
2380
+ classify from the id operation alone. JSON spellings are accepted where the
2381
+ SDK's types are not JSON — `bcs` as base64 or a byte array, every `u64` as a
2382
+ number or a `bigint` as well as the decimal string.
2383
+
2384
+ Two things it cannot invent:
2385
+
2386
+ - **the types.** `created(type)`, `mutated(type)` and `expectCreated(type)`
2387
+ match against the `objectTypes` join, so without one they match nothing. Ask
2388
+ your relay for `objectTypes`; failing that, use `created()` unfiltered or
2389
+ `createdWhere(predicate)` and read the ids.
2390
+ - **the gas.** `gasUsedTotal` is `0n` for an envelope that reported no gas.
2391
+ That means "not reported", not "free".
2392
+
2393
+ `Executed.fromTransactionResult(result)` is the other constructor: the strict
2394
+ one, for an SDK `TransactionResult` read with the full include set, which is
2395
+ what to use when the service handed you a real execute response.
2396
+
2397
+ ### `Tx.submitVia`: the journal, for a submission you do not make
2398
+
2399
+ `Tx.submit` is what writes journal entries, and a consumer that hands its bytes
2400
+ to a relay never calls it — so the crash window between "signed" and "the
2401
+ service answered" had no record at all. `Tx.submitVia` is that path:
2402
+
2403
+ <!-- inline -->
2404
+
2405
+ ```ts
2406
+ const executed = yield* Tx.submitVia(signed, (bytes, signatures) =>
2407
+ postToTheRelay({ bytes: toBase64(bytes), signature: signatures[0]! }))
2408
+ ```
2409
+
2410
+ It writes the `Signed` entry **before** calling `send`, calls `send` exactly
2411
+ once (a third party's submit is not known to be idempotent, and re-sending is
2412
+ not the library's decision), turns the reply into an `Executed` when it carries
2413
+ one — an SDK `TransactionResult`, a reduced envelope, or nothing at all, in
2414
+ which case it asks the chain by the digest it already has — and journals the
2415
+ terminal answer. A failure from `send` is ambiguous, so it ends in
2416
+ `Tx.reconcile` with the full evidence rules; an error whose instance declares
2417
+ `outcome: "not_applied"` is taken at its word and fails straight through
2418
+ without spending a reconcile, which is how a service says "I refused this and
2419
+ sent nothing". Declare that field on your relay-refusal error.
2420
+
2421
+ It fails with `ExecutionFailed`, `NotApplied`, `SubmissionUnknown` (carrying the
2422
+ bytes, with the sender's failure as its `cause`), `JournalError` from the write
2423
+ before the send, and your own error when it declared itself not-applied.
2424
+
2425
+ ## 20. What extension authors must know
1926
2426
 
1927
2427
  The short list an independent verification of v0.1.0 said a downstream
1928
2428
  conversion has to carry. Everything here is documented somewhere above; this is
@@ -1996,3 +2496,28 @@ the page to read before the conversion rather than after it.
1996
2496
  not, and never will, because it validates without decoding.
1997
2497
  - **`bun install --force` after re-packing a vendored tarball** with the same
1998
2498
  filename and version, or bun keeps the old extraction.
2499
+ - **`DecodeError` carries a `kind`** — `"type"`, `"bytes"`, `"shape"`. Branch on
2500
+ it, never on `issue`.
2501
+ - **Every taxonomy error has a real `.message`** since 0.1.2 (it is
2502
+ `SuiError.describe`), so anything that surfaces `.message` shows a line
2503
+ instead of an empty string, and `SuiError.describe` accepts a foreign error
2504
+ rather than returning `undefined` for it.
2505
+ - **`Tx.reconcileAll` returns a tagged union** — `{ _tag: "Executed", executed }`
2506
+ or `{ _tag, error }` — not a bare `Executed | error`. It returns **only what
2507
+ was unresolved**; `Tx.recorded(digest)` is how to ask about a settled one.
2508
+ - **`Tx.submit` fails outright on a gRPC `INVALID_ARGUMENT`** instead of
2509
+ reconciling: the node refused the request, nothing executed, and reconciling
2510
+ it would ask a question about a transaction that was never sent. That is the
2511
+ only `TransportError` that escapes `Tx.submit`.
2512
+ - **A sponsored submission needs both signatures before `Tx.submit`**, on the
2513
+ fake as on a node. `Tx.cosign`, or `Tx.run`'s `sponsor`.
2514
+ - **`Signer.fromSdkSigner` validates its argument** and reads `toSuiAddress()`
2515
+ and `getKeyScheme()` **at construction**. A test double needs all three
2516
+ members, or use `Signer.remote`.
2517
+ - **`Signer.fromConfig` takes a 32-byte hex seed** as well as a Bech32
2518
+ `suiprivkey`, defaulting to Ed25519.
2519
+ - **`Tx.run` has an `onSigned` hook** between the last signature and the first
2520
+ send, for a consumer's own record; `Tx.submitVia` is the same lifecycle when
2521
+ somebody else does the sending.
2522
+ - **`bigint` throws in `JSON.stringify`.** Gas, balances and versions are all
2523
+ `bigint`; use `.toString()` or a replacer at every JSON boundary.
@@ -213,7 +213,7 @@ const make = (
213
213
  Effect.flatMap((raw) =>
214
214
  decodeAddress(raw).pipe(
215
215
  Effect.mapError((issue) =>
216
- new DecodeError({ expectedType: "SuiAddress", issue: issue.message })
216
+ new DecodeError({ expectedType: "SuiAddress", kind: "shape", issue: issue.message })
217
217
  )
218
218
  )
219
219
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unconfirmed/sui-effect",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "An opinionated Effect v4 layer over @mysten/sui: two client tiers, closed error unions, and a typed transaction lifecycle.",
5
5
  "license": "MIT",
6
6
  "type": "module",