@arkiv-network/sdk 0.7.0-dev.1 → 0.8.0-dev.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 (210) hide show
  1. package/README.md +29 -16
  2. package/dist/attr/index.cjs +34 -0
  3. package/dist/attr/index.d.cts +2 -0
  4. package/dist/attr/index.d.ts +2 -0
  5. package/dist/attr/index.js +3 -0
  6. package/dist/attr-BDZ-APg5.js +809 -0
  7. package/dist/attr-BDZ-APg5.js.map +1 -0
  8. package/dist/attr-BvxHN99W.cjs +1029 -0
  9. package/dist/chains/index.cjs +2 -3
  10. package/dist/chains/index.d.cts +22 -60
  11. package/dist/chains/index.d.cts.map +1 -1
  12. package/dist/chains/index.d.ts +22 -60
  13. package/dist/chains/index.d.ts.map +1 -1
  14. package/dist/chains/index.js +2 -2
  15. package/dist/chains-BJrZs6sG.js +57 -0
  16. package/dist/chains-BJrZs6sG.js.map +1 -0
  17. package/dist/chains-BYRTTipa.cjs +73 -0
  18. package/dist/changeOwnership-B7bEgla4.d.cts +509 -0
  19. package/dist/changeOwnership-B7bEgla4.d.cts.map +1 -0
  20. package/dist/changeOwnership-DwgtdSFc.d.ts +509 -0
  21. package/dist/changeOwnership-DwgtdSFc.d.ts.map +1 -0
  22. package/dist/entity/index.cjs +19 -0
  23. package/dist/entity/index.d.cts +4 -0
  24. package/dist/entity/index.d.ts +4 -0
  25. package/dist/entity/index.js +6 -0
  26. package/dist/entity-B0t6sN7F.cjs +71 -0
  27. package/dist/entity-BIp_df9u.js +67 -0
  28. package/dist/entity-BIp_df9u.js.map +1 -0
  29. package/dist/entity-CFhbAv0R.js +70 -0
  30. package/dist/entity-CFhbAv0R.js.map +1 -0
  31. package/dist/entity-wfBnZd91.cjs +98 -0
  32. package/dist/errors-D-eJkGVc.cjs +56 -0
  33. package/dist/errors-DGB8AgT2.js +33 -0
  34. package/dist/errors-DGB8AgT2.js.map +1 -0
  35. package/dist/expiry-BUYIXTGb.cjs +135 -0
  36. package/dist/expiry-BxOlvVWC.d.ts +79 -0
  37. package/dist/expiry-BxOlvVWC.d.ts.map +1 -0
  38. package/dist/expiry-DGRzrSan.js +95 -0
  39. package/dist/expiry-DGRzrSan.js.map +1 -0
  40. package/dist/expiry-DRV29F1r.d.cts +79 -0
  41. package/dist/expiry-DRV29F1r.d.cts.map +1 -0
  42. package/dist/flags-0Vl5rrHq.d.cts +41 -0
  43. package/dist/flags-0Vl5rrHq.d.cts.map +1 -0
  44. package/dist/flags-Ct8k0gpB.cjs +43 -0
  45. package/dist/flags-ZtlvOIIk.js +33 -0
  46. package/dist/flags-ZtlvOIIk.js.map +1 -0
  47. package/dist/flags-wmcMoAnN.d.ts +41 -0
  48. package/dist/flags-wmcMoAnN.d.ts.map +1 -0
  49. package/dist/index-Cgw1u5aY.d.cts +278 -0
  50. package/dist/index-Cgw1u5aY.d.cts.map +1 -0
  51. package/dist/index-ClgIuxJ8.d.ts +159 -0
  52. package/dist/index-ClgIuxJ8.d.ts.map +1 -0
  53. package/dist/index-D0CmtiWT.d.cts +159 -0
  54. package/dist/index-D0CmtiWT.d.cts.map +1 -0
  55. package/dist/index-D8LZt-3J.d.cts +417 -0
  56. package/dist/index-D8LZt-3J.d.cts.map +1 -0
  57. package/dist/index-DVYFaXNV.d.ts +417 -0
  58. package/dist/index-DVYFaXNV.d.ts.map +1 -0
  59. package/dist/index-VCqgn29D.d.ts +278 -0
  60. package/dist/index-VCqgn29D.d.ts.map +1 -0
  61. package/dist/index.cjs +676 -375
  62. package/dist/index.d.cts +247 -208
  63. package/dist/index.d.cts.map +1 -1
  64. package/dist/index.d.ts +247 -208
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +626 -368
  67. package/dist/index.js.map +1 -1
  68. package/dist/payload-Bnw1hrvb.cjs +83 -0
  69. package/dist/payload-CIxSn_DW.js +67 -0
  70. package/dist/payload-CIxSn_DW.js.map +1 -0
  71. package/dist/payload-Dgx3PQeK.d.ts +94 -0
  72. package/dist/payload-Dgx3PQeK.d.ts.map +1 -0
  73. package/dist/payload-P0nWyjLg.d.cts +94 -0
  74. package/dist/payload-P0nWyjLg.d.cts.map +1 -0
  75. package/dist/query/index.cjs +24 -150
  76. package/dist/query/index.d.cts +5 -3
  77. package/dist/query/index.d.ts +5 -3
  78. package/dist/query/index.js +6 -138
  79. package/dist/queryBuilder-BXDvX1K_.cjs +1123 -0
  80. package/dist/queryBuilder-CbLL4pG5.js +963 -0
  81. package/dist/queryBuilder-CbLL4pG5.js.map +1 -0
  82. package/dist/selection-BvK6yX0g.d.cts +433 -0
  83. package/dist/selection-BvK6yX0g.d.cts.map +1 -0
  84. package/dist/selection-fpmI3ADQ.d.ts +433 -0
  85. package/dist/selection-fpmI3ADQ.d.ts.map +1 -0
  86. package/dist/types/index.cjs +3 -3
  87. package/dist/types/index.d.cts +7 -2
  88. package/dist/types/index.d.ts +7 -2
  89. package/dist/types/index.js +3 -3
  90. package/dist/utils/index.cjs +4 -17
  91. package/dist/utils/index.d.cts +3 -17
  92. package/dist/utils/index.d.ts +3 -17
  93. package/dist/utils/index.js +4 -18
  94. package/package.json +26 -5
  95. package/src/actions/public/getEntity.ts +27 -43
  96. package/src/actions/public/getEntityNonce.ts +30 -0
  97. package/src/actions/public/predictEntityKeys.test.ts +143 -0
  98. package/src/actions/public/predictEntityKeys.ts +138 -0
  99. package/src/actions/public/query.ts +33 -69
  100. package/src/actions/public/watchEntityEvents.test.ts +414 -0
  101. package/src/actions/public/watchEntityEvents.ts +225 -0
  102. package/src/actions/wallet/changeOwnership.ts +2 -2
  103. package/src/actions/wallet/createEntity.ts +99 -24
  104. package/src/actions/wallet/extendEntity.ts +37 -13
  105. package/src/actions/wallet/mutateEntities.test.ts +76 -0
  106. package/src/actions/wallet/mutateEntities.ts +19 -21
  107. package/src/actions/wallet/patchEntity.ts +90 -0
  108. package/src/attr/attributes.test.ts +120 -0
  109. package/src/attr/attributes.ts +190 -0
  110. package/src/attr/codec.test.ts +162 -0
  111. package/src/attr/codec.ts +220 -0
  112. package/src/attr/decimal.ts +86 -0
  113. package/src/attr/errors.ts +145 -0
  114. package/src/attr/index.ts +50 -0
  115. package/src/attr/names.ts +106 -0
  116. package/src/attr/types.ts +150 -0
  117. package/src/attr/values.test.ts +271 -0
  118. package/src/attr/values.ts +502 -0
  119. package/src/chains/cheesecake.ts +22 -0
  120. package/src/chains/index.ts +1 -2
  121. package/src/clients/createPublicClient.test.ts +3 -5
  122. package/src/clients/createPublicClient.ts +5 -4
  123. package/src/clients/createWalletClient.test.ts +2 -2
  124. package/src/clients/createWalletClient.ts +13 -7
  125. package/src/clients/decorators/arkivPublic.ts +160 -139
  126. package/src/clients/decorators/arkivWallet.ts +86 -94
  127. package/src/entity/create.test.ts +184 -0
  128. package/src/entity/errors.ts +38 -0
  129. package/src/entity/events.ts +16 -0
  130. package/src/entity/expiry.test.ts +195 -0
  131. package/src/entity/expiry.ts +181 -0
  132. package/src/entity/flags.ts +65 -0
  133. package/src/entity/index.ts +14 -0
  134. package/src/entity/key.ts +68 -0
  135. package/src/entity/operations.ts +134 -0
  136. package/src/entity/params.ts +15 -0
  137. package/src/errors.ts +26 -49
  138. package/src/index.ts +6 -0
  139. package/src/query/engine.ts +59 -139
  140. package/src/query/errors.ts +151 -0
  141. package/src/query/expression.test.ts +286 -0
  142. package/src/query/expression.ts +493 -0
  143. package/src/query/index.ts +40 -4
  144. package/src/query/injection.test.ts +151 -0
  145. package/src/query/queryBuilder.test.ts +322 -637
  146. package/src/query/queryBuilder.ts +126 -359
  147. package/src/query/queryResult.ts +54 -51
  148. package/src/query/selection.test.ts +48 -98
  149. package/src/query/selection.ts +77 -65
  150. package/src/types/entity.test.ts +44 -155
  151. package/src/types/entity.ts +60 -52
  152. package/src/types/events.ts +59 -16
  153. package/src/types/index.ts +20 -17
  154. package/src/types/rpcSchema.ts +69 -37
  155. package/src/utils/arkivTransactions.test.ts +600 -155
  156. package/src/utils/arkivTransactions.ts +181 -190
  157. package/src/utils/chains.test.ts +21 -24
  158. package/src/utils/compression.ts +1 -1
  159. package/src/utils/entities.test.ts +62 -0
  160. package/src/utils/entities.ts +109 -37
  161. package/src/utils/expirationTime.ts +114 -10
  162. package/src/utils/logger.ts +2 -2
  163. package/src/utils/revert.test.ts +99 -0
  164. package/src/utils/revert.ts +145 -0
  165. package/dist/chains-BifaY04V.cjs +0 -108
  166. package/dist/chains-CrtkVbBh.js +0 -86
  167. package/dist/chains-CrtkVbBh.js.map +0 -1
  168. package/dist/consts-Bz0LZQZY.js +0 -7
  169. package/dist/consts-Bz0LZQZY.js.map +0 -1
  170. package/dist/consts-CxT1-RXU.cjs +0 -18
  171. package/dist/entity-BhSXaB23.cjs +0 -81
  172. package/dist/entity-CN41IOrq.d.cts +0 -401
  173. package/dist/entity-CN41IOrq.d.cts.map +0 -1
  174. package/dist/entity-DGfVyB-a.js +0 -71
  175. package/dist/entity-DGfVyB-a.js.map +0 -1
  176. package/dist/entity-NnEVlePN.d.ts +0 -401
  177. package/dist/entity-NnEVlePN.d.ts.map +0 -1
  178. package/dist/index-ClkpS6s_.d.ts +0 -481
  179. package/dist/index-ClkpS6s_.d.ts.map +0 -1
  180. package/dist/index-w4qop0Pe.d.cts +0 -481
  181. package/dist/index-w4qop0Pe.d.cts.map +0 -1
  182. package/dist/payload-BpNUPOnY.js +0 -13
  183. package/dist/payload-BpNUPOnY.js.map +0 -1
  184. package/dist/payload-C2KHOUoj.d.ts +0 -6
  185. package/dist/payload-C2KHOUoj.d.ts.map +0 -1
  186. package/dist/payload-CJN8fTvX.d.cts +0 -6
  187. package/dist/payload-CJN8fTvX.d.cts.map +0 -1
  188. package/dist/payload-D8YLKcJj.cjs +0 -23
  189. package/dist/query/index.js.map +0 -1
  190. package/dist/queryBuilder-BE_XhLLa.js +0 -735
  191. package/dist/queryBuilder-BE_XhLLa.js.map +0 -1
  192. package/dist/queryBuilder-J37buGCi.cjs +0 -865
  193. package/dist/utils/index.d.cts.map +0 -1
  194. package/dist/utils/index.d.ts.map +0 -1
  195. package/dist/utils/index.js.map +0 -1
  196. package/src/actions/public/buildQuery.ts +0 -10
  197. package/src/actions/public/subscribeEntityEvents.ts +0 -93
  198. package/src/actions/wallet/updateEntity.ts +0 -59
  199. package/src/chains/braga.ts +0 -26
  200. package/src/chains/kaolin.ts +0 -29
  201. package/src/query/engine.test.ts +0 -571
  202. package/src/query/predicate.test.ts +0 -461
  203. package/src/query/predicate.ts +0 -144
  204. package/src/query/queryResult.test.ts +0 -270
  205. package/src/query/selectQueryBuilder.test.ts +0 -213
  206. package/src/types/attributes.ts +0 -28
  207. package/src/utils/validation.test.ts +0 -106
  208. package/src/utils/validation.ts +0 -80
  209. /package/dist/{types-Ci4BRClw.js → types-Bx1EKe0v.js} +0 -0
  210. /package/dist/{types-a8gm_IaQ.cjs → types-CKPddlfS.cjs} +0 -0
