@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.
Files changed (53) hide show
  1. package/AGENTS.md +16 -6
  2. package/CHANGELOG.md +40 -0
  3. package/LLMS.md +1609 -1098
  4. package/README.md +347 -3
  5. package/dist/domain/bcs.d.ts +44 -0
  6. package/dist/domain/bcs.d.ts.map +1 -1
  7. package/dist/domain/bcs.js +57 -0
  8. package/dist/domain/bcs.js.map +1 -1
  9. package/dist/domain/errors.d.ts +63 -21
  10. package/dist/domain/errors.d.ts.map +1 -1
  11. package/dist/domain/errors.js +35 -7
  12. package/dist/domain/errors.js.map +1 -1
  13. package/dist/domain/executed.d.ts +54 -18
  14. package/dist/domain/executed.d.ts.map +1 -1
  15. package/dist/domain/journal-entry.d.ts +6 -2
  16. package/dist/domain/journal-entry.d.ts.map +1 -1
  17. package/dist/domain/schemas.d.ts +207 -66
  18. package/dist/domain/schemas.d.ts.map +1 -1
  19. package/dist/domain/schemas.js +62 -2
  20. package/dist/domain/schemas.js.map +1 -1
  21. package/dist/domain/sui-schema.d.ts +3 -2
  22. package/dist/domain/sui-schema.d.ts.map +1 -1
  23. package/dist/domain/sui-schema.js +3 -2
  24. package/dist/domain/sui-schema.js.map +1 -1
  25. package/dist/extension.d.ts +1 -1
  26. package/dist/extension.d.ts.map +1 -1
  27. package/dist/extension.js +1 -1
  28. package/dist/extension.js.map +1 -1
  29. package/dist/services/SuiCore.d.ts +12 -0
  30. package/dist/services/SuiCore.d.ts.map +1 -1
  31. package/dist/services/SuiCore.js +124 -0
  32. package/dist/services/SuiCore.js.map +1 -1
  33. package/dist/services/SuiCoreFake.d.ts +12 -0
  34. package/dist/services/SuiCoreFake.d.ts.map +1 -1
  35. package/dist/services/SuiCoreFake.js +6 -1
  36. package/dist/services/SuiCoreFake.js.map +1 -1
  37. package/dist/services/SuiExtension.d.ts +127 -14
  38. package/dist/services/SuiExtension.d.ts.map +1 -1
  39. package/dist/services/SuiExtension.js +125 -28
  40. package/dist/services/SuiExtension.js.map +1 -1
  41. package/dist/services/SuiGraphQL.d.ts +13 -0
  42. package/dist/services/SuiGraphQL.d.ts.map +1 -1
  43. package/dist/services/SuiGraphQL.js +13 -0
  44. package/dist/services/SuiGraphQL.js.map +1 -1
  45. package/dist/services/Tx.d.ts +3 -1
  46. package/dist/services/Tx.d.ts.map +1 -1
  47. package/dist/testing.d.ts +21 -3
  48. package/dist/testing.d.ts.map +1 -1
  49. package/dist/testing.js +30 -3
  50. package/dist/testing.js.map +1 -1
  51. package/docs/extensions.md +338 -25
  52. package/examples/extension-template/test/escrow.test.ts +91 -2
  53. package/package.json +3 -2
@@ -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. Normalize first
438
- (`normalizeSuiAddress`) or write the padded form.
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
- ### Non-plain values are leaves, and plain ones are not
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
- The face maps plain object literals recursively and passes everything else
892
- through: a `BcsType`, a `Schema.Class` instance, a `Date` anything with a
893
- prototype of its own — arrives whole, in the type and at runtime alike. So
894
- exposing a codec or a domain class as a member is safe, and a "namespace" must
895
- be a plain object literal to be mapped as one.
1005
+ For **any other class instance** a `Schema.Class` instance, a policy object,
1006
+ anything with methods of its ownsay 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
- The other half of that rule is the trap. A **plain-object value** member —
898
- `deployment: { packageId }` — is indistinguishable from a namespace of members,
899
- so the type maps it as the value while the **cold** face treats it as a
900
- namespace and hands back a placeholder for `deployment.packageId`. Reading that
901
- placeholder throws `ExtensionNotReady` naming the path, so the disagreement is
902
- typed and named rather than silent, but it is still a disagreement. Either
903
- register `warm` (or `await $ready()`), or expose the value through an `Effect`
904
- member, or give it a prototype of its own. Do not put a plain-object value
905
- member on a service that consumers will register lazily.
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, and
1087
- `SuiGraphQL.query(run, method?)` is the one call that sorts out the two
1088
- failures:
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 }`. That is
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 plain object member on it. Write the
1375
- interface before you move any code: it is the only artefact the conversion is
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.0",
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",