@unconfirmed/sui-effect 0.1.0 → 0.1.1
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 +16 -6
- package/CHANGELOG.md +40 -0
- package/LLMS.md +1609 -1098
- package/README.md +347 -3
- package/dist/domain/bcs.d.ts +44 -0
- package/dist/domain/bcs.d.ts.map +1 -1
- package/dist/domain/bcs.js +57 -0
- package/dist/domain/bcs.js.map +1 -1
- package/dist/domain/errors.d.ts +63 -21
- package/dist/domain/errors.d.ts.map +1 -1
- package/dist/domain/errors.js +35 -7
- package/dist/domain/errors.js.map +1 -1
- package/dist/domain/executed.d.ts +54 -18
- package/dist/domain/executed.d.ts.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 +207 -66
- package/dist/domain/schemas.d.ts.map +1 -1
- package/dist/domain/schemas.js +62 -2
- 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/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 +12 -0
- package/dist/services/SuiCoreFake.d.ts.map +1 -1
- package/dist/services/SuiCoreFake.js +6 -1
- 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 +3 -1
- package/dist/services/Tx.d.ts.map +1 -1
- package/dist/testing.d.ts +21 -3
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +30 -3
- package/dist/testing.js.map +1 -1
- package/docs/extensions.md +338 -25
- package/examples/extension-template/test/escrow.test.ts +91 -2
- package/package.json +3 -2
package/docs/extensions.md
CHANGED
|
@@ -283,6 +283,28 @@ export const SettlementContent = (typeOrigin: string) =>
|
|
|
283
283
|
)
|
|
284
284
|
```
|
|
285
285
|
|
|
286
|
+
When the mapping is a plain function that may throw — a constructor, a
|
|
287
|
+
`BigInt(...)`, a branding call — `SuiSchema.decodeWith(layout, type, map)` is
|
|
288
|
+
that composition in one call:
|
|
289
|
+
|
|
290
|
+
<!-- inline -->
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
const EscrowContent = (typeOrigin: string) =>
|
|
294
|
+
SuiSchema.decodeWith(
|
|
295
|
+
EscrowLayout,
|
|
296
|
+
escrowType(typeOrigin),
|
|
297
|
+
(raw) => new Escrow(ObjectId.normalize(raw.id), BigInt(raw.amount))
|
|
298
|
+
)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
A throw inside `map` becomes the `DecodeError` the caller already handles,
|
|
302
|
+
carrying the expected type — which is the part that gets forgotten when the same
|
|
303
|
+
thing is written as `Effect.try` around `Schema.decodeUnknownEffect`, where the
|
|
304
|
+
throw arrives as a defect instead. The codec still carries the Move type, so
|
|
305
|
+
`sui.getObject(id, { schema })` checks the tag before it parses. There is no
|
|
306
|
+
encoder: a mapping function has no inverse, so serialize with the layout itself.
|
|
307
|
+
|
|
286
308
|
The domain class is an ordinary `Schema.Class`:
|
|
287
309
|
|
|
288
310
|
<!-- from: examples/extension-template/src/schema.ts -->
|
|
@@ -323,6 +345,39 @@ comparing `address::module::name` only. Give it a tag that *carries* type
|
|
|
323
345
|
arguments and it is compared in full, after normalization, so
|
|
324
346
|
`Coin<0x2::sui::SUI>` does not accept `Coin<…::usdc::USDC>`.
|
|
325
347
|
|
|
348
|
+
Worked, on owned objects, because that is where it pays: one codec and one bare
|
|
349
|
+
tag serve every instantiation a wallet holds, including on the fake.
|
|
350
|
+
|
|
351
|
+
<!-- inline -->
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
// One codec for every `Composition<T>`, built from the type origin.
|
|
355
|
+
const compositionType = (typeOrigin: string) => `${typeOrigin}::composition::Composition`
|
|
356
|
+
const CompositionContent = (typeOrigin: string) =>
|
|
357
|
+
SuiSchema.bcs(CompositionLayout, compositionType(typeOrigin))
|
|
358
|
+
|
|
359
|
+
// Every composition an address owns, whatever it is parameterized by.
|
|
360
|
+
const owned = (owner: SuiAddress) =>
|
|
361
|
+
sui.streamOwnedObjects(owner, {
|
|
362
|
+
type: compositionType(typeOrigin),
|
|
363
|
+
schema: CompositionContent(typeOrigin)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
// In a test, the fake filters with the same rule, so objects whose `type` is
|
|
367
|
+
// the instantiated tag are served for the bare one:
|
|
368
|
+
const script = {
|
|
369
|
+
objects: [
|
|
370
|
+
{ objectId: FIRST, type: `${ORIGIN}::composition::Composition<${ORIGIN}::share::Share>`, … },
|
|
371
|
+
{ objectId: SECOND, type: `${ORIGIN}::composition::Composition<0x2::sui::SUI>`, … }
|
|
372
|
+
]
|
|
373
|
+
}
|
|
374
|
+
// `owned(address)` yields both, each decoded by the one codec.
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
The instantiation is not lost: each object keeps its own tag on
|
|
378
|
+
`SuiObject.type`, so a member that cares which one it read still can. What the
|
|
379
|
+
bare tag does is stop you writing a codec per type argument.
|
|
380
|
+
|
|
326
381
|
The same rule holds everywhere a Move type is compared: the `expectedType`
|
|
327
382
|
option of `getObject` / `getObjectOption` / `getObjects`, `SuiSchema.decode`'s
|
|
328
383
|
`actualType`, the `type` filter of `streamOwnedObjects`, and the fake's filter
|
|
@@ -434,8 +489,23 @@ becomes a typed `DecodeError`; `.make` on it is a defect in a member whose error
|
|
|
434
489
|
union says it cannot fail.
|
|
435
490
|
|
|
436
491
|
They also validate rather than normalize: `SuiAddress.make("0x1")` throws,
|
|
437
|
-
because `0x1` is not a 32-byte address.
|
|
438
|
-
|
|
492
|
+
because `0x1` is not a 32-byte address. **`SuiAddress.normalize` and
|
|
493
|
+
`ObjectId.normalize` are the pair that take the shorthand**: they run the
|
|
494
|
+
schema's own decode (`normalizeSuiAddress`) and then brand, so `"0x1"`, an
|
|
495
|
+
unpadded hex string and the padded form all produce the same branded value.
|
|
496
|
+
|
|
497
|
+
<!-- inline -->
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
const treasury = SuiAddress.normalize("0x2") // 0x0000…0002, branded
|
|
501
|
+
const clock = ObjectId.normalize("0x6")
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
They throw, exactly like `.make`, so they are still for literals and
|
|
505
|
+
configuration **you** control — a deployment constant, a CLI flag you already
|
|
506
|
+
validated. Anything that arrived from a node, a user or an upstream package goes
|
|
507
|
+
through `Schema.decodeUnknownEffect(ObjectId)` and becomes a typed
|
|
508
|
+
`DecodeError`.
|
|
439
509
|
|
|
440
510
|
When the error you would build *needs a field you do not have* — a `DecodeError`
|
|
441
511
|
wants an `objectId` and you are decoding an event payload with no object — that
|
|
@@ -815,7 +885,8 @@ different extensions on the same client. After that:
|
|
|
815
885
|
- an `Effect` member is a zero-argument method returning a `Promise`;
|
|
816
886
|
- a function returning an `Effect` keeps its arguments and returns a `Promise`;
|
|
817
887
|
- a `Stream` is an `AsyncIterable`, usable in `for await`;
|
|
818
|
-
- a nested namespace is mapped recursively
|
|
888
|
+
- a nested namespace is mapped recursively, **including one typed as an
|
|
889
|
+
`interface`** — the recursion is by type, not by how the member was declared;
|
|
819
890
|
- a plain value passes through;
|
|
820
891
|
- a rejection is **the same tagged error instance**, so a Promise consumer can
|
|
821
892
|
still switch on `_tag` and read `outcome`;
|
|
@@ -860,6 +931,15 @@ export const platform = (options: PlatformRegistrationOptions) =>
|
|
|
860
931
|
})
|
|
861
932
|
```
|
|
862
933
|
|
|
934
|
+
**A `warm` registration runs the whole layer synchronously, so every failure of
|
|
935
|
+
that layer is thrown out of `$extend`.** Not only an asynchronous step and not
|
|
936
|
+
only a missing chain id: a deployment your bundle does not have for this
|
|
937
|
+
network, a `ConfigError`, a `NetworkMismatch`, anything the layer declares.
|
|
938
|
+
There is no first call to reject, because the layer is built before `register`
|
|
939
|
+
returns. Catch it where you register, and say so in your registration's JSDoc.
|
|
940
|
+
The mirror image is the lazy default, where the layer's failure surfaces as the
|
|
941
|
+
rejection of whatever call needed it first.
|
|
942
|
+
|
|
863
943
|
`warm` has two conditions and both are enforced. The layer must not perform an
|
|
864
944
|
asynchronous step — a layer that reads the network at build cannot be built
|
|
865
945
|
synchronously and `register` throws. And the chain identifier is **taken, not
|
|
@@ -883,26 +963,132 @@ it is what lets every registration on one client agree — see the next
|
|
|
883
963
|
paragraph. The template has a test for the warm face on a network with no
|
|
884
964
|
built-in chain id; a conversion should have one too.
|
|
885
965
|
|
|
966
|
+
**What a cold call actually is.** The value a member call returns before the
|
|
967
|
+
runtime exists is a real `Promise` subclass that also implements
|
|
968
|
+
`Symbol.asyncIterator`, because nothing yet knows whether the member was an
|
|
969
|
+
`Effect` (a Promise) or a `Stream` (an `AsyncIterable`). So `instanceof Promise`
|
|
970
|
+
holds, `for await` works, and in `bun:test`
|
|
971
|
+
`await expect(client.ext.thing()).rejects.toBeInstanceOf(ExtensionNotReady)`
|
|
972
|
+
does what it looks like. (In 0.1.0 it was a bare thenable and `.rejects` did not
|
|
973
|
+
recognise it; `await ... .catch()` was the workaround and is no longer needed.)
|
|
974
|
+
|
|
975
|
+
Its rejection is also **pre-handled**: a cold call nobody awaits —
|
|
976
|
+
`client.ext.doThing()` written as a statement — rejects with
|
|
977
|
+
`ExtensionNotReady` into a no-op catch rather than aborting the process on an
|
|
978
|
+
unhandled rejection. Your own `await` still throws. Write the test that proves
|
|
979
|
+
this for your own face; it is the one failure mode that kills a test run rather
|
|
980
|
+
than failing a test.
|
|
981
|
+
|
|
982
|
+
And **`$dispose()` keeps a warm registration warm**: the next use re-runs the
|
|
983
|
+
same warm build rather than leaving every synchronous member throwing
|
|
984
|
+
`ExtensionNotReady` forever after.
|
|
985
|
+
|
|
886
986
|
If your extension's surface is entirely `Effect` and `Stream` members, none of
|
|
887
987
|
this applies: the lazy default is right and the first `await` builds everything.
|
|
888
988
|
|
|
889
|
-
###
|
|
989
|
+
### Namespaces, leaves, and the one member that still lies
|
|
990
|
+
|
|
991
|
+
The face recurses into object-typed members — that is what makes
|
|
992
|
+
`client.platform.escrow.get(id)` work — and **the recursion is by type, not by
|
|
993
|
+
declaration style**. An `interface`-typed namespace (`readonly escrow:
|
|
994
|
+
EscrowService`) is mapped exactly like an inline object literal. In 0.1.0 it was
|
|
995
|
+
not: the type's bound was `Record<string, unknown>`, which an interface is not
|
|
996
|
+
assignable to, so an interface-typed namespace kept its `Effect` members **in
|
|
997
|
+
the type** while the runtime mapped them to Promises. If you carried a local
|
|
998
|
+
type alias to work around that, delete it.
|
|
999
|
+
|
|
1000
|
+
These are the leaves — passed through whole, in the type and at runtime alike:
|
|
1001
|
+
functions, arrays, `Uint8Array`, `Date`, `Promise`, and a BCS codec (anything
|
|
1002
|
+
with both `parse` and `serialize`, which is every `BcsType`). Exposing a codec
|
|
1003
|
+
as a member is safe.
|
|
890
1004
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1005
|
+
For **any other class instance** — a `Schema.Class` instance, a policy object,
|
|
1006
|
+
anything with methods of its own — say so:
|
|
1007
|
+
|
|
1008
|
+
<!-- inline -->
|
|
1009
|
+
|
|
1010
|
+
```ts
|
|
1011
|
+
import { SuiExtension } from "@unconfirmed/sui-effect/extension"
|
|
1012
|
+
|
|
1013
|
+
interface MyService {
|
|
1014
|
+
readonly policy: SuiExtension.Leaf<Policy>
|
|
1015
|
+
}
|
|
1016
|
+
// in the layer:
|
|
1017
|
+
return { policy: SuiExtension.leaf(new Policy()) }
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
`Leaf<T>` **is** a `T`, so the Effect face is unaffected; what it does is tell
|
|
1021
|
+
the Promise face that this member is a value rather than a namespace of members.
|
|
1022
|
+
Without it the type would recurse into the class while the runtime passes class
|
|
1023
|
+
instances through untouched, and for a class whose methods return `Effect`s that
|
|
1024
|
+
is the same lie in the other direction.
|
|
1025
|
+
|
|
1026
|
+
The remaining disagreement is the one that cannot be resolved by types at all. A
|
|
1027
|
+
**plain-object value** member — `deployment: { packageId }` — is
|
|
1028
|
+
indistinguishable from a namespace of members, so the type maps it as the value
|
|
1029
|
+
while the **cold** face treats it as a namespace and hands back a placeholder
|
|
1030
|
+
for `deployment.packageId`. Reading that placeholder throws `ExtensionNotReady`
|
|
1031
|
+
naming the path, so it is typed and named rather than silent, but it is still a
|
|
1032
|
+
disagreement. Either register `warm` (or `await $ready()`), or expose the value
|
|
1033
|
+
through an `Effect` member. Do not put a plain-object value member on a service
|
|
1034
|
+
that consumers will register lazily.
|
|
1035
|
+
|
|
1036
|
+
**Write a `PromiseFace<Service>` type test per namespace.** It is four lines, it
|
|
1037
|
+
is the only thing that catches a face type that has drifted from the runtime,
|
|
1038
|
+
and the template has one to copy:
|
|
1039
|
+
|
|
1040
|
+
<!-- from: examples/extension-template/test/escrow.test.ts -->
|
|
1041
|
+
|
|
1042
|
+
```ts
|
|
1043
|
+
describe("the Promise face type", () => {
|
|
1044
|
+
/** Compile-time assignability, as a value a test can assert on. */
|
|
1045
|
+
const assignableTo = <_A extends _B, _B>(): true => true
|
|
896
1046
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1047
|
+
type EscrowFace = PromiseFace<EscrowService>
|
|
1048
|
+
type PlatformFace = PromiseFace<PlatformService>
|
|
1049
|
+
|
|
1050
|
+
test("an Effect member becomes a Promise-returning method", () => {
|
|
1051
|
+
expect(assignableTo<EscrowFace["get"], (id: ObjectId) => Promise<EscrowObject>>()).toBe(true)
|
|
1052
|
+
expect(assignableTo<EscrowFace["feeCollector"], () => Promise<SuiAddress>>()).toBe(true)
|
|
1053
|
+
})
|
|
1054
|
+
|
|
1055
|
+
test("a Stream member becomes an AsyncIterable", () => {
|
|
1056
|
+
expect(
|
|
1057
|
+
assignableTo<EscrowFace["owned"]["stream"], (owner: SuiAddress) => AsyncIterable<EscrowObject>>()
|
|
1058
|
+
).toBe(true)
|
|
1059
|
+
})
|
|
1060
|
+
|
|
1061
|
+
test("a synchronous member stays synchronous", () => {
|
|
1062
|
+
expect(assignableTo<EscrowFace["packageId"], string>()).toBe(true)
|
|
1063
|
+
expect(assignableTo<EscrowFace["claim"], (escrow: EscrowObject) => Recipe>()).toBe(true)
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
test("an interface-typed namespace is mapped all the way down", () => {
|
|
1067
|
+
// `PlatformService.escrow` is `EscrowService`, an interface. The members
|
|
1068
|
+
// reached through it must be the mapped ones, not the Effect ones.
|
|
1069
|
+
expect(assignableTo<PlatformFace["escrow"]["get"], (id: ObjectId) => Promise<EscrowObject>>())
|
|
1070
|
+
.toBe(true)
|
|
1071
|
+
expect(
|
|
1072
|
+
assignableTo<
|
|
1073
|
+
PlatformFace["escrow"]["owned"]["count"],
|
|
1074
|
+
(owner: SuiAddress) => Promise<number>
|
|
1075
|
+
>()
|
|
1076
|
+
).toBe(true)
|
|
1077
|
+
expect(assignableTo<PlatformFace["escrow"]["packageId"], string>()).toBe(true)
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1080
|
+
test("the composition's own member is mapped too", () => {
|
|
1081
|
+
expect(
|
|
1082
|
+
assignableTo<
|
|
1083
|
+
PlatformFace["claimEverything"],
|
|
1084
|
+
(ids: ReadonlyArray<ObjectId>, opts: { readonly signer: Signer }) => Promise<
|
|
1085
|
+
ReadonlyArray<ChangedRef>
|
|
1086
|
+
>
|
|
1087
|
+
>()
|
|
1088
|
+
).toBe(true)
|
|
1089
|
+
})
|
|
1090
|
+
})
|
|
1091
|
+
```
|
|
906
1092
|
|
|
907
1093
|
### The rest of the contract
|
|
908
1094
|
|
|
@@ -1011,6 +1197,51 @@ The same shape holds for a dependency that is not an extension at all — a
|
|
|
1011
1197
|
`SuiGraphQL` client, an `HttpClient`, your own operator service: yield it in
|
|
1012
1198
|
`make`, provide its layer in `layer`.
|
|
1013
1199
|
|
|
1200
|
+
#### A standalone function that needs a sibling extension
|
|
1201
|
+
|
|
1202
|
+
A service member yields its dependencies; a **standalone exported function**
|
|
1203
|
+
has no layer of its own, and the temptation is to build the sibling's layer
|
|
1204
|
+
inside it on every call or, worse, to keep a module-level `ManagedRuntime`.
|
|
1205
|
+
Neither is sanctioned. There are exactly two shapes, and the first is the
|
|
1206
|
+
default:
|
|
1207
|
+
|
|
1208
|
+
<!-- inline -->
|
|
1209
|
+
|
|
1210
|
+
```ts
|
|
1211
|
+
// 1. Take the sibling's service as a parameter. The caller already holds it —
|
|
1212
|
+
// it is in a member's `Effect.gen`, or in a script that provided the layer —
|
|
1213
|
+
// and the function stays `R = Sui`, testable with the sibling's test layer
|
|
1214
|
+
// and nothing else.
|
|
1215
|
+
export const settleAll = Effect.fn("settleAll")(function*(
|
|
1216
|
+
escrow: EscrowService,
|
|
1217
|
+
ids: ReadonlyArray<ObjectId>,
|
|
1218
|
+
opts: { readonly signer: Signer }
|
|
1219
|
+
) {
|
|
1220
|
+
const settled: Array<ChangedRef> = []
|
|
1221
|
+
for (const id of ids) settled.push(yield* escrow.claimFor(id, opts))
|
|
1222
|
+
return settled
|
|
1223
|
+
})
|
|
1224
|
+
|
|
1225
|
+
// 2. Require it, and let the caller provide it once. Use this when the function
|
|
1226
|
+
// is part of a surface whose consumers already hold the layer.
|
|
1227
|
+
export const settleAllOwned = Effect.fn("settleAllOwned")(function*(
|
|
1228
|
+
owner: SuiAddress,
|
|
1229
|
+
opts: { readonly signer: Signer }
|
|
1230
|
+
) {
|
|
1231
|
+
const escrow = yield* Escrow // R = Sui | Escrow
|
|
1232
|
+
…
|
|
1233
|
+
})
|
|
1234
|
+
```
|
|
1235
|
+
|
|
1236
|
+
What not to do: `Layer.build` (or `Effect.provide(Escrow.layer(options))`)
|
|
1237
|
+
**inside** the function body. It is one layer build per call — a fresh cache, a
|
|
1238
|
+
fresh connection, a fresh sender lock for whatever the sibling holds — and the
|
|
1239
|
+
options have to come from somewhere, which is how a package id ends up read from
|
|
1240
|
+
`process.env` three files away from the service that owns it. If a function
|
|
1241
|
+
genuinely needs to build the sibling itself, build it **once** in the function
|
|
1242
|
+
that builds the registration and close over the service, the way `Platform.layer`
|
|
1243
|
+
does with `Layer.provide`.
|
|
1244
|
+
|
|
1014
1245
|
## 9. Wrapping an upstream Promise package
|
|
1015
1246
|
|
|
1016
1247
|
For upstream SDKs we do not own (suins, deepbook, whatever comes next) we do not
|
|
@@ -1083,9 +1314,12 @@ release.
|
|
|
1083
1314
|
|
|
1084
1315
|
### If your extension reads GraphQL
|
|
1085
1316
|
|
|
1086
|
-
`SuiGraphQL` is a tag over the SDK's own client, not a wrapper
|
|
1087
|
-
|
|
1088
|
-
|
|
1317
|
+
`SuiGraphQL` is a tag over the SDK's own client, not a wrapper: `yield*
|
|
1318
|
+
SuiGraphQL` hands back the `SuiGraphQLClient` that was passed to
|
|
1319
|
+
`SuiGraphQL.layer(client)`, so the service type is `SuiGraphQL["Service"]`
|
|
1320
|
+
(which *is* `SuiGraphQLClient`) and that is what a helper taking it as a
|
|
1321
|
+
parameter should be typed with. `SuiGraphQL.query(run, method?)` is the one call
|
|
1322
|
+
that sorts out the two failures:
|
|
1089
1323
|
|
|
1090
1324
|
<!-- inline -->
|
|
1091
1325
|
|
|
@@ -1139,6 +1373,19 @@ const provide = <A, E>(
|
|
|
1139
1373
|
So a test exercises the production high tier: the include sets, the BCS bridge,
|
|
1140
1374
|
the chunked batch reads, the sender lock and every `Tx` step.
|
|
1141
1375
|
|
|
1376
|
+
It also provides **`SuiGraphQL.layerUnavailable`**, so an extension that reads
|
|
1377
|
+
GraphQL builds in a test with no endpoint and every GraphQL call fails with
|
|
1378
|
+
`GraphQLUnavailable` — the failure it already handles. Anything else your layer
|
|
1379
|
+
requires that the client could not have given it goes in the third argument:
|
|
1380
|
+
|
|
1381
|
+
<!-- inline -->
|
|
1382
|
+
|
|
1383
|
+
```ts
|
|
1384
|
+
const layer = layerExtensionTest(Escrow.layerTest(), script, {
|
|
1385
|
+
extra: SuiGraphQL.layer(scriptedClient) // or an HttpClient, an operator service…
|
|
1386
|
+
})
|
|
1387
|
+
```
|
|
1388
|
+
|
|
1142
1389
|
The `script` is what the fake serves — objects with real BCS content, gas coins,
|
|
1143
1390
|
and scripted outcomes for simulate, execute and `getTransaction`:
|
|
1144
1391
|
|
|
@@ -1167,6 +1414,19 @@ const script = {
|
|
|
1167
1414
|
}
|
|
1168
1415
|
```
|
|
1169
1416
|
|
|
1417
|
+
**`waitForTransaction` is scripted through `getTransaction`.** There is no
|
|
1418
|
+
separate knob: `Tx.submit` waits by polling `getTransaction`, so the ordered
|
|
1419
|
+
`FakeScript.getTransaction` outcomes (and `FakeScript.transactions` /
|
|
1420
|
+
`SuiTest.recordTransaction` for answers keyed by digest) are what decide whether
|
|
1421
|
+
a wait succeeds, times out or reports the transaction missing. A test that wants
|
|
1422
|
+
"executed, then not visible for two polls, then visible" scripts exactly that
|
|
1423
|
+
list.
|
|
1424
|
+
|
|
1425
|
+
`FakeScript.coinMetadata` is the same idea for `getCoinMetadata`: a record keyed
|
|
1426
|
+
by coin type, with an unscripted type answering `{ coinMetadata: null }` the way
|
|
1427
|
+
a node does. Unscripted entirely, the method dies naming itself, like every
|
|
1428
|
+
other method the script does not cover.
|
|
1429
|
+
|
|
1170
1430
|
`SuiTest` drives the fake from inside an `Effect`: `putObject`, `bumpVersion`,
|
|
1171
1431
|
`deleteObject`, `setClock` (the chain's clock, which is what `Tx.build` bounds a
|
|
1172
1432
|
transaction against — Effect's `TestClock` drives the program's own time),
|
|
@@ -1350,7 +1610,14 @@ extension exits with the code a wrapper can act on — 5 applied, 4 not applied,
|
|
|
1350
1610
|
`SuiError.toJson` serializes your errors too. A tag in @unconfirmed/sui-effect's own taxonomy
|
|
1351
1611
|
encodes through the taxonomy's schema; **anything else that is a
|
|
1352
1612
|
`Schema.TaggedError` encodes through its own**, so an extension error arrives as
|
|
1353
|
-
`{ _tag, escrowId, outcome }` rather than a bare `{ _tag, message }`.
|
|
1613
|
+
`{ _tag, escrowId, outcome }` rather than a bare `{ _tag, message }`.
|
|
1614
|
+
|
|
1615
|
+
**`outcome` is in that JSON even though it is a class field.** Declaring it the
|
|
1616
|
+
way the template does — `readonly outcome: Outcome = "unknown"` beside the
|
|
1617
|
+
schema fields — keeps the call site clean, and a class field is not part of the
|
|
1618
|
+
schema, so encoding alone would drop exactly the field a wrapper script acts on.
|
|
1619
|
+
`toJson` reads it off the instance and puts it back. Keep declaring it as a
|
|
1620
|
+
field; there is nothing to change in your errors. That is
|
|
1354
1621
|
what makes a structured log of a failed run useful, and it is a reason to give
|
|
1355
1622
|
every field of an error a schema rather than stuffing detail into a string.
|
|
1356
1623
|
|
|
@@ -1371,9 +1638,15 @@ functions beside it is a different job, and the order that works is this.
|
|
|
1371
1638
|
|
|
1372
1639
|
1. **Inventory the namespaces first.** List what consumers actually call,
|
|
1373
1640
|
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.
|
|
1641
|
+
your service interface, and a group is a member on it — an `interface` is
|
|
1642
|
+
fine, the face maps it either way. Write the interface before you move any
|
|
1643
|
+
code: it is the only artefact the conversion is reviewed against.
|
|
1644
|
+
|
|
1645
|
+
**Grep for captured aliases, not only for dotted calls.** A consumer that
|
|
1646
|
+
writes `const party = client.miso.party` and then `party.join(...)` does not
|
|
1647
|
+
appear in a search for `client.miso.party.join`, and a namespace that looks
|
|
1648
|
+
unused gets dropped from the interface. Search for the namespace name on its
|
|
1649
|
+
own (`\bclient\.\w+\.party\b`, `= .*\.party\b`) as well as for the calls.
|
|
1377
1650
|
2. **Keep the standalone functions.** An existing `Effect<A, E, Sui>` function
|
|
1378
1651
|
that is exported and used outside the facade stays exported and keeps its
|
|
1379
1652
|
signature. Do not make consumers hold a service to call something that never
|
|
@@ -1500,6 +1773,10 @@ Reject an extension that:
|
|
|
1500
1773
|
carries, prefix included;
|
|
1501
1774
|
- leaves `tests` out of the package `tsconfig`'s `include`, so its type-level
|
|
1502
1775
|
pins never compile;
|
|
1776
|
+
- has no `PromiseFace<Service>` type test per namespace, or has one that only
|
|
1777
|
+
checks the top level;
|
|
1778
|
+
- exposes a class instance with `Effect`-returning methods without
|
|
1779
|
+
`SuiExtension.Leaf<T>` / `SuiExtension.leaf(value)`;
|
|
1503
1780
|
- calls `.make` on a branded schema with a value that came from outside;
|
|
1504
1781
|
- promises a `ConfigError` for an empty environment variable it reads with
|
|
1505
1782
|
`Config.option`.
|
|
@@ -1616,6 +1893,19 @@ old one) before installing: a file that stopped shipping is caught there rather
|
|
|
1616
1893
|
than in a consumer. Record the sui-effect commit or tag the vendor copy came
|
|
1617
1894
|
from.
|
|
1618
1895
|
|
|
1896
|
+
**And re-install with `--force`.** bun keys a file dependency by name and
|
|
1897
|
+
version, not by content, so re-packing over `vendor/unconfirmed-sui-effect-0.1.1.tgz`
|
|
1898
|
+
and running `bun install` again leaves the *old* extraction in `node_modules` —
|
|
1899
|
+
silently, and for as long as it takes you to notice that a fix you just made is
|
|
1900
|
+
not there. Either bump the filename (`…-0.1.1+2.tgz`) or:
|
|
1901
|
+
|
|
1902
|
+
```bash
|
|
1903
|
+
bun install --force
|
|
1904
|
+
```
|
|
1905
|
+
|
|
1906
|
+
Verify it took: `cat node_modules/@unconfirmed/sui-effect/package.json | grep version`, or
|
|
1907
|
+
grep the shipped `dist` for the change you are looking for.
|
|
1908
|
+
|
|
1619
1909
|
**Until the first publish, bun probes the registry for every peer.** It does so
|
|
1620
1910
|
even for a peer a local dependency already satisfies, and an unpublished name
|
|
1621
1911
|
404s the install. The escape is
|
|
@@ -1683,3 +1973,26 @@ the page to read before the conversion rather than after it.
|
|
|
1683
1973
|
`ConfigProvider` service.
|
|
1684
1974
|
- **`SuiError.describe` covers `GraphQLUnavailable` and `ExtensionNotReady`**,
|
|
1685
1975
|
and `Script.run` prints them like any other tag.
|
|
1976
|
+
- **Write a `PromiseFace<Service>` type test per namespace**, and put `test` in
|
|
1977
|
+
the package `tsconfig`'s `include` so those pins actually compile. A namespace
|
|
1978
|
+
may be an `interface`; since 0.1.1 the face maps it the same as a type alias,
|
|
1979
|
+
so the local aliases a 0.1.0 conversion carried for this are unnecessary.
|
|
1980
|
+
- **A class instance with `Effect`-returning methods needs
|
|
1981
|
+
`SuiExtension.leaf`.** `Uint8Array`, `Date`, `Promise`, arrays and BCS codecs
|
|
1982
|
+
are leaves already; everything else with a prototype of its own is passed
|
|
1983
|
+
through by the runtime and must say so in the type.
|
|
1984
|
+
- **A cold call is a real `Promise`** that is also an `AsyncIterable`, and its
|
|
1985
|
+
rejection is pre-handled, so `expect(...).rejects` works and an un-awaited
|
|
1986
|
+
cold call cannot abort the test run.
|
|
1987
|
+
- **`outcome` survives `SuiError.toJson`** even as a class field; keep declaring
|
|
1988
|
+
it as one.
|
|
1989
|
+
- **`warm` throws *any* layer failure synchronously out of `$extend`**, not only
|
|
1990
|
+
an asynchronous step or a missing chain id.
|
|
1991
|
+
- **Two copies of `@mysten/sui` are still a bug**, but `mapSdkError` duck-types
|
|
1992
|
+
the SDK's error classes now, so `ObjectNotFound` survives it and one warning
|
|
1993
|
+
names the real problem. Fix the duplication anyway: BCS codecs and
|
|
1994
|
+
`Transaction` inputs have no such fallback.
|
|
1995
|
+
- **`SuiAddress.normalize` / `ObjectId.normalize`** take `"0x1"`; `.make` does
|
|
1996
|
+
not, and never will, because it validates without decoding.
|
|
1997
|
+
- **`bun install --force` after re-packing a vendored tarball** with the same
|
|
1998
|
+
filename and version, or bun keeps the old extraction.
|
|
@@ -22,13 +22,16 @@ import {
|
|
|
22
22
|
Stream
|
|
23
23
|
} from "effect"
|
|
24
24
|
import { TestClock } from "effect/testing"
|
|
25
|
-
import type { Sui, SuiCore } from "@unconfirmed/sui-effect"
|
|
26
|
-
import { KNOWN_CHAIN_IDS, ObjectId, SuiAddress, SuiSchema } from "@unconfirmed/sui-effect"
|
|
25
|
+
import type { ChangedRef, Recipe, Sui, SuiCore } from "@unconfirmed/sui-effect"
|
|
26
|
+
import { KNOWN_CHAIN_IDS, ObjectId, SuiAddress, SuiError, SuiSchema } from "@unconfirmed/sui-effect"
|
|
27
|
+
import type { PromiseFace } from "@unconfirmed/sui-effect/extension"
|
|
27
28
|
import { FakeOutcome, layerExtensionTest, layerTest, SuiCoreFake, SuiTest } from "@unconfirmed/sui-effect/testing"
|
|
28
29
|
import { Journal, Signer } from "@unconfirmed/sui-effect/tx"
|
|
30
|
+
import type { EscrowObject, EscrowService } from "../src/Escrow.ts"
|
|
29
31
|
import { DEPLOYMENTS, Escrow } from "../src/Escrow.ts"
|
|
30
32
|
import { escrow as escrowRegistration } from "../src/extension.ts"
|
|
31
33
|
import { EscrowNotFound, EscrowSettlementUnknown, EscrowUnsupportedNetwork } from "../src/errors.ts"
|
|
34
|
+
import type { PlatformService } from "../src/Platform.ts"
|
|
32
35
|
import { Platform, platform as platformRegistration } from "../src/Platform.ts"
|
|
33
36
|
import { ESCROW_PACKAGE, receiptType, Settlement, SettlementContent } from "../src/schema.ts"
|
|
34
37
|
|
|
@@ -557,3 +560,89 @@ describe("layerConfig validates through the typed deployment path", () => {
|
|
|
557
560
|
expect(String(exit)).toContain("32-byte Sui object id")
|
|
558
561
|
})
|
|
559
562
|
})
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The face's **type**, asserted rather than described.
|
|
566
|
+
*
|
|
567
|
+
* This is the test every extension copies. `PromiseFace<Service>` is what a
|
|
568
|
+
* Promise consumer actually holds, and it is derived, so nothing in the service
|
|
569
|
+
* says out loud what it produced: an `Effect` member has to become a
|
|
570
|
+
* Promise-returning method, a `Stream` member an `AsyncIterable`, a synchronous
|
|
571
|
+
* member has to stay synchronous, and a **namespace has to be mapped all the
|
|
572
|
+
* way down** — `PlatformService.escrow` is an interface, and until 0.1.1 an
|
|
573
|
+
* interface-typed namespace kept its `Effect` members in the type while the
|
|
574
|
+
* runtime handed back Promises.
|
|
575
|
+
*
|
|
576
|
+
* Write one of these per namespace. It costs four lines and it is the only
|
|
577
|
+
* thing that catches a face type that has quietly stopped matching the runtime.
|
|
578
|
+
*/
|
|
579
|
+
describe("the Promise face type", () => {
|
|
580
|
+
/** Compile-time assignability, as a value a test can assert on. */
|
|
581
|
+
const assignableTo = <_A extends _B, _B>(): true => true
|
|
582
|
+
|
|
583
|
+
type EscrowFace = PromiseFace<EscrowService>
|
|
584
|
+
type PlatformFace = PromiseFace<PlatformService>
|
|
585
|
+
|
|
586
|
+
test("an Effect member becomes a Promise-returning method", () => {
|
|
587
|
+
expect(assignableTo<EscrowFace["get"], (id: ObjectId) => Promise<EscrowObject>>()).toBe(true)
|
|
588
|
+
expect(assignableTo<EscrowFace["feeCollector"], () => Promise<SuiAddress>>()).toBe(true)
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
test("a Stream member becomes an AsyncIterable", () => {
|
|
592
|
+
expect(
|
|
593
|
+
assignableTo<EscrowFace["owned"]["stream"], (owner: SuiAddress) => AsyncIterable<EscrowObject>>()
|
|
594
|
+
).toBe(true)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test("a synchronous member stays synchronous", () => {
|
|
598
|
+
expect(assignableTo<EscrowFace["packageId"], string>()).toBe(true)
|
|
599
|
+
expect(assignableTo<EscrowFace["claim"], (escrow: EscrowObject) => Recipe>()).toBe(true)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
test("an interface-typed namespace is mapped all the way down", () => {
|
|
603
|
+
// `PlatformService.escrow` is `EscrowService`, an interface. The members
|
|
604
|
+
// reached through it must be the mapped ones, not the Effect ones.
|
|
605
|
+
expect(assignableTo<PlatformFace["escrow"]["get"], (id: ObjectId) => Promise<EscrowObject>>())
|
|
606
|
+
.toBe(true)
|
|
607
|
+
expect(
|
|
608
|
+
assignableTo<
|
|
609
|
+
PlatformFace["escrow"]["owned"]["count"],
|
|
610
|
+
(owner: SuiAddress) => Promise<number>
|
|
611
|
+
>()
|
|
612
|
+
).toBe(true)
|
|
613
|
+
expect(assignableTo<PlatformFace["escrow"]["packageId"], string>()).toBe(true)
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
test("the composition's own member is mapped too", () => {
|
|
617
|
+
expect(
|
|
618
|
+
assignableTo<
|
|
619
|
+
PlatformFace["claimEverything"],
|
|
620
|
+
(ids: ReadonlyArray<ObjectId>, opts: { readonly signer: Signer }) => Promise<
|
|
621
|
+
ReadonlyArray<ChangedRef>
|
|
622
|
+
>
|
|
623
|
+
>()
|
|
624
|
+
).toBe(true)
|
|
625
|
+
})
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* The errors serialize with their `outcome`, which is what a wrapper script
|
|
630
|
+
* acts on and what an operator reads out of a log line.
|
|
631
|
+
*/
|
|
632
|
+
describe("the errors", () => {
|
|
633
|
+
test("SuiError.toJson keeps the outcome, though it is a class field", () => {
|
|
634
|
+
const error = new EscrowSettlementUnknown({
|
|
635
|
+
escrowId: ESCROW_ID,
|
|
636
|
+
digest: "1".repeat(32) as never,
|
|
637
|
+
message: "the operator never confirmed"
|
|
638
|
+
})
|
|
639
|
+
const json = SuiError.toJson(error)
|
|
640
|
+
expect(json["_tag"]).toBe("escrow/EscrowSettlementUnknown")
|
|
641
|
+
expect(json["escrowId"]).toBe(ESCROW_ID)
|
|
642
|
+
// `outcome` is declared as a class field — not a schema field — because
|
|
643
|
+
// that is the shape that reads well at the call site. `toJson` reads it off
|
|
644
|
+
// the instance, so it is in the JSON anyway.
|
|
645
|
+
expect(json["outcome"]).toBe("unknown")
|
|
646
|
+
expect(json["outcome"]).toBe(SuiError.outcome(error))
|
|
647
|
+
})
|
|
648
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unconfirmed/sui-effect",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"files": [
|
|
34
34
|
"dist",
|
|
35
35
|
"README.md",
|
|
36
|
+
"CHANGELOG.md",
|
|
36
37
|
"LICENSE",
|
|
37
38
|
"LLMS.md",
|
|
38
39
|
"AGENTS.md",
|
|
@@ -53,7 +54,7 @@
|
|
|
53
54
|
},
|
|
54
55
|
"repository": {
|
|
55
56
|
"type": "git",
|
|
56
|
-
"url": "https://github.com/unconfirmedlabs/sui-effect"
|
|
57
|
+
"url": "git+https://github.com/unconfirmedlabs/sui-effect.git"
|
|
57
58
|
},
|
|
58
59
|
"keywords": [
|
|
59
60
|
"sui",
|