@@ -1,43 +1,109 @@
1
1
  import type { Hash, Hex } from "viem"
2
+ import type { AttributeInputs } from "../../attr"
2
3
  import type { ArkivClient } from "../../clients/baseClient"
3
- import type { Attribute, MimeType, TxParams } from "../../types"
4
+ import type { CreationFlags, Expiry } from "../../entity"
5
+ import { EntityMutationError } from "../../errors"
6
+ import type { MimeType, TxParams } from "../../types"
4
7
  import { sendArkivTransaction } from "../../utils/arkivTransactions"
5
8
  import { getLogger } from "../../utils/logger"
6
9
 
7
10
  const logger = getLogger("actions:wallet:create-entity")
8
11
 
9
12
  /**
10
- * Parameters for the createEntity function.
11
- * - payload: The payload of the entity.
12
- * - attributes: The attributes of the entity. Attribute values may be strings
13
- * or numbers, but numeric values **must be integers**. To store a non-integer,
14
- * scale it to an integer (e.g. `1.5` -> `1500`) to preserve numeric ordering,
15
- * or pass it as a string (e.g. `"1.5"`). A non-integer numeric value throws an
16
- * {@link InvalidAttributeError}.
17
- * - contentType: The content type of the entity.
18
- * - expiresIn: How long until the entity expires, in seconds. Because Arkiv
19
- * measures expiration in blocks (1 block = 2 seconds), this **must be a
20
- * positive integer and a multiple of the block time (2 seconds)**
21
- * Invalid values throw an {@link InvalidExpirationError}.
13
+ * Parameters for creating an entity.
14
+ *
15
+ * An entity always carries contents and a type for them, plus a lifetime. Attributes, flags and a
16
+ * salt are optional on top of that.
17
+ *
18
+ * @example
19
+ * const { entityKey } = await client.createEntity({
20
+ * attributes: {
21
+ * level: i32(10),
22
+ * balance: u256(1_000_000n),
23
+ * score: dec("3.5"),
24
+ * name: "Bob", // bare string -> str
25
+ * flagged: true, // bare boolean -> bool
26
+ * },
27
+ * payload: jsonToPayload({ hello: "world" }),
28
+ * contentType: "application/json",
29
+ * expires: ExpirationTime.fromDays(30),
30
+ * flags: { readonly: true },
31
+ * })
32
+ *
33
+ * @example Pin an absolute deadline, but refuse to create something nearly dead:
34
+ * await client.createEntity({
35
+ * payload, contentType: "application/json",
36
+ * expires: ExpirationTime.atDate(someDeadline, {
37
+ * atLeast: ExpirationTime.fromDays(1), // ...but live at least a day regardless
38
+ * }),
39
+ * })
40
+ *
41
+ * @example An entity that is nothing but queryable attributes still declares its (empty) contents:
42
+ * await client.createEntity({
43
+ * attributes: { parent: key(parentKey), rank: i32(3) },
44
+ * payload: new Uint8Array(),
45
+ * contentType: "application/octet-stream",
46
+ * expires: ExpirationTime.fromDays(30),
47
+ * })
22
48
  */
