@unconfirmed/sui-effect 0.1.0 → 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.
- package/AGENTS.md +47 -14
- package/CHANGELOG.md +86 -0
- package/LLMS.md +2889 -1726
- package/README.md +517 -11
- package/dist/domain/bcs.d.ts +44 -0
- package/dist/domain/bcs.d.ts.map +1 -1
- package/dist/domain/bcs.js +64 -0
- package/dist/domain/bcs.js.map +1 -1
- package/dist/domain/errors.d.ts +164 -26
- package/dist/domain/errors.d.ts.map +1 -1
- package/dist/domain/errors.js +193 -15
- package/dist/domain/errors.js.map +1 -1
- package/dist/domain/executed.d.ts +123 -18
- package/dist/domain/executed.d.ts.map +1 -1
- package/dist/domain/executed.js +196 -5
- package/dist/domain/executed.js.map +1 -1
- package/dist/domain/journal-entry.d.ts +6 -2
- package/dist/domain/journal-entry.d.ts.map +1 -1
- package/dist/domain/schemas.d.ts +234 -67
- package/dist/domain/schemas.d.ts.map +1 -1
- package/dist/domain/schemas.js +82 -4
- package/dist/domain/schemas.js.map +1 -1
- package/dist/domain/sui-schema.d.ts +3 -2
- package/dist/domain/sui-schema.d.ts.map +1 -1
- package/dist/domain/sui-schema.js +3 -2
- package/dist/domain/sui-schema.js.map +1 -1
- package/dist/extension.d.ts +1 -1
- package/dist/extension.d.ts.map +1 -1
- package/dist/extension.js +1 -1
- package/dist/extension.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/script.d.ts +1 -1
- package/dist/script.d.ts.map +1 -1
- package/dist/script.js +1 -1
- package/dist/script.js.map +1 -1
- package/dist/services/Script.d.ts +42 -0
- package/dist/services/Script.d.ts.map +1 -1
- package/dist/services/Script.js +69 -2
- package/dist/services/Script.js.map +1 -1
- package/dist/services/Signer.d.ts +32 -7
- package/dist/services/Signer.d.ts.map +1 -1
- package/dist/services/Signer.js +69 -10
- package/dist/services/Signer.js.map +1 -1
- package/dist/services/Sui.d.ts +42 -1
- package/dist/services/Sui.d.ts.map +1 -1
- package/dist/services/Sui.js +18 -4
- package/dist/services/Sui.js.map +1 -1
- package/dist/services/SuiCore.d.ts +12 -0
- package/dist/services/SuiCore.d.ts.map +1 -1
- package/dist/services/SuiCore.js +124 -0
- package/dist/services/SuiCore.js.map +1 -1
- package/dist/services/SuiCoreFake.d.ts +59 -4
- package/dist/services/SuiCoreFake.d.ts.map +1 -1
- package/dist/services/SuiCoreFake.js +199 -23
- package/dist/services/SuiCoreFake.js.map +1 -1
- package/dist/services/SuiExtension.d.ts +127 -14
- package/dist/services/SuiExtension.d.ts.map +1 -1
- package/dist/services/SuiExtension.js +125 -28
- package/dist/services/SuiExtension.js.map +1 -1
- package/dist/services/SuiGraphQL.d.ts +13 -0
- package/dist/services/SuiGraphQL.d.ts.map +1 -1
- package/dist/services/SuiGraphQL.js +13 -0
- package/dist/services/SuiGraphQL.js.map +1 -1
- package/dist/services/Tx.d.ts +630 -12
- package/dist/services/Tx.d.ts.map +1 -1
- package/dist/services/Tx.js +187 -8
- package/dist/services/Tx.js.map +1 -1
- package/dist/testing.d.ts +22 -3
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +39 -3
- package/dist/testing.js.map +1 -1
- package/dist/tx.d.ts +1 -1
- package/dist/tx.d.ts.map +1 -1
- package/dist/tx.js +1 -1
- package/dist/tx.js.map +1 -1
- package/docs/extensions.md +871 -33
- package/examples/extension-template/src/Escrow.ts +1 -1
- package/examples/extension-template/test/escrow.test.ts +91 -2
- package/package.json +3 -2
package/docs/extensions.md
CHANGED
|
@@ -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
|
|
@@ -283,6 +313,28 @@ export const SettlementContent = (typeOrigin: string) =>
|
|
|
283
313
|
)
|
|
284
314
|
```
|
|
285
315
|
|
|
316
|
+
When the mapping is a plain function that may throw — a constructor, a
|
|
317
|
+
`BigInt(...)`, a branding call — `SuiSchema.decodeWith(layout, type, map)` is
|
|
318
|
+
that composition in one call:
|
|
319
|
+
|
|
320
|
+
<!-- inline -->
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
const EscrowContent = (typeOrigin: string) =>
|
|
324
|
+
SuiSchema.decodeWith(
|
|
325
|
+
EscrowLayout,
|
|
326
|
+
escrowType(typeOrigin),
|
|
327
|
+
(raw) => new Escrow(ObjectId.normalize(raw.id), BigInt(raw.amount))
|
|
328
|
+
)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
A throw inside `map` becomes the `DecodeError` the caller already handles,
|
|
332
|
+
carrying the expected type — which is the part that gets forgotten when the same
|
|
333
|
+
thing is written as `Effect.try` around `Schema.decodeUnknownEffect`, where the
|
|
334
|
+
throw arrives as a defect instead. The codec still carries the Move type, so
|
|
335
|
+
`sui.getObject(id, { schema })` checks the tag before it parses. There is no
|
|
336
|
+
encoder: a mapping function has no inverse, so serialize with the layout itself.
|
|
337
|
+
|
|
286
338
|
The domain class is an ordinary `Schema.Class`:
|
|
287
339
|
|
|
288
340
|
<!-- from: examples/extension-template/src/schema.ts -->
|
|
@@ -323,6 +375,39 @@ comparing `address::module::name` only. Give it a tag that *carries* type
|
|
|
323
375
|
arguments and it is compared in full, after normalization, so
|
|
324
376
|
`Coin<0x2::sui::SUI>` does not accept `Coin<…::usdc::USDC>`.
|
|
325
377
|
|
|
378
|
+
Worked, on owned objects, because that is where it pays: one codec and one bare
|
|
379
|
+
tag serve every instantiation a wallet holds, including on the fake.
|
|
380
|
+
|
|
381
|
+
<!-- inline -->
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
// One codec for every `Composition<T>`, built from the type origin.
|
|
385
|
+
const compositionType = (typeOrigin: string) => `${typeOrigin}::composition::Composition`
|
|
386
|
+
const CompositionContent = (typeOrigin: string) =>
|
|
387
|
+
SuiSchema.bcs(CompositionLayout, compositionType(typeOrigin))
|
|
388
|
+
|
|
389
|
+
// Every composition an address owns, whatever it is parameterized by.
|
|
390
|
+
const owned = (owner: SuiAddress) =>
|
|
391
|
+
sui.streamOwnedObjects(owner, {
|
|
392
|
+
type: compositionType(typeOrigin),
|
|
393
|
+
schema: CompositionContent(typeOrigin)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
// In a test, the fake filters with the same rule, so objects whose `type` is
|
|
397
|
+
// the instantiated tag are served for the bare one:
|
|
398
|
+
const script = {
|
|
399
|
+
objects: [
|
|
400
|
+
{ objectId: FIRST, type: `${ORIGIN}::composition::Composition<${ORIGIN}::share::Share>`, … },
|
|
401
|
+
{ objectId: SECOND, type: `${ORIGIN}::composition::Composition<0x2::sui::SUI>`, … }
|
|
402
|
+
]
|
|
403
|
+
}
|
|
404
|
+
// `owned(address)` yields both, each decoded by the one codec.
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
The instantiation is not lost: each object keeps its own tag on
|
|
408
|
+
`SuiObject.type`, so a member that cares which one it read still can. What the
|
|
409
|
+
bare tag does is stop you writing a codec per type argument.
|
|
410
|
+
|
|
326
411
|
The same rule holds everywhere a Move type is compared: the `expectedType`
|
|
327
412
|
option of `getObject` / `getObjectOption` / `getObjects`, `SuiSchema.decode`'s
|
|
328
413
|
`actualType`, the `type` filter of `streamOwnedObjects`, and the fake's filter
|
|
@@ -434,8 +519,23 @@ becomes a typed `DecodeError`; `.make` on it is a defect in a member whose error
|
|
|
434
519
|
union says it cannot fail.
|
|
435
520
|
|
|
436
521
|
They also validate rather than normalize: `SuiAddress.make("0x1")` throws,
|
|
437
|
-
because `0x1` is not a 32-byte address.
|
|
438
|
-
|
|
522
|
+
because `0x1` is not a 32-byte address. **`SuiAddress.normalize` and
|
|
523
|
+
`ObjectId.normalize` are the pair that take the shorthand**: they run the
|
|
524
|
+
schema's own decode (`normalizeSuiAddress`) and then brand, so `"0x1"`, an
|
|
525
|
+
unpadded hex string and the padded form all produce the same branded value.
|
|
526
|
+
|
|
527
|
+
<!-- inline -->
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
const treasury = SuiAddress.normalize("0x2") // 0x0000…0002, branded
|
|
531
|
+
const clock = ObjectId.normalize("0x6")
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
They throw, exactly like `.make`, so they are still for literals and
|
|
535
|
+
configuration **you** control — a deployment constant, a CLI flag you already
|
|
536
|
+
validated. Anything that arrived from a node, a user or an upstream package goes
|
|
537
|
+
through `Schema.decodeUnknownEffect(ObjectId)` and becomes a typed
|
|
538
|
+
`DecodeError`.
|
|
439
539
|
|
|
440
540
|
When the error you would build *needs a field you do not have* — a `DecodeError`
|
|
441
541
|
wants an `objectId` and you are decoding an event payload with no object — that
|
|
@@ -488,7 +588,8 @@ out of fifty is not a failed read. Two idioms, and you should pick deliberately:
|
|
|
488
588
|
`results.filter(Result.isSuccess).map((result) => result.success)`, or
|
|
489
589
|
`Result.getOrElse(result, () => fallback)` per item, or a `Map` keyed by id so
|
|
490
590
|
a caller can ask about one;
|
|
491
|
-
- **hard** — every id must be there: `sui.
|
|
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
|
|
492
593
|
fails with the first item's error (`ObjectNotFound`, `ObjectDeleted`,
|
|
493
594
|
`ObjectUnavailable` or `DecodeError`) and otherwise hands back the objects in
|
|
494
595
|
the order of the ids.
|
|
@@ -648,6 +749,66 @@ An extension whose two parties cannot both sign in one process — the sponsor i
|
|
|
648
749
|
a remote service, the sender is a wallet — uses the explicit lifecycle instead:
|
|
649
750
|
`Tx.build`, `Tx.sign`, hand the bytes over, `Tx.cosign`, `Tx.submit`.
|
|
650
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
|
+
|
|
651
812
|
## 6. Layers
|
|
652
813
|
|
|
653
814
|
Following the house convention: `layer(opts)` for the live one, `layerConfig`
|
|
@@ -815,7 +976,8 @@ different extensions on the same client. After that:
|
|
|
815
976
|
- an `Effect` member is a zero-argument method returning a `Promise`;
|
|
816
977
|
- a function returning an `Effect` keeps its arguments and returns a `Promise`;
|
|
817
978
|
- a `Stream` is an `AsyncIterable`, usable in `for await`;
|
|
818
|
-
- a nested namespace is mapped recursively
|
|
979
|
+
- a nested namespace is mapped recursively, **including one typed as an
|
|
980
|
+
`interface`** — the recursion is by type, not by how the member was declared;
|
|
819
981
|
- a plain value passes through;
|
|
820
982
|
- a rejection is **the same tagged error instance**, so a Promise consumer can
|
|
821
983
|
still switch on `_tag` and read `outcome`;
|
|
@@ -860,6 +1022,15 @@ export const platform = (options: PlatformRegistrationOptions) =>
|
|
|
860
1022
|
})
|
|
861
1023
|
```
|
|
862
1024
|
|
|
1025
|
+
**A `warm` registration runs the whole layer synchronously, so every failure of
|
|
1026
|
+
that layer is thrown out of `$extend`.** Not only an asynchronous step and not
|
|
1027
|
+
only a missing chain id: a deployment your bundle does not have for this
|
|
1028
|
+
network, a `ConfigError`, a `NetworkMismatch`, anything the layer declares.
|
|
1029
|
+
There is no first call to reject, because the layer is built before `register`
|
|
1030
|
+
returns. Catch it where you register, and say so in your registration's JSDoc.
|
|
1031
|
+
The mirror image is the lazy default, where the layer's failure surfaces as the
|
|
1032
|
+
rejection of whatever call needed it first.
|
|
1033
|
+
|
|
863
1034
|
`warm` has two conditions and both are enforced. The layer must not perform an
|
|
864
1035
|
asynchronous step — a layer that reads the network at build cannot be built
|
|
865
1036
|
synchronously and `register` throws. And the chain identifier is **taken, not
|
|
@@ -876,6 +1047,35 @@ chain. What catches it is the chain itself: `Tx.build` stamps that id on the
|
|
|
876
1047
|
transaction's expiration and a validator refuses bytes signed for another chain.
|
|
877
1048
|
Register lazily when the assertion is what you want.
|
|
878
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
|
+
|
|
879
1079
|
**Thread the chain id through your registration options**, the way
|
|
880
1080
|
`src/extension.ts` and `src/Platform.ts` both do, rather than relying on the
|
|
881
1081
|
built-in table. It is what makes the face work on `devnet` and `localnet`, and
|
|
@@ -883,26 +1083,132 @@ it is what lets every registration on one client agree — see the next
|
|
|
883
1083
|
paragraph. The template has a test for the warm face on a network with no
|
|
884
1084
|
built-in chain id; a conversion should have one too.
|
|
885
1085
|
|
|
1086
|
+
**What a cold call actually is.** The value a member call returns before the
|
|
1087
|
+
runtime exists is a real `Promise` subclass that also implements
|
|
1088
|
+
`Symbol.asyncIterator`, because nothing yet knows whether the member was an
|
|
1089
|
+
`Effect` (a Promise) or a `Stream` (an `AsyncIterable`). So `instanceof Promise`
|
|
1090
|
+
holds, `for await` works, and in `bun:test`
|
|
1091
|
+
`await expect(client.ext.thing()).rejects.toBeInstanceOf(ExtensionNotReady)`
|
|
1092
|
+
does what it looks like. (In 0.1.0 it was a bare thenable and `.rejects` did not
|
|
1093
|
+
recognise it; `await ... .catch()` was the workaround and is no longer needed.)
|
|
1094
|
+
|
|
1095
|
+
Its rejection is also **pre-handled**: a cold call nobody awaits —
|
|
1096
|
+
`client.ext.doThing()` written as a statement — rejects with
|
|
1097
|
+
`ExtensionNotReady` into a no-op catch rather than aborting the process on an
|
|
1098
|
+
unhandled rejection. Your own `await` still throws. Write the test that proves
|
|
1099
|
+
this for your own face; it is the one failure mode that kills a test run rather
|
|
1100
|
+
than failing a test.
|
|
1101
|
+
|
|
1102
|
+
And **`$dispose()` keeps a warm registration warm**: the next use re-runs the
|
|
1103
|
+
same warm build rather than leaving every synchronous member throwing
|
|
1104
|
+
`ExtensionNotReady` forever after.
|
|
1105
|
+
|
|
886
1106
|
If your extension's surface is entirely `Effect` and `Stream` members, none of
|
|
887
1107
|
this applies: the lazy default is right and the first `await` builds everything.
|
|
888
1108
|
|
|
889
|
-
###
|
|
1109
|
+
### Namespaces, leaves, and the one member that still lies
|
|
1110
|
+
|
|
1111
|
+
The face recurses into object-typed members — that is what makes
|
|
1112
|
+
`client.platform.escrow.get(id)` work — and **the recursion is by type, not by
|
|
1113
|
+
declaration style**. An `interface`-typed namespace (`readonly escrow:
|
|
1114
|
+
EscrowService`) is mapped exactly like an inline object literal. In 0.1.0 it was
|
|
1115
|
+
not: the type's bound was `Record<string, unknown>`, which an interface is not
|
|
1116
|
+
assignable to, so an interface-typed namespace kept its `Effect` members **in
|
|
1117
|
+
the type** while the runtime mapped them to Promises. If you carried a local
|
|
1118
|
+
type alias to work around that, delete it.
|
|
1119
|
+
|
|
1120
|
+
These are the leaves — passed through whole, in the type and at runtime alike:
|
|
1121
|
+
functions, arrays, `Uint8Array`, `Date`, `Promise`, and a BCS codec (anything
|
|
1122
|
+
with both `parse` and `serialize`, which is every `BcsType`). Exposing a codec
|
|
1123
|
+
as a member is safe.
|
|
1124
|
+
|
|
1125
|
+
For **any other class instance** — a `Schema.Class` instance, a policy object,
|
|
1126
|
+
anything with methods of its own — say so:
|
|
1127
|
+
|
|
1128
|
+
<!-- inline -->
|
|
1129
|
+
|
|
1130
|
+
```ts
|
|
1131
|
+
import { SuiExtension } from "@unconfirmed/sui-effect/extension"
|
|
1132
|
+
|
|
1133
|
+
interface MyService {
|
|
1134
|
+
readonly policy: SuiExtension.Leaf<Policy>
|
|
1135
|
+
}
|
|
1136
|
+
// in the layer:
|
|
1137
|
+
return { policy: SuiExtension.leaf(new Policy()) }
|
|
1138
|
+
```
|
|
1139
|
+
|
|
1140
|
+
`Leaf<T>` **is** a `T`, so the Effect face is unaffected; what it does is tell
|
|
1141
|
+
the Promise face that this member is a value rather than a namespace of members.
|
|
1142
|
+
Without it the type would recurse into the class while the runtime passes class
|
|
1143
|
+
instances through untouched, and for a class whose methods return `Effect`s that
|
|
1144
|
+
is the same lie in the other direction.
|
|
1145
|
+
|
|
1146
|
+
The remaining disagreement is the one that cannot be resolved by types at all. A
|
|
1147
|
+
**plain-object value** member — `deployment: { packageId }` — is
|
|
1148
|
+
indistinguishable from a namespace of members, so the type maps it as the value
|
|
1149
|
+
while the **cold** face treats it as a namespace and hands back a placeholder
|
|
1150
|
+
for `deployment.packageId`. Reading that placeholder throws `ExtensionNotReady`
|
|
1151
|
+
naming the path, so it is typed and named rather than silent, but it is still a
|
|
1152
|
+
disagreement. Either register `warm` (or `await $ready()`), or expose the value
|
|
1153
|
+
through an `Effect` member. Do not put a plain-object value member on a service
|
|
1154
|
+
that consumers will register lazily.
|
|
1155
|
+
|
|
1156
|
+
**Write a `PromiseFace<Service>` type test per namespace.** It is four lines, it
|
|
1157
|
+
is the only thing that catches a face type that has drifted from the runtime,
|
|
1158
|
+
and the template has one to copy:
|
|
1159
|
+
|
|
1160
|
+
<!-- from: examples/extension-template/test/escrow.test.ts -->
|
|
1161
|
+
|
|
1162
|
+
```ts
|
|
1163
|
+
describe("the Promise face type", () => {
|
|
1164
|
+
/** Compile-time assignability, as a value a test can assert on. */
|
|
1165
|
+
const assignableTo = <_A extends _B, _B>(): true => true
|
|
1166
|
+
|
|
1167
|
+
type EscrowFace = PromiseFace<EscrowService>
|
|
1168
|
+
type PlatformFace = PromiseFace<PlatformService>
|
|
1169
|
+
|
|
1170
|
+
test("an Effect member becomes a Promise-returning method", () => {
|
|
1171
|
+
expect(assignableTo<EscrowFace["get"], (id: ObjectId) => Promise<EscrowObject>>()).toBe(true)
|
|
1172
|
+
expect(assignableTo<EscrowFace["feeCollector"], () => Promise<SuiAddress>>()).toBe(true)
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
test("a Stream member becomes an AsyncIterable", () => {
|
|
1176
|
+
expect(
|
|
1177
|
+
assignableTo<EscrowFace["owned"]["stream"], (owner: SuiAddress) => AsyncIterable<EscrowObject>>()
|
|
1178
|
+
).toBe(true)
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
test("a synchronous member stays synchronous", () => {
|
|
1182
|
+
expect(assignableTo<EscrowFace["packageId"], string>()).toBe(true)
|
|
1183
|
+
expect(assignableTo<EscrowFace["claim"], (escrow: EscrowObject) => Recipe>()).toBe(true)
|
|
1184
|
+
})
|
|
890
1185
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1186
|
+
test("an interface-typed namespace is mapped all the way down", () => {
|
|
1187
|
+
// `PlatformService.escrow` is `EscrowService`, an interface. The members
|
|
1188
|
+
// reached through it must be the mapped ones, not the Effect ones.
|
|
1189
|
+
expect(assignableTo<PlatformFace["escrow"]["get"], (id: ObjectId) => Promise<EscrowObject>>())
|
|
1190
|
+
.toBe(true)
|
|
1191
|
+
expect(
|
|
1192
|
+
assignableTo<
|
|
1193
|
+
PlatformFace["escrow"]["owned"]["count"],
|
|
1194
|
+
(owner: SuiAddress) => Promise<number>
|
|
1195
|
+
>()
|
|
1196
|
+
).toBe(true)
|
|
1197
|
+
expect(assignableTo<PlatformFace["escrow"]["packageId"], string>()).toBe(true)
|
|
1198
|
+
})
|
|
896
1199
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1200
|
+
test("the composition's own member is mapped too", () => {
|
|
1201
|
+
expect(
|
|
1202
|
+
assignableTo<
|
|
1203
|
+
PlatformFace["claimEverything"],
|
|
1204
|
+
(ids: ReadonlyArray<ObjectId>, opts: { readonly signer: Signer }) => Promise<
|
|
1205
|
+
ReadonlyArray<ChangedRef>
|
|
1206
|
+
>
|
|
1207
|
+
>()
|
|
1208
|
+
).toBe(true)
|
|
1209
|
+
})
|
|
1210
|
+
})
|
|
1211
|
+
```
|
|
906
1212
|
|
|
907
1213
|
### The rest of the contract
|
|
908
1214
|
|
|
@@ -1011,6 +1317,51 @@ The same shape holds for a dependency that is not an extension at all — a
|
|
|
1011
1317
|
`SuiGraphQL` client, an `HttpClient`, your own operator service: yield it in
|
|
1012
1318
|
`make`, provide its layer in `layer`.
|
|
1013
1319
|
|
|
1320
|
+
#### A standalone function that needs a sibling extension
|
|
1321
|
+
|
|
1322
|
+
A service member yields its dependencies; a **standalone exported function**
|
|
1323
|
+
has no layer of its own, and the temptation is to build the sibling's layer
|
|
1324
|
+
inside it on every call or, worse, to keep a module-level `ManagedRuntime`.
|
|
1325
|
+
Neither is sanctioned. There are exactly two shapes, and the first is the
|
|
1326
|
+
default:
|
|
1327
|
+
|
|
1328
|
+
<!-- inline -->
|
|
1329
|
+
|
|
1330
|
+
```ts
|
|
1331
|
+
// 1. Take the sibling's service as a parameter. The caller already holds it —
|
|
1332
|
+
// it is in a member's `Effect.gen`, or in a script that provided the layer —
|
|
1333
|
+
// and the function stays `R = Sui`, testable with the sibling's test layer
|
|
1334
|
+
// and nothing else.
|
|
1335
|
+
export const settleAll = Effect.fn("settleAll")(function*(
|
|
1336
|
+
escrow: EscrowService,
|
|
1337
|
+
ids: ReadonlyArray<ObjectId>,
|
|
1338
|
+
opts: { readonly signer: Signer }
|
|
1339
|
+
) {
|
|
1340
|
+
const settled: Array<ChangedRef> = []
|
|
1341
|
+
for (const id of ids) settled.push(yield* escrow.claimFor(id, opts))
|
|
1342
|
+
return settled
|
|
1343
|
+
})
|
|
1344
|
+
|
|
1345
|
+
// 2. Require it, and let the caller provide it once. Use this when the function
|
|
1346
|
+
// is part of a surface whose consumers already hold the layer.
|
|
1347
|
+
export const settleAllOwned = Effect.fn("settleAllOwned")(function*(
|
|
1348
|
+
owner: SuiAddress,
|
|
1349
|
+
opts: { readonly signer: Signer }
|
|
1350
|
+
) {
|
|
1351
|
+
const escrow = yield* Escrow // R = Sui | Escrow
|
|
1352
|
+
…
|
|
1353
|
+
})
|
|
1354
|
+
```
|
|
1355
|
+
|
|
1356
|
+
What not to do: `Layer.build` (or `Effect.provide(Escrow.layer(options))`)
|
|
1357
|
+
**inside** the function body. It is one layer build per call — a fresh cache, a
|
|
1358
|
+
fresh connection, a fresh sender lock for whatever the sibling holds — and the
|
|
1359
|
+
options have to come from somewhere, which is how a package id ends up read from
|
|
1360
|
+
`process.env` three files away from the service that owns it. If a function
|
|
1361
|
+
genuinely needs to build the sibling itself, build it **once** in the function
|
|
1362
|
+
that builds the registration and close over the service, the way `Platform.layer`
|
|
1363
|
+
does with `Layer.provide`.
|
|
1364
|
+
|
|
1014
1365
|
## 9. Wrapping an upstream Promise package
|
|
1015
1366
|
|
|
1016
1367
|
For upstream SDKs we do not own (suins, deepbook, whatever comes next) we do not
|
|
@@ -1038,7 +1389,7 @@ const feeCollector = sui.core
|
|
|
1038
1389
|
Effect.flatMap((raw) =>
|
|
1039
1390
|
decodeAddress(raw).pipe(
|
|
1040
1391
|
Effect.mapError((issue) =>
|
|
1041
|
-
new DecodeError({ expectedType: "SuiAddress", issue: issue.message })
|
|
1392
|
+
new DecodeError({ expectedType: "SuiAddress", kind: "shape", issue: issue.message })
|
|
1042
1393
|
)
|
|
1043
1394
|
)
|
|
1044
1395
|
),
|
|
@@ -1083,9 +1434,12 @@ release.
|
|
|
1083
1434
|
|
|
1084
1435
|
### If your extension reads GraphQL
|
|
1085
1436
|
|
|
1086
|
-
`SuiGraphQL` is a tag over the SDK's own client, not a wrapper
|
|
1087
|
-
|
|
1088
|
-
|
|
1437
|
+
`SuiGraphQL` is a tag over the SDK's own client, not a wrapper: `yield*
|
|
1438
|
+
SuiGraphQL` hands back the `SuiGraphQLClient` that was passed to
|
|
1439
|
+
`SuiGraphQL.layer(client)`, so the service type is `SuiGraphQL["Service"]`
|
|
1440
|
+
(which *is* `SuiGraphQLClient`) and that is what a helper taking it as a
|
|
1441
|
+
parameter should be typed with. `SuiGraphQL.query(run, method?)` is the one call
|
|
1442
|
+
that sorts out the two failures:
|
|
1089
1443
|
|
|
1090
1444
|
<!-- inline -->
|
|
1091
1445
|
|
|
@@ -1139,6 +1493,19 @@ const provide = <A, E>(
|
|
|
1139
1493
|
So a test exercises the production high tier: the include sets, the BCS bridge,
|
|
1140
1494
|
the chunked batch reads, the sender lock and every `Tx` step.
|
|
1141
1495
|
|
|
1496
|
+
It also provides **`SuiGraphQL.layerUnavailable`**, so an extension that reads
|
|
1497
|
+
GraphQL builds in a test with no endpoint and every GraphQL call fails with
|
|
1498
|
+
`GraphQLUnavailable` — the failure it already handles. Anything else your layer
|
|
1499
|
+
requires that the client could not have given it goes in the third argument:
|
|
1500
|
+
|
|
1501
|
+
<!-- inline -->
|
|
1502
|
+
|
|
1503
|
+
```ts
|
|
1504
|
+
const layer = layerExtensionTest(Escrow.layerTest(), script, {
|
|
1505
|
+
extra: SuiGraphQL.layer(scriptedClient) // or an HttpClient, an operator service…
|
|
1506
|
+
})
|
|
1507
|
+
```
|
|
1508
|
+
|
|
1142
1509
|
The `script` is what the fake serves — objects with real BCS content, gas coins,
|
|
1143
1510
|
and scripted outcomes for simulate, execute and `getTransaction`:
|
|
1144
1511
|
|
|
@@ -1167,6 +1534,19 @@ const script = {
|
|
|
1167
1534
|
}
|
|
1168
1535
|
```
|
|
1169
1536
|
|
|
1537
|
+
**`waitForTransaction` is scripted through `getTransaction`.** There is no
|
|
1538
|
+
separate knob: `Tx.submit` waits by polling `getTransaction`, so the ordered
|
|
1539
|
+
`FakeScript.getTransaction` outcomes (and `FakeScript.transactions` /
|
|
1540
|
+
`SuiTest.recordTransaction` for answers keyed by digest) are what decide whether
|
|
1541
|
+
a wait succeeds, times out or reports the transaction missing. A test that wants
|
|
1542
|
+
"executed, then not visible for two polls, then visible" scripts exactly that
|
|
1543
|
+
list.
|
|
1544
|
+
|
|
1545
|
+
`FakeScript.coinMetadata` is the same idea for `getCoinMetadata`: a record keyed
|
|
1546
|
+
by coin type, with an unscripted type answering `{ coinMetadata: null }` the way
|
|
1547
|
+
a node does. Unscripted entirely, the method dies naming itself, like every
|
|
1548
|
+
other method the script does not cover.
|
|
1549
|
+
|
|
1170
1550
|
`SuiTest` drives the fake from inside an `Effect`: `putObject`, `bumpVersion`,
|
|
1171
1551
|
`deleteObject`, `setClock` (the chain's clock, which is what `Tx.build` bounds a
|
|
1172
1552
|
transaction against — Effect's `TestClock` drives the program's own time),
|
|
@@ -1251,6 +1631,91 @@ test("a retryable transport failure re-sends the identical bytes", async () => {
|
|
|
1251
1631
|
})
|
|
1252
1632
|
```
|
|
1253
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
|
+
|
|
1254
1719
|
### Your own fake beside the harness
|
|
1255
1720
|
|
|
1256
1721
|
`layerExtensionTest` composes: the first argument is *your* layer, and your
|
|
@@ -1286,6 +1751,9 @@ the harness's.
|
|
|
1286
1751
|
|
|
1287
1752
|
### What the fake does and does not do
|
|
1288
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.
|
|
1289
1757
|
- **Its client supports `$extend`.** `SuiCoreFake`'s handle exposes `client`, a
|
|
1290
1758
|
`ClientWithCoreApi` that implements `$extend`, so a derived Promise face can
|
|
1291
1759
|
be tested exactly the way a consumer writes it — `fake.client.$extend(escrow(options))`
|
|
@@ -1350,10 +1818,39 @@ extension exits with the code a wrapper can act on — 5 applied, 4 not applied,
|
|
|
1350
1818
|
`SuiError.toJson` serializes your errors too. A tag in @unconfirmed/sui-effect's own taxonomy
|
|
1351
1819
|
encodes through the taxonomy's schema; **anything else that is a
|
|
1352
1820
|
`Schema.TaggedError` encodes through its own**, so an extension error arrives as
|
|
1353
|
-
`{ _tag, escrowId, outcome }` rather than a bare `{ _tag, message }`.
|
|
1821
|
+
`{ _tag, escrowId, outcome }` rather than a bare `{ _tag, message }`.
|
|
1822
|
+
|
|
1823
|
+
**`outcome` is in that JSON even though it is a class field.** Declaring it the
|
|
1824
|
+
way the template does — `readonly outcome: Outcome = "unknown"` beside the
|
|
1825
|
+
schema fields — keeps the call site clean, and a class field is not part of the
|
|
1826
|
+
schema, so encoding alone would drop exactly the field a wrapper script acts on.
|
|
1827
|
+
`toJson` reads it off the instance and puts it back. Keep declaring it as a
|
|
1828
|
+
field; there is nothing to change in your errors. That is
|
|
1354
1829
|
what makes a structured log of a failed run useful, and it is a reason to give
|
|
1355
1830
|
every field of an error a schema rather than stuffing detail into a string.
|
|
1356
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
|
+
|
|
1357
1854
|
Two of those deserve a second look. `UnexpectedEffects` — what
|
|
1358
1855
|
`executed.expectCreated(type)` fails with — is **applied**, exit 5: it can only
|
|
1359
1856
|
come from an `Executed`, so the transaction ran and gas was charged and only the
|
|
@@ -1371,9 +1868,27 @@ functions beside it is a different job, and the order that works is this.
|
|
|
1371
1868
|
|
|
1372
1869
|
1. **Inventory the namespaces first.** List what consumers actually call,
|
|
1373
1870
|
grouped the way they call it (`ids`, `tx`, `protocol`, `party`). That list is
|
|
1374
|
-
your service interface, and a group is a
|
|
1375
|
-
|
|
1376
|
-
reviewed against.
|
|
1871
|
+
your service interface, and a group is a member on it — an `interface` is
|
|
1872
|
+
fine, the face maps it either way. Write the interface before you move any
|
|
1873
|
+
code: it is the only artefact the conversion is reviewed against.
|
|
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
|
+
|
|
1887
|
+
**Grep for captured aliases, not only for dotted calls.** A consumer that
|
|
1888
|
+
writes `const party = client.miso.party` and then `party.join(...)` does not
|
|
1889
|
+
appear in a search for `client.miso.party.join`, and a namespace that looks
|
|
1890
|
+
unused gets dropped from the interface. Search for the namespace name on its
|
|
1891
|
+
own (`\bclient\.\w+\.party\b`, `= .*\.party\b`) as well as for the calls.
|
|
1377
1892
|
2. **Keep the standalone functions.** An existing `Effect<A, E, Sui>` function
|
|
1378
1893
|
that is exported and used outside the facade stays exported and keeps its
|
|
1379
1894
|
signature. Do not make consumers hold a service to call something that never
|
|
@@ -1414,13 +1929,13 @@ conversion is mechanical except where the behaviour deliberately changed.
|
|
|
1414
1929
|
| `GraphQLUnavailableError` | `GraphQLUnavailable { method, reason }`, in the taxonomy, outcome `not_applied` — what `SuiGraphQL.layerUnavailable` rejects every call with |
|
|
1415
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) |
|
|
1416
1931
|
| `ObjectNotFoundError` | `ObjectNotFound`, plus `ObjectDeleted` and `ObjectUnavailable` from the SDK's own `reason` |
|
|
1417
|
-
| `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 |
|
|
1418
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 |
|
|
1419
1934
|
| `BcsDecodeError` | `DecodeError` |
|
|
1420
1935
|
| `TransactionFailedError { digest, status }` | `ExecutionFailed { digest, reason, command, effects }` |
|
|
1421
1936
|
| `getObjectContent` | `sui.getObject(id)` — with no schema, `content` is the raw bytes |
|
|
1422
1937
|
| `getOptionalObjectContent` | `sui.getObjectOption` — `None` for missing and deleted, which is also the blessed way to express domain absence |
|
|
1423
|
-
| `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 |
|
|
1424
1939
|
| `listDynamicFields` | `sui.streamDynamicFields` |
|
|
1425
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 |
|
|
1426
1941
|
| `deriveDynamicFieldID` + `getObjectOption` for existence | `sui.getDynamicFieldOption(parent, name)` — one call, `None` for absent |
|
|
@@ -1434,12 +1949,21 @@ conversion is mechanical except where the behaviour deliberately changed.
|
|
|
1434
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 |
|
|
1435
1950
|
| `ExecResult` and its extractors | `Executed` with `created(type)`, `createdWhere(predicate)`, `packagesPublished()`, `balanceChange(address, coinType)`, `expectCreated` |
|
|
1436
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 |
|
|
1437
1956
|
|
|
1438
1957
|
Five behaviour changes to put in the conversion issues:
|
|
1439
1958
|
|
|
1440
1959
|
1. `getObjects` returns a per-item `Result`; ids that failed are no longer
|
|
1441
|
-
silently dropped. `
|
|
1442
|
-
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.
|
|
1443
1967
|
3. `created(type)` compares normalized struct tags; the substring matching of
|
|
1444
1968
|
`createdByType` / `allCreatedByType` is `createdWhere(predicate)`.
|
|
1445
1969
|
4. `Tx.run` replaces sign-and-execute plus wait, and a transport failure once
|
|
@@ -1500,9 +2024,19 @@ Reject an extension that:
|
|
|
1500
2024
|
carries, prefix included;
|
|
1501
2025
|
- leaves `tests` out of the package `tsconfig`'s `include`, so its type-level
|
|
1502
2026
|
pins never compile;
|
|
2027
|
+
- has no `PromiseFace<Service>` type test per namespace, or has one that only
|
|
2028
|
+
checks the top level;
|
|
2029
|
+
- exposes a class instance with `Effect`-returning methods without
|
|
2030
|
+
`SuiExtension.Leaf<T>` / `SuiExtension.leaf(value)`;
|
|
1503
2031
|
- calls `.make` on a branded schema with a value that came from outside;
|
|
1504
2032
|
- promises a `ConfigError` for an empty environment variable it reads with
|
|
1505
|
-
`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.
|
|
1506
2040
|
|
|
1507
2041
|
The effect-ts skill's own checklist still applies underneath: v3 names,
|
|
1508
2042
|
`Effect.gen` returned from a plain arrow, throwing inside an Effect, mutable
|
|
@@ -1616,6 +2150,19 @@ old one) before installing: a file that stopped shipping is caught there rather
|
|
|
1616
2150
|
than in a consumer. Record the sui-effect commit or tag the vendor copy came
|
|
1617
2151
|
from.
|
|
1618
2152
|
|
|
2153
|
+
**And re-install with `--force`.** bun keys a file dependency by name and
|
|
2154
|
+
version, not by content, so re-packing over `vendor/unconfirmed-sui-effect-0.1.1.tgz`
|
|
2155
|
+
and running `bun install` again leaves the *old* extraction in `node_modules` —
|
|
2156
|
+
silently, and for as long as it takes you to notice that a fix you just made is
|
|
2157
|
+
not there. Either bump the filename (`…-0.1.1+2.tgz`) or:
|
|
2158
|
+
|
|
2159
|
+
```bash
|
|
2160
|
+
bun install --force
|
|
2161
|
+
```
|
|
2162
|
+
|
|
2163
|
+
Verify it took: `cat node_modules/@unconfirmed/sui-effect/package.json | grep version`, or
|
|
2164
|
+
grep the shipped `dist` for the change you are looking for.
|
|
2165
|
+
|
|
1619
2166
|
**Until the first publish, bun probes the registry for every peer.** It does so
|
|
1620
2167
|
even for a peer a local dependency already satisfies, and an unpublished name
|
|
1621
2168
|
404s the install. The escape is
|
|
@@ -1632,7 +2179,250 @@ Put both halves of the swap on the release checklist:
|
|
|
1632
2179
|
Say in the PR which form was used while the branch was in flight. A `link:` that
|
|
1633
2180
|
reaches `main` is a build that works on one machine.
|
|
1634
2181
|
|
|
1635
|
-
## 17.
|
|
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
|
|
1636
2426
|
|
|
1637
2427
|
The short list an independent verification of v0.1.0 said a downstream
|
|
1638
2428
|
conversion has to carry. Everything here is documented somewhere above; this is
|
|
@@ -1683,3 +2473,51 @@ the page to read before the conversion rather than after it.
|
|
|
1683
2473
|
`ConfigProvider` service.
|
|
1684
2474
|
- **`SuiError.describe` covers `GraphQLUnavailable` and `ExtensionNotReady`**,
|
|
1685
2475
|
and `Script.run` prints them like any other tag.
|
|
2476
|
+
- **Write a `PromiseFace<Service>` type test per namespace**, and put `test` in
|
|
2477
|
+
the package `tsconfig`'s `include` so those pins actually compile. A namespace
|
|
2478
|
+
may be an `interface`; since 0.1.1 the face maps it the same as a type alias,
|
|
2479
|
+
so the local aliases a 0.1.0 conversion carried for this are unnecessary.
|
|
2480
|
+
- **A class instance with `Effect`-returning methods needs
|
|
2481
|
+
`SuiExtension.leaf`.** `Uint8Array`, `Date`, `Promise`, arrays and BCS codecs
|
|
2482
|
+
are leaves already; everything else with a prototype of its own is passed
|
|
2483
|
+
through by the runtime and must say so in the type.
|
|
2484
|
+
- **A cold call is a real `Promise`** that is also an `AsyncIterable`, and its
|
|
2485
|
+
rejection is pre-handled, so `expect(...).rejects` works and an un-awaited
|
|
2486
|
+
cold call cannot abort the test run.
|
|
2487
|
+
- **`outcome` survives `SuiError.toJson`** even as a class field; keep declaring
|
|
2488
|
+
it as one.
|
|
2489
|
+
- **`warm` throws *any* layer failure synchronously out of `$extend`**, not only
|
|
2490
|
+
an asynchronous step or a missing chain id.
|
|
2491
|
+
- **Two copies of `@mysten/sui` are still a bug**, but `mapSdkError` duck-types
|
|
2492
|
+
the SDK's error classes now, so `ObjectNotFound` survives it and one warning
|
|
2493
|
+
names the real problem. Fix the duplication anyway: BCS codecs and
|
|
2494
|
+
`Transaction` inputs have no such fallback.
|
|
2495
|
+
- **`SuiAddress.normalize` / `ObjectId.normalize`** take `"0x1"`; `.make` does
|
|
2496
|
+
not, and never will, because it validates without decoding.
|
|
2497
|
+
- **`bun install --force` after re-packing a vendored tarball** with the same
|
|
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.
|