23
49
  export type CreateEntityParameters = {
50
+ /**
51
+ * How long the entity should live, built with {@link ExpirationTime}.
52
+ *
53
+ * `ExpirationTime.fromDays(30)` is the everyday form. `atBlock` / `atDate` pin an absolute
54
+ * deadline instead, and either can carry `{ atLeast }` to guarantee a minimum life on top of it.
55
+ */
56
+ expires: Expiry
57
+ /**
58
+ * The entity's opaque payload. Travels to the engine as the `$payload` system attribute; on this
59
+ * surface it is just the entity's contents.
60
+ *
61
+ * Pass an empty `Uint8Array` for an entity that is nothing but queryable attributes — it is
62
+ * written as an empty payload rather than left unset, so what you pass is what is stored.
63
+ */
24
64
  payload: Uint8Array
25
- /** Entity attributes. Numeric values must be integers (scale non-integers, e.g. `1.5` -> `1500`, or use a string). Throws {@link InvalidAttributeError} otherwise. */
26
- attributes: Attribute[]
27
- contentType: MimeType | string
28
- /** Seconds until expiry. Must be a positive integer and a multiple of the 2s block time.
29
- * Throws {@link InvalidExpirationError} otherwise. */
30
- expiresIn: number
65
+ /** The payload's MIME type, e.g. `"application/json"`. Sent as the `$contentType` cell. */
66
+ contentType: MimeType | (string & {})
67
+ /**
68
+ * The entity's queryable attributes, keyed by name. Values are the tagged constructors from
69
+ * `@arkiv-network/sdk/attr` — `i32`, `u256`, `dec`, `str`, `addr`, `key`, `bytes32`, `bool` — or
70
+ * a bare `boolean`, `number`, `bigint` or `string` where the type is unambiguous.
71
+ *
72
+ * @throws {InvalidAttributeNameError} If a name violates the attribute-name grammar.
73
+ * @throws {InvalidValueError} If a value does not fit the type it names or defaults to.
74
+ */
75
+ attributes?: AttributeInputs | undefined
76
+ /**
77
+ * Properties fixed at creation: `readonly` and `permissionlessExtension`. No operation changes
78
+ * them afterwards, so an entity is whatever it was created as for life. Both default to `false`.
79
+ */
80
+ flags?: CreationFlags | undefined
81
+ /**
82
+ * The salt mixed into the entity key. Defaults to 128 random bits, which makes the key
83
+ * unpredictable to everyone but the creator.
84
+ *
85
+ * Pass `0n` for a key derived from the owner and nonce alone — predictable by anyone, which is
86
+ * what you want only when a third party must be able to compute the key in advance.
87
+ */
88
+ salt?: bigint | undefined
31
89
  }
32
90
 
33
91
  /**
34
- * Return type for the createEntity function.
35
- * - entityKey: The key of the entity.
36
- * - txHash: The transaction hash.
92
+ * The result of creating an entity.
37
93
  */
38
94
  export type CreateEntityReturnType = {
95
+ /** The new entity's key. */
39
96
  entityKey: Hex
40
97
  txHash: Hash
98
+ /**
99
+ * The block the entity is expected to expire at, resolved against the block the transaction was
100
+ * built on.
101
+ *
102
+ * The engine resolves a duration against the block the transaction actually lands in, so for
103
+ * `ExpirationTime.fromDays(30)` and friends this is a lower bound: the real expiry is later by
104
+ * however many blocks passed between building and inclusion. An `atBlock` deadline is exact.
105
+ */
106
+ expiresAt: bigint
41
107
  }
42
108
 
43
109
  export async function createEntity(
@@ -46,7 +112,7 @@ export async function createEntity(
46
112
  txParams?: TxParams,
47
113
  ): Promise<CreateEntityReturnType> {
48
114
  logger("createEntity %o", data)
49
- const { receipt, createdEntityKeys } = await sendArkivTransaction(
115
+ const { receipt, createdEntityKeys, createdExpiries } = await sendArkivTransaction(
50
116
  client,
51
117
  { creates: [data] },
52
118
  txParams,
@@ -54,8 +120,17 @@ export async function createEntity(
54
120
 
55
121
  logger("Receipt from createEntity %o", receipt)
56
122
 
123
+ const [entityKey] = createdEntityKeys
124
+ const [expiresAt] = createdExpiries
125
+ if (entityKey === undefined || expiresAt === undefined) {
126
+ throw new EntityMutationError(
127
+ `Transaction ${receipt.transactionHash} succeeded, but the receipt did not carry the expected entity key or expiry.`,
128
+ )
129
+ }
130
+
57
131
  return {
58
132
  txHash: receipt.transactionHash as Hash,
59
- entityKey: createdEntityKeys[0],
133
+ entityKey,
134
+ expiresAt,
60
135
  }
61
136
  }
@@ -1,5 +1,7 @@
1
1
  import type { Hash, Hex } from "viem"
2
2
  import type { ArkivClient } from "../../clients/baseClient"
3
+ import type { Expiry } from "../../entity"
4
+ import { EntityMutationError } from "../../errors"
3
5
  import type { TxParams } from "../../types"
4
6
  import { sendArkivTransaction } from "../../utils/arkivTransactions"
5
7
  import { getLogger } from "../../utils/logger"
@@ -7,28 +9,38 @@ import { getLogger } from "../../utils/logger"
7
9
  const logger = getLogger("actions:wallet:extend-entity")
8
10
 
9
11
  /**
10
- * Parameters for the extendEntity function.
11
- * - entityKey: The key of the entity to extend.
12
- * - expiresIn: How long to extend the entity by, in seconds. Because Arkiv
13
- * measures expiration in blocks (1 block = 2 seconds), this **must be a
14
- * positive integer and a multiple of the block time (2 seconds)**.
15
- * Invalid values throw an {@link InvalidExpirationError}.
12
+ * Parameters for extending an entity's life.
13
+ *
14
+ * An extension **sets a new expiry**; it does not add to the existing one. It is resolved exactly
15
+ * as a create's is — a duration counts from now, not from the entity's current expiry — so
16
+ * `ExpirationTime.fromDays(30)` means "alive for thirty more days from now" whatever the entity's
17
+ * remaining life happened to be.
18
+ *
19
+ * The engine rejects an extension that would not move the expiry later, so extending an entity by
20
+ * less than it already has left is an error rather than a silent shortening.
21
+ *
22
+ * @example
23
+ * await client.extendEntity({ entityKey, expires: ExpirationTime.fromDays(30) })
24
+ * await client.extendEntity({ entityKey, expires: ExpirationTime.atBlock(1_200_000n) })
16
25
  */
17
26
  export type ExtendEntityParameters = {
18
27
  entityKey: Hex
19
- /** Seconds to extend by. Must be a positive integer and a multiple of the 2s block time.
20
- * Throws {@link InvalidExpirationError} otherwise. */
21
- expiresIn: number
28
+ /** The entity's new lifetime, built with {@link ExpirationTime}. */
29
+ expires: Expiry
22
30
  }
23
31
 
24
32
  /**
25
- * Return type for the extendEntity function.
26
- * - entityKey: The key of the entity.
27
- * - txHash: The transaction hash.
33
+ * The result of extending an entity.
28
34
  */
29
35
  export type ExtendEntityReturnType = {
30
36
  entityKey: Hex
31
37
  txHash: Hash
38
+ /**
39
+ * The block the entity is now expected to expire at, resolved against the block the transaction
40
+ * was built on. Durations are approximate, so treat this as the estimate the operation was built
41
+ * from rather than the block the engine settled on.
42
+ */
43
+ expiresAt: bigint
32
44
  }
33
45
 
34
46
  export async function extendEntity(
@@ -37,12 +49,24 @@ export async function extendEntity(
37
49
  txParams?: TxParams,
38
50
  ): Promise<ExtendEntityReturnType> {
39
51
  logger("extendEntity %o", data)
40
- const { receipt } = await sendArkivTransaction(client, { extensions: [data] }, txParams)
52
+ const { receipt, extendedExpiries } = await sendArkivTransaction(
53
+ client,
54
+ { extensions: [data] },
55
+ txParams,
56
+ )
41
57
 
42
58
  logger("Receipt from extendEntity %o", receipt)
43
59
 
60
+ const [expiresAt] = extendedExpiries
61
+ if (expiresAt === undefined) {
62
+ throw new EntityMutationError(
63
+ `Transaction ${receipt.transactionHash} succeeded, but the receipt did not carry the expected entity expiry.`,
64
+ )
65
+ }
66
+
44
67
  return {
45
68
  txHash: receipt.transactionHash as Hash,
46
69
  entityKey: data.entityKey,
70
+ expiresAt,
47
71
  }
48
72
  }
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it, vi } from "bun:test"
2
+ import type { ArkivClient } from "../../clients/baseClient"
3
+ import { mutateEntities } from "./mutateEntities"
4
+
5
+ const ENTITY_KEY = `0x${"ab".repeat(32)}` as const
6
+ const NEW_OWNER = "0x2222222222222222222222222222222222222222"
7
+
8
+ function makeClient() {
9
+ const writeContract = vi.fn().mockResolvedValue("0xdeadbeef")
10
+ return {
11
+ client: {
12
+ account: { address: "0x1111111111111111111111111111111111111111" },
13
+ chain: { id: 1 },
14
+ getBlockNumber: vi.fn().mockResolvedValue(100n),
15
+ readContract: vi.fn().mockResolvedValue(7n),
16
+ writeContract,
17
+ waitForTransactionReceipt: vi.fn().mockResolvedValue({
18
+ status: "success",
19
+ transactionHash: "0xdeadbeef",
20
+ logs: [],
21
+ }),
22
+ } as unknown as ArkivClient,
23
+ writeContract,
24
+ }
25
+ }
26
+
27
+ describe("the empty-batch guard", () => {
28
+ it("counts every kind of operation, not just the first four", async () => {
29
+ // An ownership-only batch is a batch: it used to be rejected because the guard listed
30
+ // creates/updates/deletes/extensions and forgot ownershipChanges.
31
+ const { client, writeContract } = makeClient()
32
+ const result = await mutateEntities(client, {
33
+ ownershipChanges: [{ entityKey: ENTITY_KEY, newOwner: NEW_OWNER }],
34
+ })
35
+ expect(writeContract).toHaveBeenCalledTimes(1)
36
+ expect(result.ownershipChanges).toEqual([ENTITY_KEY])
37
+ })
38
+
39
+ it("rejects a batch with nothing in it", async () => {
40
+ const { client, writeContract } = makeClient()
41
+ await expect(mutateEntities(client, {})).rejects.toThrow(/No operations/)
42
+ // Present but empty is just as empty — the engine would revert with EmptyBatch().
43
+ await expect(mutateEntities(client, { deletes: [], ownershipChanges: [] })).rejects.toThrow(
44
+ /No operations/,
45
+ )
46
+ expect(writeContract).not.toHaveBeenCalled()
47
+ })
48
+
49
+ it("counts only real operation kinds, not whatever keys the caller passed", async () => {
50
+ const { client, writeContract } = makeClient()
51
+ // `updates` was this API's name for patches until they were renamed. Counting the caller's own
52
+ // keys would score this batch as non-empty and then drop it on the way to the wire, sending
53
+ // execute([]) — so the guard counts the five kinds that actually reach the transaction.
54
+ const stale = { updates: [{ entityKey: ENTITY_KEY, set: { level: 1 } }] } as never
55
+ await expect(mutateEntities(client, stale)).rejects.toThrow(/No operations/)
56
+ // A typo is the same mistake with a different name.
57
+ await expect(
58
+ mutateEntities(client, { delete: [{ entityKey: ENTITY_KEY }] } as never),
59
+ ).rejects.toThrow(/No operations/)
60
+ expect(writeContract).not.toHaveBeenCalled()
61
+ })
62
+
63
+ it("does not let a stale key smuggle itself into a batch that is otherwise valid", async () => {
64
+ const { client, writeContract } = makeClient()
65
+ // The dangerous shape: the creates apply and the transaction succeeds, so nothing surfaces the
66
+ // fact that the `updates` never happened. The guard cannot catch this one — only the caller's
67
+ // types can — but the result must at least not claim they did.
68
+ const result = await mutateEntities(client, {
69
+ deletes: [{ entityKey: ENTITY_KEY }],
70
+ updates: [{ entityKey: ENTITY_KEY, set: { level: 1 } }],
71
+ } as never)
72
+ expect(result.patchedEntities).toEqual([])
73
+ // Only the delete reached the wire, and the report says so.
74
+ expect(writeContract.mock.calls[0][0].args[0]).toHaveLength(1)
75
+ })
76
+ })
@@ -7,40 +7,42 @@ import type { ChangeOwnershipParameters } from "./changeOwnership"
7
7
  import type { CreateEntityParameters } from "./createEntity"
8
8
  import type { DeleteEntityParameters } from "./deleteEntity"
9
9
  import type { ExtendEntityParameters } from "./extendEntity"
10
- import type { UpdateEntityParameters } from "./updateEntity"
10
+ import type { PatchEntityParameters } from "./patchEntity"
11
11
 
12
12
  const logger = getLogger("actions:wallet:mutate-entities")
13
13
 
14
14
  /**
15
15
  * Parameters for the mutateEntities function.
16
- * - creates: The creates to perform.
17
- * - updates: The updates to perform.
18
- * - deletes: The deletes to perform.
19
- * - extensions: The extensions to perform.
16
+ *
17
+ * At least one operation is required; a batch with nothing in it throws rather than spending gas on
18
+ * an empty transaction.
20
19
  */
21
20
  export type MutateEntitiesParameters = {
21
+ /** The entities to create. */
22
22
  creates?: CreateEntityParameters[]
23
- updates?: UpdateEntityParameters[]
23
+ /** The patches to apply. */
24
+ patches?: PatchEntityParameters[]
25
+ /** The entities to delete. */
24
26
  deletes?: DeleteEntityParameters[]
27
+ /** The expiries to set. */
25
28
  extensions?: ExtendEntityParameters[]
29
+ /** The ownership transfers to perform. */
26
30
  ownershipChanges?: ChangeOwnershipParameters[]
27
31
  }
28
32
 
29
- /**
30
- * Return type for the mutateEntities function.
31
- * - txHash: The transaction hash.
32
- * - createdEntities: The keys of the created entities.
33
- * - updatedEntities: The keys of the updated entities.
34
- * - deletedEntities: The keys of the deleted entities.
35
- * - extendedEntities: The keys of the extended entities.
36
- * - ownershipChanges: The keys of the ownership changes.
37
- */
33
+ /** Return type for the mutateEntities function. */
38
34
  export type MutateEntitiesReturnType = {
35
+ /** The transaction hash. */
39
36
  txHash: Hash
37
+ /** The keys of the created entities, in batch order. */
40
38
  createdEntities: Hex[]
41
- updatedEntities: Hex[]
39
+ /** The keys of the patched entities. */
40
+ patchedEntities: Hex[]
41
+ /** The keys of the deleted entities. */
42
42
  deletedEntities: Hex[]
43
+ /** The keys of the extended entities. */
43
44
  extendedEntities: Hex[]
45
+ /** The keys of the entities handed to a new owner. */
44
46
  ownershipChanges: Hex[]
45
47
  }
46
48
 
@@ -49,10 +51,6 @@ export async function mutateEntities(
49
51
  data: MutateEntitiesParameters,
50
52
  txParams?: TxParams,
51
53
  ): Promise<MutateEntitiesReturnType> {
52
- if (!data.creates && !data.updates && !data.deletes && !data.extensions) {
53
- throw new Error("No operations to perform")
54
- }
55
-
56
54
  const { receipt, createdEntityKeys } = await sendArkivTransaction(client, data, txParams)
57
55
 
58
56
  logger("Receipt from mutateEntities %o", receipt)
@@ -60,7 +58,7 @@ export async function mutateEntities(
60
58
  return {
61
59
  txHash: receipt.transactionHash as Hash,
62
60
  createdEntities: createdEntityKeys,
63
- updatedEntities: (data.updates ?? []).map((u) => u.entityKey),
61
+ patchedEntities: (data.patches ?? []).map((p) => p.entityKey),
64
62
  deletedEntities: (data.deletes ?? []).map((d) => d.entityKey),
65
63
  extendedEntities: (data.extensions ?? []).map((e) => e.entityKey),
66
64
  ownershipChanges: (data.ownershipChanges ?? []).map((o) => o.entityKey),
@@ -0,0 +1,90 @@
1
+ import type { Hash, Hex } from "viem"
2
+ import type { AttributeInputs } from "../../attr"
3
+ import type { ArkivClient } from "../../clients/baseClient"
4
+ import type { MimeType, TxParams } from "../../types"
5
+ import { sendArkivTransaction } from "../../utils/arkivTransactions"
6
+ import { getLogger } from "../../utils/logger"
7
+
8
+ const logger = getLogger("actions:wallet:patch-entity")
9
+
10
+ /**
11
+ * Parameters for patching an entity.
12
+ *
13
+ * A patch names only what changes. Attributes it does not mention keep whatever value they have, and
14
+ * the payload and content type stay as they are unless given — so a caller can change one field
15
+ * without first reading the entity to learn what the others are, and without racing anyone else who
16
+ * is changing a different field.
17
+ *
18
+ * The entity's identity is fixed: a patch never moves the key, the owner, the creation flags or the
19
+ * expiry. Use `extendEntity` for the expiry and `changeOwnership` for the owner.
20
+ *
21
+ * @example Change one attribute and remove another, leaving the payload alone:
22
+ * await client.patchEntity({
23
+ * entityKey,
24
+ * set: { status: "published", level: i32(11) },
25
+ * unset: ["draft"],
26
+ * })
27
+ *
28
+ * @example Replace the contents without touching a single attribute:
29
+ * await client.patchEntity({
30
+ * entityKey,
31
+ * payload: jsonToPayload({ hello: "again" }),
32
+ * })
33
+ */
34
+ export type PatchEntityParameters = {
35
+ /** The entity to patch. */
36
+ entityKey: Hex
37
+ /**
38
+ * Attributes to write, keyed by name. An existing attribute is overwritten — including with a
39
+ * different type — and a name the entity does not carry yet is added.
40
+ *
41
+ * Values are the tagged constructors from `@arkiv-network/sdk/attr` — `i32`, `u256`, `dec`, `str`,
42
+ * `addr`, `key`, `bytes32`, `bool` — or a bare `boolean`, `number`, `bigint` or `string` where the
43
+ * type is unambiguous.
44
+ *
45
+ * @throws {InvalidAttributeNameError} If a name violates the attribute-name grammar.
46
+ * @throws {InvalidValueError} If a value does not fit the type it names or defaults to.
47
+ */
48
+ set?: AttributeInputs | undefined
49
+ /**
50
+ * Attribute names to remove. Naming one the entity does not carry is not an error — the outcome
51
+ * is the same either way, and a caller clearing a field should not have to know whether it was
52
+ * ever set.
53
+ *
54
+ * @throws {ConflictingMutationError} If a name also appears in `set`.
55
+ */
56
+ unset?: readonly string[] | undefined
57
+ /**
58
+ * The entity's new payload. Omit it to leave the current contents untouched; pass an empty
59
+ * `Uint8Array` to replace them with nothing.
60
+ */
61
+ payload?: Uint8Array | undefined
62
+ /**
63
+ * The payload's new MIME type. Omit it to keep the current one — which is what you want when the
64
+ * new payload is the same kind of thing as the old.
65
+ */
66
+ contentType?: MimeType | (string & {}) | undefined
67
+ }
68
+
69
+ /** The result of patching an entity. */
70
+ export type PatchEntityReturnType = {
71
+ /** The patched entity's key — unchanged, since a patch never moves it. */
72
+ entityKey: Hex
73
+ txHash: Hash
74
+ }
75
+
76
+ export async function patchEntity(
77
+ client: ArkivClient,
78
+ data: PatchEntityParameters,
79
+ txParams?: TxParams,
80
+ ): Promise<PatchEntityReturnType> {
81
+ logger("patchEntity %o", data)
82
+ const { receipt } = await sendArkivTransaction(client, { patches: [data] }, txParams)
83
+
84
+ logger("Receipt from patchEntity %o", receipt)
85
+
86
+ return {
87
+ txHash: receipt.transactionHash as Hash,
88
+ entityKey: data.entityKey,
89
+ }
90
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { encodeAttributes, encodeMutations, MAX_ATTRIBUTES, resolveAttributes } from "./attributes"
3
+ import { InvalidAttributeNameError, TooManyAttributesError } from "./errors"
4
+ import { isValidAttributeName, validateAttributeName } from "./names"
5
+ import { dec, i32, str, u256 } from "./values"
6
+
7
+ describe("resolveAttributes", () => {
8
+ it("types bare values and leaves tagged ones alone", () => {
9
+ const resolved = resolveAttributes({
10
+ level: 10,
11
+ balance: 1_000_000n,
12
+ name: "Bob",
13
+ flagged: true,
14
+ score: dec("3.5"),
15
+ })
16
+ expect(resolved.level).toEqual(i32(10))
17
+ expect(resolved.balance).toEqual(u256(1_000_000n))
18
+ expect(resolved.name).toEqual(str("Bob"))
19
+ expect(resolved.flagged.type).toBe("bool")
20
+ expect(resolved.score).toEqual(dec("3.5"))
21
+ })
22
+
23
+ it("reports the attribute count when there are too many", () => {
24
+ const tooMany = Object.fromEntries(
25
+ Array.from({ length: MAX_ATTRIBUTES + 1 }, (_, i) => [`a${i}`, i]),
26
+ )
27
+ expect(() => resolveAttributes(tooMany)).toThrow(TooManyAttributesError)
28
+ expect(() => resolveAttributes(tooMany)).toThrow(/at most 32 attributes, got 33/)
29
+ })
30
+ })
31
+
32
+ describe("the attribute cap", () => {
33
+ const attributes = (count: number) =>
34
+ Object.fromEntries(Array.from({ length: count }, (_, i) => [`a${i}`, i]))
35
+
36
+ it("counts the payload and content-type cells, which the engine also counts", () => {
37
+ // Under the cap on its own, over it once the two system cells are appended. Letting this
38
+ // through would spend gas on a transaction the engine answers with TooManyAttributes(34, 32).
39
+ expect(() =>
40
+ encodeAttributes(attributes(MAX_ATTRIBUTES), {
41
+ payload: new Uint8Array([1]),
42
+ contentType: "application/json",
43
+ }),
44
+ ).toThrow(/at most 32 attributes, got 34/)
45
+ })
46
+
47
+ it("leaves room for them rather than rejecting a full-but-legal operation", () => {
48
+ expect(
49
+ encodeAttributes(attributes(MAX_ATTRIBUTES - 2), {
50
+ payload: new Uint8Array([1]),
51
+ contentType: "application/json",
52
+ }),
53
+ ).toHaveLength(MAX_ATTRIBUTES)
54
+ })
55
+
56
+ it("counts tombstones in a patch — a removal occupies a cell like any other", () => {
57
+ expect(() =>
58
+ encodeMutations({
59
+ set: attributes(MAX_ATTRIBUTES - 1),
60
+ unset: ["gone_a", "gone_b"],
61
+ }),
62
+ ).toThrow(/at most 32 attributes, got 33/)
63
+ })
64
+ })
65
+
66
+ describe("encodeAttributes", () => {
67
+ it("sorts strictly ascending by the encoded name, whatever order they were written in", () => {
68
+ const encoded = encodeAttributes({ zebra: 1, apple: 2, mango: 3 })
69
+ const names = encoded.map((attribute) => attribute.name)
70
+ expect(names).toEqual([...names].sort())
71
+ expect(encoded).toHaveLength(3)
72
+ })
73
+
74
+ it("carries each value's typeId", () => {
75
+ const ids = encodeAttributes({ level: i32(1), score: dec("1"), tag: "x" }).map((a) => a.typeId)
76
+ expect([...ids].sort()).toEqual([2, 5, 8]) // i32, dec, str
77
+ })
78
+
79
+ it("accepts an empty attribute set", () => {
80
+ expect(encodeAttributes({})).toEqual([])
81
+ })
82
+ })
83
+
84
+ describe("validateAttributeName", () => {
85
+ it("accepts the Ident32 grammar, in either case", () => {
86
+ for (const name of ["a", "tag", "Tag", "projectId", "num_attr", "a.b-c_D9", "a".repeat(32)]) {
87
+ expect(isValidAttributeName(name)).toBe(true)
88
+ }
89
+ })
90
+
91
+ it("treats names as case-sensitive, so two casings are two attributes", () => {
92
+ const resolved = resolveAttributes({ level: 1, Level: 2 })
93
+ expect(resolved.level).toEqual(i32(1))
94
+ expect(resolved.Level).toEqual(i32(2))
95
+ expect(encodeAttributes({ level: 1, Level: 2 })).toHaveLength(2)
96
+ })
97
+
98
+ it("rejects names outside the grammar, pointing at the offending character", () => {
99
+ expect(() => validateAttributeName("")).toThrow(InvalidAttributeNameError)
100
+ expect(() => validateAttributeName("1tag")).toThrow(/starts with "1"/)
101
+ expect(() => validateAttributeName("_tag")).toThrow(/starts with "_"/)
102
+ expect(() => validateAttributeName("has space")).toThrow(/" " at position 3/)
103
+ expect(() => validateAttributeName("héllo")).toThrow(/"é" at position 1/)
104
+ expect(() => validateAttributeName("a:b")).toThrow(/":" at position 1/)
105
+ expect(() => validateAttributeName("a".repeat(33))).toThrow(/33 bytes long/)
106
+ })
107
+
108
+ it("reserves $ for the system attributes the engine sets", () => {
109
+ expect(() => validateAttributeName("$owner")).toThrow(/reserved for system attributes/)
110
+ })
111
+
112
+ it("rejects names the query language could never parse back, whatever their casing", () => {
113
+ for (const reserved of ["and", "OR", "Not", "key", "str", "u256", "EXISTS", "Bytes32"]) {
114
+ expect(() => validateAttributeName(reserved)).toThrow(/reserved by the query language/)
115
+ }
116
+ // Only the exact word is reserved — a name containing it is fine.
117
+ expect(isValidAttributeName("android")).toBe(true)
118
+ expect(isValidAttributeName("sortKey")).toBe(true)
119
+ })
120
+ })